screenhand 0.3.1 → 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 +502 -98
- 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
|
@@ -98,12 +98,19 @@ export class Planner {
|
|
|
98
98
|
}
|
|
99
99
|
learningEngine;
|
|
100
100
|
toolRegistry = null;
|
|
101
|
+
appMap = null;
|
|
101
102
|
/**
|
|
102
103
|
* Set the tool registry for LLM plan generation.
|
|
103
104
|
*/
|
|
104
105
|
setToolRegistry(registry) {
|
|
105
106
|
this.toolRegistry = registry;
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Set the AppMap for navigation-aware planning and state-aware enrichment.
|
|
110
|
+
*/
|
|
111
|
+
setAppMap(map) {
|
|
112
|
+
this.appMap = map;
|
|
113
|
+
}
|
|
107
114
|
/**
|
|
108
115
|
* Create a Goal from a description.
|
|
109
116
|
* Decomposes complex goals into multiple subgoals when the description
|
|
@@ -139,17 +146,73 @@ export class Planner {
|
|
|
139
146
|
// 1. Try playbook match
|
|
140
147
|
const playbookPlan = this.findPlaybookPlan(subgoal.description);
|
|
141
148
|
if (playbookPlan)
|
|
142
|
-
return playbookPlan;
|
|
149
|
+
return this.annotateAndReturn(this.enrichWithStateContext(playbookPlan));
|
|
143
150
|
// 2. Try strategy recall
|
|
144
151
|
const strategyPlan = this.findStrategyPlan(subgoal.description);
|
|
145
152
|
if (strategyPlan)
|
|
146
|
-
return strategyPlan;
|
|
147
|
-
// 3. Try
|
|
153
|
+
return this.annotateAndReturn(this.enrichWithStateContext(strategyPlan));
|
|
154
|
+
// 3. Try AppMap BFS navigation (Wire #6: L7→L4)
|
|
155
|
+
// State enrichment is already done inside findNavigationPlan
|
|
156
|
+
const navPlan = this.findNavigationPlan(subgoal.description);
|
|
157
|
+
if (navPlan)
|
|
158
|
+
return this.annotateAndReturn(navPlan);
|
|
159
|
+
// 4. Try reference flow
|
|
148
160
|
const flowPlan = this.findFlowPlan(subgoal.description);
|
|
149
161
|
if (flowPlan)
|
|
150
|
-
return flowPlan;
|
|
151
|
-
//
|
|
152
|
-
return this.createLLMPlan(subgoal.description);
|
|
162
|
+
return this.annotateAndReturn(this.enrichWithStateContext(flowPlan));
|
|
163
|
+
// 5. Fallback: LLM-generated plan (or stub if no API key)
|
|
164
|
+
return this.annotateAndReturn(await this.createLLMPlan(subgoal.description));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Wire #8: L7→L4 — Enrich any plan with state machine context.
|
|
168
|
+
* Prepends state-fix steps if needed (e.g. expand sidebar before clicking sidebar items).
|
|
169
|
+
*/
|
|
170
|
+
enrichWithStateContext(plan) {
|
|
171
|
+
if (!this.appMap)
|
|
172
|
+
return plan;
|
|
173
|
+
const bundleId = this.getBundleId();
|
|
174
|
+
if (!bundleId)
|
|
175
|
+
return plan;
|
|
176
|
+
const enrichedSteps = this.enrichStepsWithStateContext(plan.steps, bundleId);
|
|
177
|
+
if (enrichedSteps === plan.steps)
|
|
178
|
+
return plan;
|
|
179
|
+
return {
|
|
180
|
+
...plan,
|
|
181
|
+
steps: enrichedSteps,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Wire #13: L5→L4 — Annotate plan steps with known failure pattern warnings,
|
|
186
|
+
* then return the plan. Called as the final step of planSubgoal().
|
|
187
|
+
*/
|
|
188
|
+
annotateAndReturn(plan) {
|
|
189
|
+
if (!this.learningEngine || typeof this.learningEngine.queryPatterns !== "function")
|
|
190
|
+
return plan;
|
|
191
|
+
const bundleId = this.getBundleId();
|
|
192
|
+
if (!bundleId)
|
|
193
|
+
return plan;
|
|
194
|
+
for (const step of plan.steps) {
|
|
195
|
+
const patterns = this.learningEngine.queryPatterns(bundleId, step.tool);
|
|
196
|
+
// Find patterns with strong evidence of failure
|
|
197
|
+
for (const pat of patterns) {
|
|
198
|
+
if (pat.score < 0.4 && pat.failCount >= 3) {
|
|
199
|
+
const target = step.params.target ?? step.params.selector ?? step.params.text ?? step.params.title ?? step.params.name ?? step.params.label ?? step.params.placeholder ?? "";
|
|
200
|
+
// Only warn if target matches the failing pattern — skip if step has no locator-like param
|
|
201
|
+
if (target && pat.locator === target) {
|
|
202
|
+
// Sanitize locator text: strip newlines/control chars, cap length to prevent prompt injection
|
|
203
|
+
const sanitizeLoc = (s) => s.replace(/[\n\r\t\x00-\x1f]/g, " ").slice(0, 100);
|
|
204
|
+
step._patternWarning = `⚠ ${step.tool}: "${sanitizeLoc(pat.locator)}" fails ${pat.failCount}x (score ${pat.score.toFixed(2)})`;
|
|
205
|
+
// Suggest best alternative if available
|
|
206
|
+
const best = patterns.find((p) => p.score > 0.6 && p.locator !== pat.locator);
|
|
207
|
+
if (best) {
|
|
208
|
+
step._patternWarning += ` — try "${sanitizeLoc(best.locator)}" instead (score ${best.score.toFixed(2)})`;
|
|
209
|
+
}
|
|
210
|
+
break; // One warning per step is enough
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return plan;
|
|
153
216
|
}
|
|
154
217
|
/**
|
|
155
218
|
* Plan all subgoals in a goal.
|
|
@@ -178,19 +241,42 @@ export class Planner {
|
|
|
178
241
|
return null;
|
|
179
242
|
}
|
|
180
243
|
subgoal.status = "pending";
|
|
244
|
+
// Wire F2: Ask LearningEngine which recovery strategy works best (L5→L4)
|
|
245
|
+
if (this.learningEngine && subgoal.plan) {
|
|
246
|
+
const bundleId = this.getBundleId();
|
|
247
|
+
if (bundleId) {
|
|
248
|
+
const blockerMap = {
|
|
249
|
+
unexpected_dialog: "unexpected_dialog",
|
|
250
|
+
element_not_found: "element_gone",
|
|
251
|
+
timeout: "loading_stuck",
|
|
252
|
+
};
|
|
253
|
+
const blockerType = blockerMap[reason];
|
|
254
|
+
if (blockerType) {
|
|
255
|
+
const ranked = this.learningEngine.rankRecoveryStrategies(blockerType, bundleId);
|
|
256
|
+
if (ranked.length > 0 && ranked[0].score > 0.6) {
|
|
257
|
+
const strategy = ranked[0];
|
|
258
|
+
const recoveryStep = this.strategyToStep(strategy.strategyId, bundleId);
|
|
259
|
+
if (recoveryStep) {
|
|
260
|
+
subgoal.plan.steps.unshift(recoveryStep);
|
|
261
|
+
subgoal.plan.currentStepIndex = 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
181
267
|
// On replan, try alternative sources or adjust params
|
|
182
268
|
const currentSource = subgoal.plan?.source;
|
|
183
269
|
// If playbook failed, try strategy
|
|
184
270
|
if (currentSource === "playbook") {
|
|
185
271
|
const strategyPlan = this.findStrategyPlan(subgoal.description);
|
|
186
272
|
if (strategyPlan)
|
|
187
|
-
return strategyPlan;
|
|
273
|
+
return this.annotateAndReturn(this.enrichWithStateContext(strategyPlan));
|
|
188
274
|
}
|
|
189
275
|
// If strategy failed, try reference flow
|
|
190
276
|
if (currentSource === "playbook" || currentSource === "strategy") {
|
|
191
277
|
const flowPlan = this.findFlowPlan(subgoal.description);
|
|
192
278
|
if (flowPlan)
|
|
193
|
-
return flowPlan;
|
|
279
|
+
return this.annotateAndReturn(this.enrichWithStateContext(flowPlan));
|
|
194
280
|
}
|
|
195
281
|
// Don't downgrade deterministic plans to LLM stubs.
|
|
196
282
|
// A 9-step reference_flow failing on step 1 (bridge crash) shouldn't
|
|
@@ -205,11 +291,11 @@ export class Planner {
|
|
|
205
291
|
if (step.status === "failed")
|
|
206
292
|
step.status = "pending";
|
|
207
293
|
}
|
|
208
|
-
return subgoal.plan;
|
|
294
|
+
return this.annotateAndReturn(subgoal.plan);
|
|
209
295
|
}
|
|
210
296
|
}
|
|
211
297
|
// Only fall back to LLM when no deterministic plan existed
|
|
212
|
-
return this.createLLMPlan(subgoal.description);
|
|
298
|
+
return this.annotateAndReturn(await this.createLLMPlan(subgoal.description));
|
|
213
299
|
}
|
|
214
300
|
/**
|
|
215
301
|
* Check if a goal is complete (all subgoals done or failed).
|
|
@@ -244,6 +330,407 @@ export class Planner {
|
|
|
244
330
|
getBundleId() {
|
|
245
331
|
return this.worldModel.getState().focusedApp?.bundleId ?? "";
|
|
246
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Wire F2: Map a recovery strategy ID to a concrete plan step.
|
|
335
|
+
*/
|
|
336
|
+
strategyToStep(strategyId, _bundleId) {
|
|
337
|
+
const stepBase = {
|
|
338
|
+
expectedPostcondition: null,
|
|
339
|
+
timeout: 5000,
|
|
340
|
+
fallbackTool: null,
|
|
341
|
+
requiresLLM: false,
|
|
342
|
+
status: "pending",
|
|
343
|
+
};
|
|
344
|
+
if (strategyId === "dismiss_dialog" || strategyId.startsWith("undo_")) {
|
|
345
|
+
return { ...stepBase, tool: "key", params: { key: "Escape" }, description: `Recovery: ${strategyId}` };
|
|
346
|
+
}
|
|
347
|
+
if (strategyId === "refocus") {
|
|
348
|
+
return { ...stepBase, tool: "focus", params: {}, description: "Recovery: refocus app" };
|
|
349
|
+
}
|
|
350
|
+
if (strategyId === "restart_app") {
|
|
351
|
+
return { ...stepBase, tool: "launch", params: {}, timeout: 10000, description: "Recovery: restart app" };
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Wire #6: L7→L4 — BFS navigation plan from AppMap.
|
|
357
|
+
*
|
|
358
|
+
* Extracts navigation intent from the goal description (e.g. "navigate to Settings",
|
|
359
|
+
* "go from Edit to Deliver", "open the Color page") and uses AppMap's BFS pathfinding
|
|
360
|
+
* to generate a concrete plan without LLM.
|
|
361
|
+
*/
|
|
362
|
+
findNavigationPlan(description) {
|
|
363
|
+
if (!this.appMap)
|
|
364
|
+
return null;
|
|
365
|
+
const bundleId = this.getBundleId();
|
|
366
|
+
if (!bundleId)
|
|
367
|
+
return null;
|
|
368
|
+
const nav = this.parseNavigationIntent(description);
|
|
369
|
+
if (!nav)
|
|
370
|
+
return null;
|
|
371
|
+
const path = this.appMap.findPath(bundleId, nav.from, nav.to);
|
|
372
|
+
if (!path || path.length === 0)
|
|
373
|
+
return null;
|
|
374
|
+
// Convert NavEdge[] → PlanStep[]
|
|
375
|
+
const steps = this.navEdgesToSteps(path, bundleId);
|
|
376
|
+
if (steps.length === 0)
|
|
377
|
+
return null;
|
|
378
|
+
// Wire #8: Enrich with state context (prepend state-fix steps if needed)
|
|
379
|
+
const enrichedSteps = this.enrichStepsWithStateContext(steps, bundleId);
|
|
380
|
+
return {
|
|
381
|
+
steps: enrichedSteps,
|
|
382
|
+
currentStepIndex: 0,
|
|
383
|
+
confidence: this.computeNavConfidence(path),
|
|
384
|
+
source: "learned",
|
|
385
|
+
sourceId: `bfs:${nav.from}→${nav.to}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Parse navigation intent from a goal description.
|
|
390
|
+
* Patterns:
|
|
391
|
+
* "navigate to Settings" → from = current page, to = "Settings"
|
|
392
|
+
* "go from Edit to Deliver" → from = "Edit", to = "Deliver"
|
|
393
|
+
* "open the Color page" → from = current page, to = "Color"
|
|
394
|
+
* "switch to the Deliver tab" → from = current page, to = "Deliver"
|
|
395
|
+
*/
|
|
396
|
+
parseNavigationIntent(description) {
|
|
397
|
+
const desc = description.trim();
|
|
398
|
+
// Pattern 1: "from X to Y" / "from X page to Y page"
|
|
399
|
+
const fromToMatch = desc.match(/\bfrom\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?\s+(?:page\s+|tab\s+)?to\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?(?:\s+(?:page|tab|panel|view))?\s*$/i);
|
|
400
|
+
if (fromToMatch) {
|
|
401
|
+
return { from: fromToMatch[1].trim(), to: fromToMatch[2].trim() };
|
|
402
|
+
}
|
|
403
|
+
// Pattern 2: "navigate/go/switch to X" / "open the X page/tab"
|
|
404
|
+
const toMatch = desc.match(/\b(?:navigate|go|switch|move)\s+to\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?(?:\s+(?:page|tab|panel|view))?\s*$/i);
|
|
405
|
+
if (toMatch) {
|
|
406
|
+
const to = toMatch[1].trim();
|
|
407
|
+
const from = this.getCurrentNavNode();
|
|
408
|
+
return from ? { from, to } : null;
|
|
409
|
+
}
|
|
410
|
+
// Pattern 3: "open the X page/tab/panel"
|
|
411
|
+
const openMatch = desc.match(/\bopen\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?\s+(?:page|tab|panel|view)\s*$/i);
|
|
412
|
+
if (openMatch) {
|
|
413
|
+
const to = openMatch[1].trim();
|
|
414
|
+
const from = this.getCurrentNavNode();
|
|
415
|
+
return from ? { from, to } : null;
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Get the current navigation node from world model state.
|
|
421
|
+
* Matches the window title against known AppMap nav nodes to find the current page.
|
|
422
|
+
* Falls back to null if no match found (BFS cannot start without a valid from-node).
|
|
423
|
+
*/
|
|
424
|
+
getCurrentNavNode() {
|
|
425
|
+
if (!this.appMap)
|
|
426
|
+
return null;
|
|
427
|
+
const bundleId = this.getBundleId();
|
|
428
|
+
if (!bundleId)
|
|
429
|
+
return null;
|
|
430
|
+
const state = this.worldModel.getState();
|
|
431
|
+
if (!state.focusedWindowId)
|
|
432
|
+
return null;
|
|
433
|
+
const win = state.windows.get(state.focusedWindowId);
|
|
434
|
+
if (!win?.title?.value)
|
|
435
|
+
return null;
|
|
436
|
+
const titleLower = win.title.value.toLowerCase();
|
|
437
|
+
// Load the AppMap to access navigation graph node names
|
|
438
|
+
const mapData = this.appMap.load(bundleId);
|
|
439
|
+
if (!mapData)
|
|
440
|
+
return null;
|
|
441
|
+
const nodeKeys = Object.keys(mapData.navigationGraph.nodes);
|
|
442
|
+
if (nodeKeys.length === 0)
|
|
443
|
+
return null;
|
|
444
|
+
// Try exact match first, then substring match against window title
|
|
445
|
+
// Window titles are typically "AppName — PageName" or "PageName - AppName"
|
|
446
|
+
for (const nodeKey of nodeKeys) {
|
|
447
|
+
if (nodeKey.toLowerCase() === titleLower)
|
|
448
|
+
return nodeKey;
|
|
449
|
+
}
|
|
450
|
+
for (const nodeKey of nodeKeys) {
|
|
451
|
+
if (nodeKey.length >= 3 && titleLower.includes(nodeKey.toLowerCase()))
|
|
452
|
+
return nodeKey;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Convert a BFS path (NavEdge[]) into executable PlanStep[].
|
|
458
|
+
* Parses the edge.action string to extract tool + params.
|
|
459
|
+
*/
|
|
460
|
+
navEdgesToSteps(path, bundleId) {
|
|
461
|
+
const ctx = this.getRuntimeContext();
|
|
462
|
+
const steps = [];
|
|
463
|
+
for (const edge of path) {
|
|
464
|
+
const step = this.parseEdgeAction(edge, bundleId, ctx);
|
|
465
|
+
if (step) {
|
|
466
|
+
steps.push(step);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
// Can't parse this edge action — create an LLM-required step
|
|
470
|
+
steps.push({
|
|
471
|
+
tool: "",
|
|
472
|
+
params: {},
|
|
473
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
474
|
+
timeout: this.config.defaultStepTimeout,
|
|
475
|
+
fallbackTool: null,
|
|
476
|
+
requiresLLM: true,
|
|
477
|
+
status: "pending",
|
|
478
|
+
description: `${edge.action} (navigate from ${edge.from} to ${edge.to})`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return steps;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Parse an edge action string into a PlanStep.
|
|
486
|
+
* Edge actions are recorded as "click Submit", "navigate /settings",
|
|
487
|
+
* "key cmd+,", "menu_click File > Preferences", etc.
|
|
488
|
+
*/
|
|
489
|
+
parseEdgeAction(edge, _bundleId, ctx) {
|
|
490
|
+
const action = edge.action.trim();
|
|
491
|
+
// "click X" or "click_text X"
|
|
492
|
+
const clickMatch = action.match(/^(?:click_text|click)\s+(.+)$/i);
|
|
493
|
+
if (clickMatch) {
|
|
494
|
+
const target = clickMatch[1].replace(/^["']|["']$/g, "");
|
|
495
|
+
return {
|
|
496
|
+
tool: "click_text",
|
|
497
|
+
params: { text: target, ...(ctx.windowId != null ? { windowId: ctx.windowId } : {}) },
|
|
498
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
499
|
+
timeout: this.config.defaultStepTimeout,
|
|
500
|
+
fallbackTool: "click_with_fallback",
|
|
501
|
+
requiresLLM: false,
|
|
502
|
+
status: "pending",
|
|
503
|
+
description: `Click "${target}" to navigate to ${edge.to}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
// "ui_press X" or "press X"
|
|
507
|
+
const pressMatch = action.match(/^(?:ui_press|press)\s+(.+)$/i);
|
|
508
|
+
if (pressMatch) {
|
|
509
|
+
const target = pressMatch[1].replace(/^["']|["']$/g, "");
|
|
510
|
+
return {
|
|
511
|
+
tool: "ui_press",
|
|
512
|
+
params: { title: target, ...(ctx.pid != null ? { pid: ctx.pid } : {}) },
|
|
513
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
514
|
+
timeout: this.config.defaultStepTimeout,
|
|
515
|
+
fallbackTool: "click_text",
|
|
516
|
+
requiresLLM: false,
|
|
517
|
+
status: "pending",
|
|
518
|
+
description: `Press "${target}" to navigate to ${edge.to}`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// "key cmd+," or "key Return"
|
|
522
|
+
const keyMatch = action.match(/^key\s+(.+)$/i);
|
|
523
|
+
if (keyMatch) {
|
|
524
|
+
return {
|
|
525
|
+
tool: "key",
|
|
526
|
+
params: { key: keyMatch[1].trim() },
|
|
527
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
528
|
+
timeout: this.config.defaultStepTimeout,
|
|
529
|
+
fallbackTool: null,
|
|
530
|
+
requiresLLM: false,
|
|
531
|
+
status: "pending",
|
|
532
|
+
description: `Press ${keyMatch[1].trim()} to navigate to ${edge.to}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// "navigate URL" or "browser_navigate URL"
|
|
536
|
+
const navMatch = action.match(/^(?:navigate|browser_navigate)\s+(.+)$/i);
|
|
537
|
+
if (navMatch) {
|
|
538
|
+
return {
|
|
539
|
+
tool: "browser_navigate",
|
|
540
|
+
params: { url: navMatch[1].trim() },
|
|
541
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
542
|
+
timeout: this.config.defaultStepTimeout,
|
|
543
|
+
fallbackTool: null,
|
|
544
|
+
requiresLLM: false,
|
|
545
|
+
status: "pending",
|
|
546
|
+
description: `Navigate to ${navMatch[1].trim()}`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// "menu_click File > Preferences"
|
|
550
|
+
const menuMatch = action.match(/^menu_click\s+(.+)$/i);
|
|
551
|
+
if (menuMatch) {
|
|
552
|
+
const menuPath = menuMatch[1].trim();
|
|
553
|
+
const parts = menuPath.split(/\s*>\s*/);
|
|
554
|
+
return {
|
|
555
|
+
tool: "menu_click",
|
|
556
|
+
params: {
|
|
557
|
+
menu: parts[0],
|
|
558
|
+
...(parts.length > 1 ? { item: parts.slice(1).join(" > ") } : {}),
|
|
559
|
+
},
|
|
560
|
+
expectedPostcondition: { type: "text_visible", target: edge.to },
|
|
561
|
+
timeout: this.config.defaultStepTimeout,
|
|
562
|
+
fallbackTool: null,
|
|
563
|
+
requiresLLM: false,
|
|
564
|
+
status: "pending",
|
|
565
|
+
description: `Menu: ${menuPath} to navigate to ${edge.to}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Compute plan confidence from BFS path edges.
|
|
572
|
+
* Higher when all edges are verified with good success rates.
|
|
573
|
+
*/
|
|
574
|
+
computeNavConfidence(path) {
|
|
575
|
+
if (path.length === 0)
|
|
576
|
+
return 0.5;
|
|
577
|
+
let totalScore = 0;
|
|
578
|
+
for (const edge of path) {
|
|
579
|
+
const total = edge.successCount + edge.failCount;
|
|
580
|
+
if (total === 0) {
|
|
581
|
+
// Use L5 Bayesian score if available, otherwise default
|
|
582
|
+
totalScore += edge.topologyScore ?? 0.3;
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const rate = edge.successCount / total;
|
|
586
|
+
const baseScore = edge.verified ? Math.max(0.5, rate) : rate * 0.8;
|
|
587
|
+
// Blend raw rate with L5 TopologyPolicy score when available
|
|
588
|
+
totalScore += edge.topologyScore != null
|
|
589
|
+
? (baseScore + edge.topologyScore) / 2
|
|
590
|
+
: baseScore;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return Math.min(0.95, totalScore / path.length);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Wire #8: L7→L4 — Enrich plan steps with state machine context.
|
|
597
|
+
*
|
|
598
|
+
* Checks AppMap state dimensions. If a step's target element is only visible
|
|
599
|
+
* in a specific state (e.g. sidebar must be expanded), and the current state
|
|
600
|
+
* is wrong, prepends state-change steps using known transitions.
|
|
601
|
+
*/
|
|
602
|
+
enrichStepsWithStateContext(steps, bundleId) {
|
|
603
|
+
if (!this.appMap)
|
|
604
|
+
return steps;
|
|
605
|
+
const currentState = this.appMap.getCurrentState(bundleId);
|
|
606
|
+
if (Object.keys(currentState).length === 0)
|
|
607
|
+
return steps;
|
|
608
|
+
const visConditions = this.appMap.getConditionalElements(bundleId);
|
|
609
|
+
if (visConditions.length === 0)
|
|
610
|
+
return steps;
|
|
611
|
+
const prependSteps = [];
|
|
612
|
+
const handledDimensions = new Set();
|
|
613
|
+
for (const step of steps) {
|
|
614
|
+
const target = (step.params.text ?? step.params.title ?? step.params.name);
|
|
615
|
+
if (!target)
|
|
616
|
+
continue;
|
|
617
|
+
// Check if this target has visibility conditions tied to state
|
|
618
|
+
const targetLower = target.toLowerCase();
|
|
619
|
+
for (const vc of visConditions) {
|
|
620
|
+
if (vc.conditionType !== "state")
|
|
621
|
+
continue;
|
|
622
|
+
// Require minimum label length to avoid false positives ("Add" matching everything)
|
|
623
|
+
if (vc.elementLabel.length < 4)
|
|
624
|
+
continue;
|
|
625
|
+
if (!vc.elementLabel.toLowerCase().includes(targetLower) &&
|
|
626
|
+
!targetLower.includes(vc.elementLabel.toLowerCase()))
|
|
627
|
+
continue;
|
|
628
|
+
// This element is state-conditional. Check if we need to change state.
|
|
629
|
+
// Look for state dimensions that might affect visibility
|
|
630
|
+
const dimensions = this.appMap.getStateDimensions(bundleId);
|
|
631
|
+
for (const dim of dimensions) {
|
|
632
|
+
if (handledDimensions.has(dim.key))
|
|
633
|
+
continue;
|
|
634
|
+
// Get transitions that might reveal this element
|
|
635
|
+
const transitions = this.appMap.getStateTransitions(bundleId, dim.key);
|
|
636
|
+
// Only fire when transition is unambiguous (single toggle) or leads
|
|
637
|
+
// to a clearly "revealing" state (open/show/visible/expanded)
|
|
638
|
+
const REVEAL_PATTERNS = /\b(open|show|visible|expanded|enabled|on|active)\b/i;
|
|
639
|
+
for (const t of transitions) {
|
|
640
|
+
if (t.fromValue === currentState[dim.key] && t.toValue !== currentState[dim.key]) {
|
|
641
|
+
// Filter: only use this transition if it's unambiguous or leads to a reveal state
|
|
642
|
+
if (transitions.length > 2 && !REVEAL_PATTERNS.test(t.toValue))
|
|
643
|
+
continue;
|
|
644
|
+
const fixStep = this.parseTransitionTrigger(t, bundleId);
|
|
645
|
+
if (fixStep) {
|
|
646
|
+
prependSteps.push(fixStep);
|
|
647
|
+
handledDimensions.add(dim.key);
|
|
648
|
+
break; // one fix step per dimension
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Wire F3: Zone spatial scroll prepend (L7→L4)
|
|
656
|
+
// If a target element lives in a zone near the bottom of the window, prepend a scroll step.
|
|
657
|
+
if (!handledDimensions.has("__scroll__")) {
|
|
658
|
+
for (const step of steps) {
|
|
659
|
+
const target = (step.params.text ?? step.params.title ?? step.params.name);
|
|
660
|
+
if (!target)
|
|
661
|
+
continue;
|
|
662
|
+
const zone = this.findElementZone(bundleId, target);
|
|
663
|
+
if (!zone)
|
|
664
|
+
continue;
|
|
665
|
+
const pos = zone.relativePosition;
|
|
666
|
+
if (pos.top > 0.85) {
|
|
667
|
+
prependSteps.push({
|
|
668
|
+
tool: "scroll", params: { direction: "down", amount: 300 },
|
|
669
|
+
expectedPostcondition: null, timeout: 3000, fallbackTool: null,
|
|
670
|
+
requiresLLM: false, status: "pending",
|
|
671
|
+
description: "Scroll down to reveal off-screen element",
|
|
672
|
+
});
|
|
673
|
+
handledDimensions.add("__scroll__");
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
else if (pos.top < 0.05 && pos.height < 0.1) {
|
|
677
|
+
prependSteps.push({
|
|
678
|
+
tool: "scroll", params: { direction: "up", amount: 300 },
|
|
679
|
+
expectedPostcondition: null, timeout: 3000, fallbackTool: null,
|
|
680
|
+
requiresLLM: false, status: "pending",
|
|
681
|
+
description: "Scroll up to reveal off-screen element",
|
|
682
|
+
});
|
|
683
|
+
handledDimensions.add("__scroll__");
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return prependSteps.length > 0 ? [...prependSteps, ...steps] : steps;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Wire F3: Find the zone containing a target element in AppMap.
|
|
692
|
+
*/
|
|
693
|
+
findElementZone(bundleId, label) {
|
|
694
|
+
if (!this.appMap)
|
|
695
|
+
return null;
|
|
696
|
+
const data = this.appMap.load(bundleId);
|
|
697
|
+
if (!data)
|
|
698
|
+
return null;
|
|
699
|
+
const labelLower = label.toLowerCase();
|
|
700
|
+
for (const zone of Object.values(data.zones)) {
|
|
701
|
+
for (const el of zone.elements) {
|
|
702
|
+
if (el.label.toLowerCase() === labelLower) {
|
|
703
|
+
return zone;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Parse a state transition trigger into a PlanStep.
|
|
711
|
+
*/
|
|
712
|
+
parseTransitionTrigger(transition, _bundleId) {
|
|
713
|
+
const trigger = transition.trigger.trim();
|
|
714
|
+
if (!trigger)
|
|
715
|
+
return null;
|
|
716
|
+
// Try parsing as "click X", "key X", "press X", etc. — reuse edge action parsing
|
|
717
|
+
const ctx = this.getRuntimeContext();
|
|
718
|
+
const fakeEdge = {
|
|
719
|
+
from: transition.fromValue,
|
|
720
|
+
action: trigger,
|
|
721
|
+
to: transition.toValue,
|
|
722
|
+
verified: transition.observedCount >= 2,
|
|
723
|
+
successCount: transition.observedCount,
|
|
724
|
+
failCount: 0,
|
|
725
|
+
lastUsed: transition.lastSeen,
|
|
726
|
+
};
|
|
727
|
+
const step = this.parseEdgeAction(fakeEdge, _bundleId, ctx);
|
|
728
|
+
if (step) {
|
|
729
|
+
step.description = `[L7→L4 state fix] ${trigger} (${transition.dimensionKey}: ${transition.fromValue} → ${transition.toValue})`;
|
|
730
|
+
step.expectedPostcondition = null; // state change is the postcondition itself
|
|
731
|
+
}
|
|
732
|
+
return step;
|
|
733
|
+
}
|
|
247
734
|
findPlaybookPlan(description) {
|
|
248
735
|
// Try task-based match only — don't unconditionally use the active playbook
|
|
249
736
|
// here, because that would shadow findFlowPlan() which also uses the active
|
|
@@ -429,6 +916,18 @@ export class Planner {
|
|
|
429
916
|
if (active) {
|
|
430
917
|
lines.push(`Platform reference loaded: ${active.platform ?? active.id}`);
|
|
431
918
|
}
|
|
919
|
+
// Wire #8: Include AppMap state dimensions for state-aware LLM planning
|
|
920
|
+
if (this.appMap) {
|
|
921
|
+
const bundleId = this.getBundleId();
|
|
922
|
+
if (bundleId) {
|
|
923
|
+
const currentState = this.appMap.getCurrentState(bundleId);
|
|
924
|
+
const stateEntries = Object.entries(currentState);
|
|
925
|
+
if (stateEntries.length > 0) {
|
|
926
|
+
const stateStr = stateEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
927
|
+
lines.push(`App state: ${stateStr}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
432
931
|
return lines.length > 0 ? `\nRuntime context:\n${lines.join("\n")}` : "";
|
|
433
932
|
}
|
|
434
933
|
async createLLMPlan(description) {
|
|
@@ -108,7 +108,17 @@ export class PlaybookEngine {
|
|
|
108
108
|
/**
|
|
109
109
|
* Execute a single playbook step.
|
|
110
110
|
*/
|
|
111
|
+
/** Tools that the PlaybookEngine refuses to execute (defense in depth). */
|
|
112
|
+
static BLOCKED_ACTIONS = new Set([
|
|
113
|
+
"applescript", "browser_stealth",
|
|
114
|
+
"memory_save", "memory_clear", "memory_snapshot",
|
|
115
|
+
"supervisor_start", "supervisor_stop", "supervisor_install", "supervisor_uninstall",
|
|
116
|
+
"job_create", "worker_start",
|
|
117
|
+
]);
|
|
111
118
|
async executeStep(sessionId, step, cdpPort) {
|
|
119
|
+
if (PlaybookEngine.BLOCKED_ACTIONS.has(step.action)) {
|
|
120
|
+
throw new Error(`Blocked: playbook step uses restricted action "${step.action}"`);
|
|
121
|
+
}
|
|
112
122
|
const target = this.resolveTarget(step.target);
|
|
113
123
|
switch (step.action) {
|
|
114
124
|
case "navigate": {
|