screenhand 0.3.2 → 0.3.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/README.md +2 -2
- package/dist/mcp-desktop.js +490 -96
- package/dist/src/community/fetcher.js +32 -2
- package/dist/src/community/validator.js +15 -1
- package/dist/src/context-tracker.js +115 -43
- package/dist/src/ingestion/reference-merger.js +3 -1
- package/dist/src/learning/engine.js +225 -7
- package/dist/src/learning/locator-policy.js +16 -0
- package/dist/src/learning/pattern-policy.js +9 -0
- package/dist/src/learning/recovery-policy.js +16 -0
- package/dist/src/learning/sensor-policy.js +9 -0
- package/dist/src/learning/timing-model.js +62 -0
- package/dist/src/memory/research.js +7 -1
- package/dist/src/memory/store.js +18 -7
- package/dist/src/perception/coordinator.js +304 -4
- package/dist/src/perception/manager.js +13 -0
- package/dist/src/perception/vision-source.js +14 -4
- package/dist/src/planner/executor.js +125 -2
- package/dist/src/planner/planner.js +509 -10
- package/dist/src/playbook/engine.js +10 -0
- package/dist/src/recovery/engine.js +50 -3
- package/dist/src/runtime/execution-contract.js +67 -5
- package/dist/src/runtime/executor.js +41 -1
- package/dist/src/runtime/service.js +7 -0
- package/dist/src/state/app-map.js +307 -17
- package/dist/src/util/atomic-write.js +25 -4
- package/dist-references/reddit.json +2 -2
- package/package.json +1 -1
|
@@ -80,6 +80,31 @@ function redactStrings(strings) {
|
|
|
80
80
|
* Atomic writes via writeFileAtomicSync + readJsonWithRecovery for
|
|
81
81
|
* crash safety.
|
|
82
82
|
*/
|
|
83
|
+
// Max page:: zones per app (separate from maxZonesPerApp to prevent title explosion)
|
|
84
|
+
const MAX_PAGE_ZONES = 20;
|
|
85
|
+
/**
|
|
86
|
+
* Normalize dynamic page context strings to prevent zone explosion.
|
|
87
|
+
* Collapses UUIDs, timestamps, numeric IDs, file extensions, and hashes
|
|
88
|
+
* into stable placeholders so similar pages share a zone.
|
|
89
|
+
*/
|
|
90
|
+
function normalizePageContext(ctx) {
|
|
91
|
+
return ctx
|
|
92
|
+
// UUIDs: 8-4-4-4-12 hex
|
|
93
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
|
|
94
|
+
// Long hex hashes (8+ chars)
|
|
95
|
+
.replace(/\b[0-9a-f]{8,}\b/gi, "<hash>")
|
|
96
|
+
// ISO timestamps
|
|
97
|
+
.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?/g, "<time>")
|
|
98
|
+
// Date-like patterns
|
|
99
|
+
.replace(/\d{4}[-/]\d{2}[-/]\d{2}/g, "<date>")
|
|
100
|
+
// Standalone numeric IDs (3+ digits)
|
|
101
|
+
.replace(/\b\d{3,}\b/g, "<num>")
|
|
102
|
+
// File extensions at the end
|
|
103
|
+
.replace(/\.\w{1,5}$/, "")
|
|
104
|
+
// Collapse whitespace
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
83
108
|
export class AppMap {
|
|
84
109
|
config;
|
|
85
110
|
cache = new Map();
|
|
@@ -87,6 +112,8 @@ export class AppMap {
|
|
|
87
112
|
saveTimer = null;
|
|
88
113
|
/** Cache of auto-generated ladders (from reference files) */
|
|
89
114
|
generatedLadderCache = new Map();
|
|
115
|
+
/** Wire #11: TopologyPolicy reference for Bayesian edge scoring */
|
|
116
|
+
topologyPolicy = null;
|
|
90
117
|
constructor(config) {
|
|
91
118
|
this.config = {
|
|
92
119
|
...DEFAULT_APP_MAP_CONFIG,
|
|
@@ -98,6 +125,10 @@ export class AppMap {
|
|
|
98
125
|
init() {
|
|
99
126
|
fs.mkdirSync(this.config.mapsDir, { recursive: true });
|
|
100
127
|
}
|
|
128
|
+
/** Wire #11: Connect TopologyPolicy for Bayesian edge scoring */
|
|
129
|
+
setTopologyPolicy(tp) {
|
|
130
|
+
this.topologyPolicy = tp;
|
|
131
|
+
}
|
|
101
132
|
// ── Load / Save ───────────────────────────────────────────────────
|
|
102
133
|
load(bundleId) {
|
|
103
134
|
const cached = this.cache.get(bundleId);
|
|
@@ -285,6 +316,131 @@ export class AppMap {
|
|
|
285
316
|
this.save(data);
|
|
286
317
|
return data;
|
|
287
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Wire #12: L6→L7 — Bootstrap pre-built zones from a MenuScanner result.
|
|
321
|
+
* Creates toolbar zone + per-menu sub-zones so new apps start with structure.
|
|
322
|
+
* Only bootstraps if no map exists yet — never overwrites existing data.
|
|
323
|
+
* Capped at 10 zones to prevent menu-heavy apps from flooding.
|
|
324
|
+
*/
|
|
325
|
+
bootstrapFromMenuScan(bundleId, appName, scanResult) {
|
|
326
|
+
// Only bootstrap if no menu_bar zone exists yet — perception may have
|
|
327
|
+
// auto-created other zones, but menu structure is separate
|
|
328
|
+
const existing = this.load(bundleId);
|
|
329
|
+
if (existing?.zones["menu_bar"])
|
|
330
|
+
return false;
|
|
331
|
+
const data = existing ?? this.createEmpty(bundleId, appName);
|
|
332
|
+
const now = new Date().toISOString();
|
|
333
|
+
// Filter out Apple menu, empty titles, and disabled items
|
|
334
|
+
const topMenus = scanResult.menuTree.filter((m) => m.title && m.title !== "Apple" && m.enabled !== false);
|
|
335
|
+
let zoneCount = 0;
|
|
336
|
+
// Sanitize a menu title for use as zone key or element label
|
|
337
|
+
const sanitize = (title) => redactPII(title)
|
|
338
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\ufeff]/g, "")
|
|
339
|
+
.slice(0, 100);
|
|
340
|
+
// 1. Create toolbar zone with top-level menu names as elements
|
|
341
|
+
const toolbarElements = [];
|
|
342
|
+
for (let i = 0; i < topMenus.length && i < 15; i++) {
|
|
343
|
+
const menu = topMenus[i];
|
|
344
|
+
const safeTitle = sanitize(menu.title);
|
|
345
|
+
if (!safeTitle)
|
|
346
|
+
continue;
|
|
347
|
+
toolbarElements.push({
|
|
348
|
+
label: safeTitle,
|
|
349
|
+
relativeX: Math.min(0.95, 0.02 + i * 0.08),
|
|
350
|
+
relativeY: 0.02,
|
|
351
|
+
anchor: "top-left",
|
|
352
|
+
ocrBackup: safeTitle,
|
|
353
|
+
successCount: 0,
|
|
354
|
+
failCount: 0,
|
|
355
|
+
lastInteracted: now,
|
|
356
|
+
sessionsSinceUse: 0,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
data.zones["menu_bar"] = {
|
|
360
|
+
relativePosition: { top: 0, left: 0, width: 1, height: 0.04 },
|
|
361
|
+
type: "toolbar",
|
|
362
|
+
elements: toolbarElements,
|
|
363
|
+
verified: false,
|
|
364
|
+
lastSeen: now,
|
|
365
|
+
};
|
|
366
|
+
zoneCount++;
|
|
367
|
+
// 2. Create per-menu sub-zones with child items as elements
|
|
368
|
+
const seenZoneKeys = new Set();
|
|
369
|
+
for (let i = 0; i < topMenus.length && zoneCount < 10; i++) {
|
|
370
|
+
const menu = topMenus[i];
|
|
371
|
+
if (!menu.children || menu.children.length === 0)
|
|
372
|
+
continue;
|
|
373
|
+
// Filter disabled children
|
|
374
|
+
const enabledChildren = menu.children.filter((c) => c.title && c.enabled !== false);
|
|
375
|
+
if (enabledChildren.length === 0)
|
|
376
|
+
continue;
|
|
377
|
+
const menuElements = [];
|
|
378
|
+
for (let j = 0; j < enabledChildren.length && j < 20; j++) {
|
|
379
|
+
const child = enabledChildren[j];
|
|
380
|
+
const safeChildTitle = sanitize(child.title);
|
|
381
|
+
if (!safeChildTitle)
|
|
382
|
+
continue;
|
|
383
|
+
menuElements.push({
|
|
384
|
+
label: safeChildTitle,
|
|
385
|
+
relativeX: Math.min(0.95, 0.02 + i * 0.08),
|
|
386
|
+
relativeY: Math.min(0.95, 0.06 + j * 0.03),
|
|
387
|
+
anchor: "top-left",
|
|
388
|
+
ocrBackup: safeChildTitle,
|
|
389
|
+
successCount: 0,
|
|
390
|
+
failCount: 0,
|
|
391
|
+
lastInteracted: now,
|
|
392
|
+
sessionsSinceUse: 0,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// Sanitize zone key and deduplicate
|
|
396
|
+
const baseKey = `menu::${sanitize(menu.title).toLowerCase().replace(/\s+/g, "_")}`;
|
|
397
|
+
let zoneKey = baseKey;
|
|
398
|
+
if (seenZoneKeys.has(zoneKey)) {
|
|
399
|
+
zoneKey = `${baseKey}_${i}`;
|
|
400
|
+
}
|
|
401
|
+
seenZoneKeys.add(zoneKey);
|
|
402
|
+
// Skip if zone already exists (perception may have built it with verified data)
|
|
403
|
+
if (data.zones[zoneKey]) {
|
|
404
|
+
seenZoneKeys.add(zoneKey);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
data.zones[zoneKey] = {
|
|
408
|
+
relativePosition: {
|
|
409
|
+
top: 0.04,
|
|
410
|
+
left: Math.min(0.9, i * 0.08),
|
|
411
|
+
width: 0.15,
|
|
412
|
+
height: Math.min(0.5, enabledChildren.length * 0.03 + 0.02),
|
|
413
|
+
},
|
|
414
|
+
type: "menu",
|
|
415
|
+
elements: menuElements,
|
|
416
|
+
verified: false,
|
|
417
|
+
lastSeen: now,
|
|
418
|
+
};
|
|
419
|
+
zoneCount++;
|
|
420
|
+
}
|
|
421
|
+
// 3. Record initial feature mastery at depth 2 for menu-derived features
|
|
422
|
+
const featureMap = {
|
|
423
|
+
file: "file_management", edit: "editing", view: "view_control",
|
|
424
|
+
window: "window_management", help: "help_usage",
|
|
425
|
+
};
|
|
426
|
+
for (const menu of topMenus) {
|
|
427
|
+
const featureId = featureMap[menu.title.toLowerCase()];
|
|
428
|
+
if (featureId && !data.featureMastery[featureId]) {
|
|
429
|
+
data.featureMastery[featureId] = {
|
|
430
|
+
depth: 2,
|
|
431
|
+
confidence: 0.3,
|
|
432
|
+
repeatCount: 0,
|
|
433
|
+
workflowCount: 0,
|
|
434
|
+
healingCount: 0,
|
|
435
|
+
failCount: 0,
|
|
436
|
+
lastSeen: now,
|
|
437
|
+
lastVerified: null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.save(data);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
288
444
|
// ── Zone Operations ───────────────────────────────────────────────
|
|
289
445
|
addZone(bundleId, zoneKey, zone) {
|
|
290
446
|
const data = this.ensureLoaded(bundleId);
|
|
@@ -417,7 +573,7 @@ export class AppMap {
|
|
|
417
573
|
let zone = data.zones[zoneKey];
|
|
418
574
|
if (!zone && zoneKey === "auto") {
|
|
419
575
|
const targetZoneKey = pageContext
|
|
420
|
-
? `page::${pageContext}`
|
|
576
|
+
? `page::${normalizePageContext(pageContext)}`
|
|
421
577
|
: "auto_discovered";
|
|
422
578
|
// When page context is known, prefer the page-specific zone
|
|
423
579
|
// This ensures elements migrate OUT of auto_discovered into proper page zones
|
|
@@ -428,9 +584,9 @@ export class AppMap {
|
|
|
428
584
|
zone = pageZone;
|
|
429
585
|
}
|
|
430
586
|
else {
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
if (Object.keys(data.zones).length >= this.config.maxZonesPerApp) {
|
|
587
|
+
// Check page:: zone count separately to prevent title explosion
|
|
588
|
+
const pageZoneCount = Object.keys(data.zones).filter((k) => k.startsWith("page::")).length;
|
|
589
|
+
if (Object.keys(data.zones).length >= this.config.maxZonesPerApp || pageZoneCount >= MAX_PAGE_ZONES) {
|
|
434
590
|
// At zone limit — fall back to auto_discovered
|
|
435
591
|
zone = data.zones["auto_discovered"];
|
|
436
592
|
if (!zone) {
|
|
@@ -491,8 +647,8 @@ export class AppMap {
|
|
|
491
647
|
return;
|
|
492
648
|
el = {
|
|
493
649
|
label,
|
|
494
|
-
relativeX:
|
|
495
|
-
relativeY:
|
|
650
|
+
relativeX: -1,
|
|
651
|
+
relativeY: -1,
|
|
496
652
|
anchor: "top-left",
|
|
497
653
|
ocrBackup: label,
|
|
498
654
|
successCount: 0,
|
|
@@ -620,16 +776,17 @@ export class AppMap {
|
|
|
620
776
|
}
|
|
621
777
|
/**
|
|
622
778
|
* Find the contract for an element across all zones.
|
|
623
|
-
*
|
|
779
|
+
* When action is provided, only returns contracts matching that action type.
|
|
780
|
+
* Falls back to any-action match when action is omitted.
|
|
624
781
|
*/
|
|
625
|
-
getContract(bundleId, elementLabel) {
|
|
782
|
+
getContract(bundleId, elementLabel, action) {
|
|
626
783
|
const data = this.ensureLoaded(bundleId);
|
|
627
784
|
if (!data)
|
|
628
785
|
return null;
|
|
629
786
|
for (const [zoneKey, zone] of Object.entries(data.zones)) {
|
|
630
787
|
if (!zone.contracts)
|
|
631
788
|
continue;
|
|
632
|
-
const contract = zone.contracts.find((c) => c.elementLabel === elementLabel);
|
|
789
|
+
const contract = zone.contracts.find((c) => c.elementLabel === elementLabel && (action == null || c.action === action));
|
|
633
790
|
if (contract)
|
|
634
791
|
return { zone: zoneKey, contract };
|
|
635
792
|
}
|
|
@@ -750,12 +907,28 @@ export class AppMap {
|
|
|
750
907
|
// V2: Redact PII from page names before persistence
|
|
751
908
|
fromPage = redactPII(fromPage);
|
|
752
909
|
toPage = redactPII(toPage);
|
|
753
|
-
//
|
|
754
|
-
|
|
910
|
+
// Sanitize: strip control chars, cap length to prevent zone key explosion
|
|
911
|
+
const sanitizeTitle = (t) => t.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\ufeff]/g, "").slice(0, 100);
|
|
912
|
+
fromPage = sanitizeTitle(fromPage);
|
|
913
|
+
toPage = sanitizeTitle(toPage);
|
|
914
|
+
// Re-check after sanitization in case both pages redact/truncate to the same string
|
|
915
|
+
if (!fromPage || !toPage || fromPage === toPage)
|
|
755
916
|
return;
|
|
756
917
|
const data = this.ensureLoaded(bundleId);
|
|
757
918
|
if (!data)
|
|
758
919
|
return;
|
|
920
|
+
// Handle initial page entry (first page after app launch)
|
|
921
|
+
if (fromPage === "__initial__") {
|
|
922
|
+
// Just ensure the initial page node exists — no edge from __initial__
|
|
923
|
+
if (!data.navigationGraph.nodes[toPage]) {
|
|
924
|
+
data.navigationGraph.nodes[toPage] = {
|
|
925
|
+
type: "window",
|
|
926
|
+
description: toPage,
|
|
927
|
+
};
|
|
928
|
+
this.save(data);
|
|
929
|
+
}
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
759
932
|
// Find existing edge with same from/action/to
|
|
760
933
|
const existing = data.navigationGraph.edges.find((e) => e.from === fromPage && e.action === action && e.to === toPage);
|
|
761
934
|
if (existing) {
|
|
@@ -846,8 +1019,41 @@ export class AppMap {
|
|
|
846
1019
|
edge.failCount++;
|
|
847
1020
|
}
|
|
848
1021
|
edge.lastUsed = new Date().toISOString();
|
|
1022
|
+
// Wire #11: stamp Bayesian score from TopologyPolicy if available
|
|
1023
|
+
if (this.topologyPolicy) {
|
|
1024
|
+
const entries = this.topologyPolicy.query(bundleId, from);
|
|
1025
|
+
const match = entries.find((e) => e.action === action && e.toNode === to);
|
|
1026
|
+
if (match) {
|
|
1027
|
+
edge.topologyScore = match.score;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
849
1030
|
this.save(data);
|
|
850
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Wire #11: Get reliability score for a nav edge.
|
|
1034
|
+
* Prefers TopologyPolicy Bayesian score when available,
|
|
1035
|
+
* falls back to simple success ratio from AppMap edge data.
|
|
1036
|
+
*/
|
|
1037
|
+
getEdgeScore(bundleId, from, action, to) {
|
|
1038
|
+
// Prefer live TopologyPolicy score
|
|
1039
|
+
if (this.topologyPolicy) {
|
|
1040
|
+
const entries = this.topologyPolicy.query(bundleId, from);
|
|
1041
|
+
const match = entries.find((e) => e.action === action && e.toNode === to);
|
|
1042
|
+
if (match)
|
|
1043
|
+
return match.score;
|
|
1044
|
+
}
|
|
1045
|
+
// Fallback to AppMap edge data
|
|
1046
|
+
const data = this.load(bundleId);
|
|
1047
|
+
if (!data)
|
|
1048
|
+
return null;
|
|
1049
|
+
const edge = data.navigationGraph.edges.find((e) => e.from === from && e.action === action && e.to === to);
|
|
1050
|
+
if (!edge)
|
|
1051
|
+
return null;
|
|
1052
|
+
if (edge.topologyScore !== undefined)
|
|
1053
|
+
return edge.topologyScore;
|
|
1054
|
+
const total = edge.successCount + edge.failCount;
|
|
1055
|
+
return total > 0 ? edge.successCount / total : null;
|
|
1056
|
+
}
|
|
851
1057
|
// ── Hierarchy ────────────────────────────────────────────────────
|
|
852
1058
|
/**
|
|
853
1059
|
* Record a parent/child containment relationship within a zone.
|
|
@@ -1082,7 +1288,7 @@ export class AppMap {
|
|
|
1082
1288
|
}
|
|
1083
1289
|
resolvePosition(bundleId, label, windowBounds) {
|
|
1084
1290
|
const found = this.findElement(bundleId, label);
|
|
1085
|
-
if (!found || found.element.relativeX ===
|
|
1291
|
+
if (!found || (found.element.relativeX === -1 && found.element.relativeY === -1))
|
|
1086
1292
|
return null;
|
|
1087
1293
|
return {
|
|
1088
1294
|
x: Math.round(windowBounds.x + found.element.relativeX * windowBounds.width),
|
|
@@ -1299,6 +1505,7 @@ export class AppMap {
|
|
|
1299
1505
|
* Recompute tier and confidence from current feature mastery state.
|
|
1300
1506
|
*/
|
|
1301
1507
|
recomputeTier(data) {
|
|
1508
|
+
const prevTier = data.masteryLevel;
|
|
1302
1509
|
const metrics = this.computeMetrics(data);
|
|
1303
1510
|
data.masteryMetrics = metrics;
|
|
1304
1511
|
data.confidence = this.computeConfidence(data);
|
|
@@ -1307,7 +1514,16 @@ export class AppMap {
|
|
|
1307
1514
|
const factors = this.computeRatingFactors(data);
|
|
1308
1515
|
data.ratingFactors = factors;
|
|
1309
1516
|
data.rating = this.computeRating(factors);
|
|
1310
|
-
|
|
1517
|
+
// Use lastRecomputed — lastValidated is reserved for perception coordinator
|
|
1518
|
+
data.lastRecomputed = new Date().toISOString();
|
|
1519
|
+
// Urgent flush when mastery tier changes — write ONLY this app immediately
|
|
1520
|
+
if (prevTier !== data.masteryLevel) {
|
|
1521
|
+
try {
|
|
1522
|
+
writeFileAtomicSync(this.filePath(data.app), JSON.stringify(data, null, 2) + "\n");
|
|
1523
|
+
this.dirty.delete(data.app); // Only remove the one we just wrote
|
|
1524
|
+
}
|
|
1525
|
+
catch { /* non-fatal — will be picked up by next debounced save */ }
|
|
1526
|
+
}
|
|
1311
1527
|
}
|
|
1312
1528
|
refreshMastery(bundleId) {
|
|
1313
1529
|
const data = this.ensureLoaded(bundleId);
|
|
@@ -1511,8 +1727,9 @@ export class AppMap {
|
|
|
1511
1727
|
}
|
|
1512
1728
|
}
|
|
1513
1729
|
}
|
|
1514
|
-
// Remove features that no longer exist in builtin (if
|
|
1515
|
-
|
|
1730
|
+
// Remove features that no longer exist in builtin (only if app has a handcrafted ladder)
|
|
1731
|
+
const hasBuiltinLadder = !!BUILTIN_LADDERS[data.app];
|
|
1732
|
+
if (hasBuiltinLadder && builtinIds.size > 0) {
|
|
1516
1733
|
// Migrate renamed features: old ID → closest new ID by mastery data
|
|
1517
1734
|
const OLD_TO_NEW = {
|
|
1518
1735
|
roles_notifications: "roles_permissions",
|
|
@@ -1567,7 +1784,7 @@ export class AppMap {
|
|
|
1567
1784
|
return;
|
|
1568
1785
|
data.version = newVersion;
|
|
1569
1786
|
data.confidence *= this.config.versionDecayFactor;
|
|
1570
|
-
|
|
1787
|
+
this.recomputeTier(data);
|
|
1571
1788
|
// Unverify all edges on version change
|
|
1572
1789
|
for (const edge of data.navigationGraph.edges) {
|
|
1573
1790
|
edge.verified = false;
|
|
@@ -1579,7 +1796,7 @@ export class AppMap {
|
|
|
1579
1796
|
if (!data)
|
|
1580
1797
|
return;
|
|
1581
1798
|
data.confidence = this.computeConfidence(data);
|
|
1582
|
-
|
|
1799
|
+
this.recomputeTier(data);
|
|
1583
1800
|
this.save(data);
|
|
1584
1801
|
}
|
|
1585
1802
|
// ── Pruning ───────────────────────────────────────────────────────
|
|
@@ -1940,6 +2157,79 @@ export class AppMap {
|
|
|
1940
2157
|
}
|
|
1941
2158
|
return maxTypical;
|
|
1942
2159
|
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Wire #15: Check if an element is well-known and recently verified.
|
|
2162
|
+
* Returns true if the element has 3+ successes and was interacted with
|
|
2163
|
+
* within maxAgeMs (default 5 minutes). Used by Executor to skip verify.
|
|
2164
|
+
*/
|
|
2165
|
+
isElementVerified(bundleId, label, maxAgeMs = 300_000) {
|
|
2166
|
+
const data = this.ensureLoaded(bundleId);
|
|
2167
|
+
if (!data)
|
|
2168
|
+
return false;
|
|
2169
|
+
const now = Date.now();
|
|
2170
|
+
for (const zone of Object.values(data.zones)) {
|
|
2171
|
+
for (const el of zone.elements) {
|
|
2172
|
+
if (el.label === label && el.successCount >= 3) {
|
|
2173
|
+
const lastTime = new Date(el.lastInteracted).getTime();
|
|
2174
|
+
const elapsed = now - lastTime;
|
|
2175
|
+
// Guard: elapsed must be non-negative (rejects future dates from clock skew)
|
|
2176
|
+
// and within the staleness window
|
|
2177
|
+
if (elapsed >= 0 && elapsed <= maxAgeMs) {
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Wire F9: Import element knowledge from a community playbook.
|
|
2187
|
+
* Creates the app entry if it doesn't exist, then records each step's
|
|
2188
|
+
* target as a successful element interaction.
|
|
2189
|
+
*/
|
|
2190
|
+
importFromPlaybook(bundleId, appName, steps) {
|
|
2191
|
+
let data = this.ensureLoaded(bundleId);
|
|
2192
|
+
if (!data) {
|
|
2193
|
+
data = this.createEmpty(bundleId, appName);
|
|
2194
|
+
this.save(data); // Persist to cache + disk so recordElementOutcome can find it
|
|
2195
|
+
}
|
|
2196
|
+
for (const step of steps) {
|
|
2197
|
+
const label = (step.params?.text ?? step.params?.title ?? step.params?.target ?? step.description);
|
|
2198
|
+
if (!label || typeof label !== "string")
|
|
2199
|
+
continue;
|
|
2200
|
+
this.recordElementOutcome(bundleId, "auto", label, true);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* List all known app bundleIds by scanning the maps directory.
|
|
2205
|
+
* Returns bundleIds derived from filenames (excludes .ladder.json files).
|
|
2206
|
+
*/
|
|
2207
|
+
listKnownApps() {
|
|
2208
|
+
try {
|
|
2209
|
+
const dirents = fs.readdirSync(this.config.mapsDir, { withFileTypes: true });
|
|
2210
|
+
const bundleIds = [];
|
|
2211
|
+
for (const dirent of dirents) {
|
|
2212
|
+
// Skip symlinks and directories — only read regular files
|
|
2213
|
+
if (!dirent.isFile())
|
|
2214
|
+
continue;
|
|
2215
|
+
const file = dirent.name;
|
|
2216
|
+
if (file.endsWith(".json") && !file.endsWith(".ladder.json")) {
|
|
2217
|
+
const stem = file.slice(0, -5);
|
|
2218
|
+
// Load the file and use data.app (canonical bundleId) instead of
|
|
2219
|
+
// filename stem, which may differ from the original bundleId due
|
|
2220
|
+
// to filesystem sanitization in filePath()
|
|
2221
|
+
const data = this.ensureLoaded(stem);
|
|
2222
|
+
if (data?.app) {
|
|
2223
|
+
bundleIds.push(data.app);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
return bundleIds;
|
|
2228
|
+
}
|
|
2229
|
+
catch {
|
|
2230
|
+
return [];
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
1943
2233
|
// ── Internals ─────────────────────────────────────────────────────
|
|
1944
2234
|
ensureLoaded(bundleId) {
|
|
1945
2235
|
return this.cache.get(bundleId) ?? this.load(bundleId);
|
|
@@ -36,14 +36,22 @@ export function writeFileAtomicSync(filePath, data) {
|
|
|
36
36
|
try {
|
|
37
37
|
fs.writeFileSync(tmp, data, { mode: 0o644 });
|
|
38
38
|
// Back up current file before overwriting (ignore if it doesn't exist yet)
|
|
39
|
-
//
|
|
39
|
+
// V4: Check for symlinks on BOTH primary and .bak to prevent data exfiltration
|
|
40
40
|
try {
|
|
41
41
|
const stat = fs.lstatSync(filePath);
|
|
42
42
|
if (stat.isSymbolicLink()) {
|
|
43
|
-
//
|
|
43
|
+
// Primary is a symlink — remove it before writing
|
|
44
44
|
fs.unlinkSync(filePath);
|
|
45
45
|
}
|
|
46
46
|
else {
|
|
47
|
+
// Check .bak target for symlinks before copying
|
|
48
|
+
try {
|
|
49
|
+
const bakStat = fs.lstatSync(filePath + ".bak");
|
|
50
|
+
if (bakStat.isSymbolicLink()) {
|
|
51
|
+
fs.unlinkSync(filePath + ".bak"); // Remove symlink, then copy
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* .bak doesn't exist yet — fine */ }
|
|
47
55
|
fs.copyFileSync(filePath, filePath + ".bak");
|
|
48
56
|
}
|
|
49
57
|
}
|
|
@@ -57,7 +65,10 @@ export function writeFileAtomicSync(filePath, data) {
|
|
|
57
65
|
try {
|
|
58
66
|
fs.unlinkSync(tmp);
|
|
59
67
|
}
|
|
60
|
-
catch {
|
|
68
|
+
catch {
|
|
69
|
+
// Temp file could not be cleaned up — log so it can be investigated
|
|
70
|
+
console.error(`[atomic-write] WARN: Failed to clean up temp file: ${tmp}`);
|
|
71
|
+
}
|
|
61
72
|
throw err;
|
|
62
73
|
}
|
|
63
74
|
}
|
|
@@ -78,13 +89,20 @@ export function writeFileAtomic(filePath, data, callback) {
|
|
|
78
89
|
return;
|
|
79
90
|
}
|
|
80
91
|
// Back up current file (best-effort, sync is fine for a copy)
|
|
81
|
-
//
|
|
92
|
+
// V4: Check for symlinks on BOTH primary and .bak
|
|
82
93
|
try {
|
|
83
94
|
const stat = fs.lstatSync(filePath);
|
|
84
95
|
if (stat.isSymbolicLink()) {
|
|
85
96
|
fs.unlinkSync(filePath);
|
|
86
97
|
}
|
|
87
98
|
else {
|
|
99
|
+
try {
|
|
100
|
+
const bakStat = fs.lstatSync(filePath + ".bak");
|
|
101
|
+
if (bakStat.isSymbolicLink()) {
|
|
102
|
+
fs.unlinkSync(filePath + ".bak");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch { /* .bak doesn't exist — fine */ }
|
|
88
106
|
fs.copyFileSync(filePath, filePath + ".bak");
|
|
89
107
|
}
|
|
90
108
|
}
|
|
@@ -113,6 +131,7 @@ export function readJsonWithRecovery(filePath) {
|
|
|
113
131
|
// Primary is missing or corrupt — try backup
|
|
114
132
|
const backup = tryParseJsonFile(filePath + ".bak");
|
|
115
133
|
if (backup !== null) {
|
|
134
|
+
console.error(`[atomic-write] WARN: Primary file corrupt, recovered from backup: ${filePath}`);
|
|
116
135
|
// Restore backup as primary so next read is fast
|
|
117
136
|
try {
|
|
118
137
|
fs.copyFileSync(filePath + ".bak", filePath);
|
|
@@ -120,6 +139,8 @@ export function readJsonWithRecovery(filePath) {
|
|
|
120
139
|
catch { /* ignore */ }
|
|
121
140
|
return backup;
|
|
122
141
|
}
|
|
142
|
+
// Both primary and backup are missing or corrupt — log warning so data loss is visible
|
|
143
|
+
console.error(`[atomic-write] WARN: Both primary and backup corrupt for ${filePath}, returning null`);
|
|
123
144
|
return null;
|
|
124
145
|
}
|
|
125
146
|
function tryParseJsonFile(filePath) {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"downvote": "shreddit-post >> shadowRoot >> button:has-text('Downvote')",
|
|
53
53
|
"comments": "shreddit-post >> shadowRoot >> button:has-text('Go to comments')",
|
|
54
54
|
"share": "shreddit-post >> shadowRoot >> button:has-text('Share')",
|
|
55
|
-
"
|
|
55
|
+
"_note": "These selectors are conceptual — use JS to access shadow roots: post.shadowRoot.querySelectorAll('button')"
|
|
56
56
|
},
|
|
57
57
|
"comment": {
|
|
58
58
|
"composer": "shreddit-composer",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"type_text": "[type='TEXT']",
|
|
72
72
|
"type_link": "[type='LINK']",
|
|
73
73
|
"type_image": "[type='IMAGE']",
|
|
74
|
-
"
|
|
74
|
+
"_note": "Title textarea is inside shadow root of faceplate-textarea-input — use native value setter via JS"
|
|
75
75
|
},
|
|
76
76
|
"subreddit": {
|
|
77
77
|
"header": "shreddit-subreddit-header",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screenhand",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"mcpName": "io.github.manushi4/screenhand",
|
|
5
5
|
"description": "Give AI eyes and hands on your desktop. ScreenHand is an open-source MCP server that lets Claude and other AI agents see your screen, click buttons, type text, and control any app on macOS and Windows.",
|
|
6
6
|
"homepage": "https://screenhand.com",
|