screenhand 0.1.1 → 0.3.0

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.
Files changed (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /package/{playbooks → dist-references}/devpost.json +0 -0
@@ -0,0 +1,1974 @@
1
+ // Copyright (C) 2025 Clazro Technology Private Limited
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { writeFileAtomicSync, readJsonWithRecovery } from "../util/atomic-write.js";
7
+ import { redactPII } from "../util/sanitize.js";
8
+ import { DEFAULT_APP_MAP_CONFIG, GRADE_THRESHOLDS, RATING_FACTOR_WEIGHTS, ratingToString } from "./app-map-types.js";
9
+ import { generateLadderFromReference } from "./ladder-generator.js";
10
+ // ── Built-in Feature Ladders ───────────────────────────────────────
11
+ // Define what real users do at each level. Used to measure honest mastery.
12
+ const BUILTIN_LADDERS = {
13
+ "com.hnc.Discord": [
14
+ // ── Beginner: basic consumer actions (weight 1) ──
15
+ { id: "browse_channels", description: "Join servers and browse channels", level: "beginner", weight: 1, critical: false },
16
+ { id: "send_message", description: "Send messages, replies, emojis, and reactions", level: "beginner", weight: 1, critical: false },
17
+ { id: "direct_messages", description: "Direct messages and group chats", level: "beginner", weight: 1, critical: false },
18
+ { id: "voice_video", description: "Voice channels, video calls, and screen share", level: "beginner", weight: 1, critical: false },
19
+ // ── Pro: operational features (weight 2) ──
20
+ { id: "threads_forums", description: "Create and manage threads and forum channels", level: "pro", weight: 2, critical: false },
21
+ { id: "roles_permissions", description: "Configure roles, overrides, inheritance, hidden channels", level: "pro", weight: 2, critical: true },
22
+ { id: "events_stage", description: "Schedule events, run Stage channels, manage speakers", level: "pro", weight: 2, critical: false },
23
+ { id: "onboarding_funnel", description: "Build join flows: rules screening, role assignment, starter channels", level: "pro", weight: 2, critical: true },
24
+ { id: "notification_control", description: "Channel overrides, mention control, suppression settings", level: "pro", weight: 1, critical: false },
25
+ // ── Expert: system-level features (weight 2-3) ──
26
+ { id: "moderation_system", description: "Configure AutoMod, mod bots, alert flows, ban appeals, raid defense", level: "expert", weight: 3, critical: true },
27
+ { id: "bot_ecosystem", description: "Combine bots, slash commands, webhooks into coherent server OS", level: "expert", weight: 3, critical: true },
28
+ { id: "server_architecture", description: "Design categories, channel taxonomy, permissions, escalation paths", level: "expert", weight: 3, critical: true },
29
+ { id: "community_growth", description: "Events, role rewards, content loops, announcements, retention mechanics", level: "expert", weight: 2, critical: false },
30
+ { id: "analytics_health", description: "Track activity patterns, onboarding drop-off, channel usage, retention", level: "expert", weight: 2, critical: true },
31
+ // ── Grandmaster: mastery-level operations (weight 3) ──
32
+ { id: "monetization_membership", description: "Premium roles, gated channels, supporter tiers, creator monetization", level: "grandmaster", weight: 2, critical: false },
33
+ { id: "crisis_handling", description: "Handle raids, harassment, spam, leaks, impersonation, conflicts", level: "grandmaster", weight: 3, critical: true },
34
+ { id: "cross_platform", description: "Connect Discord with GitHub, Notion, Twitch, Stripe, Zapier, tools", level: "grandmaster", weight: 2, critical: false },
35
+ { id: "staff_system", description: "Structure mod roles, escalation, internal channels, review processes", level: "grandmaster", weight: 3, critical: true },
36
+ { id: "brand_culture", description: "Shape tone, rituals, norms, recognition systems, community identity", level: "grandmaster", weight: 2, critical: false },
37
+ { id: "governance_policy", description: "Define rules, enforcement, appeals, social boundaries that hold up", level: "grandmaster", weight: 3, critical: true },
38
+ ],
39
+ "com.apple.Safari": [
40
+ { id: "browse_navigate", description: "Open URLs and navigate pages", level: "beginner", weight: 1, critical: false },
41
+ { id: "tabs_windows", description: "Manage tabs and windows", level: "beginner", weight: 1, critical: false },
42
+ { id: "bookmarks", description: "Bookmarks and reading list", level: "beginner", weight: 1, critical: false },
43
+ { id: "history_search", description: "History and search", level: "beginner", weight: 1, critical: false },
44
+ { id: "tab_groups", description: "Tab groups and profiles", level: "pro", weight: 2, critical: false },
45
+ { id: "extensions", description: "Install and use extensions", level: "pro", weight: 2, critical: false },
46
+ { id: "dev_tools", description: "Web Inspector and developer tools", level: "expert", weight: 2, critical: true },
47
+ { id: "privacy_settings", description: "Privacy, cookies, and content blockers", level: "expert", weight: 2, critical: false },
48
+ { id: "web_apps", description: "Add to Dock, web apps, notifications", level: "grandmaster", weight: 2, critical: false },
49
+ ],
50
+ "com.apple.finder": [
51
+ { id: "browse_files", description: "Browse and open files/folders", level: "beginner", weight: 1, critical: false },
52
+ { id: "copy_move", description: "Copy, move, rename, delete files", level: "beginner", weight: 1, critical: false },
53
+ { id: "search", description: "Spotlight and Finder search", level: "beginner", weight: 1, critical: false },
54
+ { id: "views_sort", description: "Change views, sort, and organize", level: "pro", weight: 2, critical: false },
55
+ { id: "tags_favorites", description: "Tags, favorites, and sidebar", level: "pro", weight: 2, critical: false },
56
+ { id: "quick_actions", description: "Quick Look, Quick Actions, and Services", level: "expert", weight: 2, critical: true },
57
+ { id: "automator_scripts", description: "Automator, terminal, and scripting", level: "grandmaster", weight: 2, critical: false },
58
+ ],
59
+ };
60
+ /** Generic fallback ladder — used when no builtin AND no reference-generated ladder exists. */
61
+ const GENERIC_LADDER = [
62
+ { id: "basic_navigation", description: "Open, navigate, and browse the app", level: "beginner", weight: 1, critical: false },
63
+ { id: "core_action", description: "Perform the app's primary action", level: "beginner", weight: 1, critical: false },
64
+ { id: "settings", description: "Configure settings and preferences", level: "pro", weight: 2, critical: false },
65
+ { id: "advanced_features", description: "Use advanced/power-user features", level: "expert", weight: 2, critical: true },
66
+ { id: "automation", description: "Automate or customize workflows", level: "grandmaster", weight: 3, critical: true },
67
+ ];
68
+ /** Redact an array of user-facing strings in place, returning a new array. */
69
+ function redactStrings(strings) {
70
+ return strings.map((s) => redactPII(s));
71
+ }
72
+ /**
73
+ * AppMap — persistent spatial understanding of application UIs.
74
+ *
75
+ * One JSON file per app at `~/.screenhand/app-maps/{bundleId}.json`.
76
+ * Stores zones, elements with relative positions, navigation graph,
77
+ * and game-style ratings (F → E → D → C → B → A → S → SS → SSS → 0).
78
+ *
79
+ * Uses full JSON (not JSONL) because the map is a structured document.
80
+ * Atomic writes via writeFileAtomicSync + readJsonWithRecovery for
81
+ * crash safety.
82
+ */
83
+ export class AppMap {
84
+ config;
85
+ cache = new Map();
86
+ dirty = new Set();
87
+ saveTimer = null;
88
+ /** Cache of auto-generated ladders (from reference files) */
89
+ generatedLadderCache = new Map();
90
+ constructor(config) {
91
+ this.config = {
92
+ ...DEFAULT_APP_MAP_CONFIG,
93
+ mapsDir: config?.mapsDir ??
94
+ path.join(os.homedir(), ".screenhand", "app-maps"),
95
+ ...config,
96
+ };
97
+ }
98
+ init() {
99
+ fs.mkdirSync(this.config.mapsDir, { recursive: true });
100
+ }
101
+ // ── Load / Save ───────────────────────────────────────────────────
102
+ load(bundleId) {
103
+ const cached = this.cache.get(bundleId);
104
+ if (cached)
105
+ return cached;
106
+ // 1. Check user's own maps
107
+ const filePath = this.filePath(bundleId);
108
+ let data = readJsonWithRecovery(filePath);
109
+ // 2. Fall back to seed maps shipped with the package
110
+ if (!data && this.config.seedDir) {
111
+ const safe = bundleId.replace(/\.\./g, "_").replace(/[^a-zA-Z0-9._-]/g, "_");
112
+ const seedPath = path.join(this.config.seedDir, `${safe}.json`);
113
+ data = readJsonWithRecovery(seedPath);
114
+ }
115
+ if (data) {
116
+ this.cache.set(bundleId, data);
117
+ }
118
+ return data;
119
+ }
120
+ getLoaded(bundleId) {
121
+ return this.cache.get(bundleId) ?? null;
122
+ }
123
+ save(data, recompute = false) {
124
+ if (recompute)
125
+ this.recomputeTier(data);
126
+ this.cache.set(data.app, data);
127
+ this.dirty.add(data.app);
128
+ this.scheduleSave();
129
+ }
130
+ flush() {
131
+ if (this.saveTimer) {
132
+ clearTimeout(this.saveTimer);
133
+ this.saveTimer = null;
134
+ }
135
+ this.writeDirty();
136
+ }
137
+ // ── Feature Ladder ──────────────────────────────────────────────────
138
+ /**
139
+ * Get the feature ladder for an app. Priority:
140
+ * 1. BUILTIN_LADDERS (handcrafted, e.g., Discord)
141
+ * 2. Auto-generated from reference file (cached in memory + disk)
142
+ * 3. Generic 5-item fallback
143
+ */
144
+ getFeatureLadder(bundleId) {
145
+ // 1. Handcrafted builtin
146
+ if (BUILTIN_LADDERS[bundleId])
147
+ return BUILTIN_LADDERS[bundleId];
148
+ // 2. In-memory cache of generated ladder
149
+ const cached = this.generatedLadderCache.get(bundleId);
150
+ if (cached && cached.ladder.length > 0)
151
+ return cached.ladder;
152
+ // 3. Persisted generated ladder on disk
153
+ const persisted = this.loadGeneratedLadder(bundleId);
154
+ if (persisted && persisted.ladder.length > 0) {
155
+ this.generatedLadderCache.set(bundleId, persisted);
156
+ return persisted.ladder;
157
+ }
158
+ // 4. Generic fallback
159
+ return GENERIC_LADDER;
160
+ }
161
+ /**
162
+ * Get the feature signal keywords for an app's auto-generated ladder.
163
+ * Returns null if no generated signals exist (caller should use hardcoded signals).
164
+ */
165
+ getGeneratedSignals(bundleId) {
166
+ const cached = this.generatedLadderCache.get(bundleId);
167
+ if (cached)
168
+ return cached.signals;
169
+ const persisted = this.loadGeneratedLadder(bundleId);
170
+ if (persisted) {
171
+ this.generatedLadderCache.set(bundleId, persisted);
172
+ return persisted.signals;
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * Auto-generate a feature ladder from a reference file and cache it.
178
+ * Called when we encounter an app with a reference but no builtin ladder.
179
+ * Returns true if a new ladder was generated (false if already exists or reference too sparse).
180
+ */
181
+ generateLadderFromRef(bundleId, ref) {
182
+ // Don't override builtin ladders
183
+ if (BUILTIN_LADDERS[bundleId])
184
+ return false;
185
+ // Check if already generated with same hash
186
+ const existing = this.generatedLadderCache.get(bundleId) ?? this.loadGeneratedLadder(bundleId);
187
+ const result = generateLadderFromReference(ref);
188
+ if (result.ladder.length === 0)
189
+ return false;
190
+ if (existing && existing.hash === result.hash) {
191
+ // Same reference, no regeneration needed
192
+ this.generatedLadderCache.set(bundleId, existing);
193
+ return false;
194
+ }
195
+ // Cache and persist
196
+ this.generatedLadderCache.set(bundleId, result);
197
+ this.saveGeneratedLadder(bundleId, result);
198
+ // Update existing AppMapData if loaded
199
+ const mapData = this.cache.get(bundleId);
200
+ if (mapData) {
201
+ mapData.featureLadder = result.ladder;
202
+ this.save(mapData);
203
+ }
204
+ return true;
205
+ }
206
+ /** Check if a generated ladder exists for this bundleId. */
207
+ hasGeneratedLadder(bundleId) {
208
+ if (BUILTIN_LADDERS[bundleId])
209
+ return true;
210
+ if (this.generatedLadderCache.has(bundleId))
211
+ return true;
212
+ return this.loadGeneratedLadder(bundleId) !== null;
213
+ }
214
+ /**
215
+ * Set a custom feature ladder for an app. Useful for apps without built-in ladders.
216
+ */
217
+ setFeatureLadder(bundleId, ladder) {
218
+ const data = this.ensureLoaded(bundleId);
219
+ if (!data)
220
+ return;
221
+ data.featureLadder = ladder;
222
+ this.save(data);
223
+ }
224
+ // ── Generated ladder persistence ─────────────────────────────────
225
+ ladderFilePath(bundleId) {
226
+ // Sanitize bundleId for filesystem safety — strip path traversal sequences first
227
+ const safe = bundleId.replace(/\.\./g, "_").replace(/[^a-zA-Z0-9._-]/g, "_");
228
+ return path.join(this.config.mapsDir, `${safe}.ladder.json`);
229
+ }
230
+ loadGeneratedLadder(bundleId) {
231
+ try {
232
+ const filePath = this.ladderFilePath(bundleId);
233
+ if (!fs.existsSync(filePath))
234
+ return null;
235
+ const raw = fs.readFileSync(filePath, "utf-8");
236
+ return JSON.parse(raw);
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ saveGeneratedLadder(bundleId, data) {
243
+ try {
244
+ fs.mkdirSync(this.config.mapsDir, { recursive: true });
245
+ writeFileAtomicSync(this.ladderFilePath(bundleId), JSON.stringify(data, null, 2));
246
+ }
247
+ catch { /* non-critical */ }
248
+ }
249
+ // ── Create ────────────────────────────────────────────────────────
250
+ createEmpty(bundleId, appName, version = "unknown") {
251
+ const data = {
252
+ app: bundleId,
253
+ appName,
254
+ version,
255
+ masteryLevel: "beginner",
256
+ rating: { grade: "F", subTier: 1 },
257
+ ratingFactors: this.emptyRatingFactors(),
258
+ confidence: 0,
259
+ lastValidated: new Date().toISOString(),
260
+ mapVersion: 1,
261
+ uiArchitecture: {
262
+ type: "other",
263
+ rendering: "native",
264
+ axSupport: "partial",
265
+ bestMethod: "ax",
266
+ menuStyle: "standard",
267
+ dragDropHeavy: false,
268
+ hasCanvas: false,
269
+ },
270
+ zones: {},
271
+ navigationGraph: { nodes: {}, edges: [] },
272
+ masteryHistory: [],
273
+ totalTasksCompleted: 0,
274
+ sessionCount: 0,
275
+ featureLadder: this.getFeatureLadder(bundleId),
276
+ featureMastery: {},
277
+ masteryMetrics: this.emptyMetrics(),
278
+ crossFeatureWorkflows: 0,
279
+ actionSuccessCount: 0,
280
+ actionFailCount: 0,
281
+ shortcutsUsed: 0,
282
+ playbooksExported: 0,
283
+ edgeCasesHandled: 0,
284
+ };
285
+ this.save(data);
286
+ return data;
287
+ }
288
+ // ── Zone Operations ───────────────────────────────────────────────
289
+ addZone(bundleId, zoneKey, zone) {
290
+ const data = this.ensureLoaded(bundleId);
291
+ if (!data)
292
+ return;
293
+ const zoneCount = Object.keys(data.zones).length;
294
+ if (zoneCount >= this.config.maxZonesPerApp)
295
+ return;
296
+ data.zones[zoneKey] = zone;
297
+ this.save(data);
298
+ }
299
+ updateZonePosition(bundleId, zoneKey, position) {
300
+ const data = this.ensureLoaded(bundleId);
301
+ if (!data)
302
+ return;
303
+ const zone = data.zones[zoneKey];
304
+ if (!zone)
305
+ return;
306
+ zone.relativePosition = position;
307
+ zone.lastSeen = new Date().toISOString();
308
+ this.save(data);
309
+ }
310
+ // ── Element Operations ────────────────────────────────────────────
311
+ addElement(bundleId, zoneKey, element) {
312
+ const data = this.ensureLoaded(bundleId);
313
+ if (!data)
314
+ return;
315
+ const zone = data.zones[zoneKey];
316
+ if (!zone)
317
+ return;
318
+ if (zone.elements.length >= this.config.maxElementsPerZone)
319
+ return;
320
+ // V2: Redact PII from user-facing element text before persistence
321
+ element.label = redactPII(element.label);
322
+ element.ocrBackup = redactPII(element.ocrBackup);
323
+ // Deduplicate by label
324
+ const existing = zone.elements.findIndex((e) => e.label === element.label);
325
+ if (existing >= 0) {
326
+ zone.elements[existing] = element;
327
+ }
328
+ else {
329
+ zone.elements.push(element);
330
+ }
331
+ this.save(data);
332
+ }
333
+ updateElementPosition(bundleId, zoneKey, label, relX, relY) {
334
+ const data = this.ensureLoaded(bundleId);
335
+ if (!data)
336
+ return;
337
+ // Find the element — it may be in the specified zone or any zone (page-aware routing)
338
+ let sourceZone = data.zones[zoneKey];
339
+ let el = sourceZone?.elements.find((e) => e.label === label);
340
+ let sourceZoneKey = zoneKey;
341
+ // If not found in the specified zone, search all zones
342
+ if (!el) {
343
+ for (const [key, z] of Object.entries(data.zones)) {
344
+ const found = z.elements.find((e) => e.label === label);
345
+ if (found) {
346
+ el = found;
347
+ sourceZone = z;
348
+ sourceZoneKey = key;
349
+ break;
350
+ }
351
+ }
352
+ }
353
+ if (!el || !sourceZone)
354
+ return;
355
+ el.relativeX = relX;
356
+ el.relativeY = relY;
357
+ el.lastInteracted = new Date().toISOString();
358
+ el.sessionsSinceUse = 0;
359
+ // ── Global zone migration based on position ──
360
+ // Elements in the sidebar region (left 15%) → global::sidebar
361
+ // Elements in the toolbar region (top 8%) → global::toolbar
362
+ // Only migrate from page-specific or auto_discovered zones, not from
363
+ // zones that are already correctly classified.
364
+ const isPageOrAuto = sourceZoneKey.startsWith("page::") || sourceZoneKey === "auto_discovered";
365
+ if (isPageOrAuto) {
366
+ let globalTarget = null;
367
+ if (relX < 0.15 && relY > 0.08) {
368
+ globalTarget = "global::sidebar";
369
+ }
370
+ else if (relY < 0.08) {
371
+ globalTarget = "global::toolbar";
372
+ }
373
+ if (globalTarget && globalTarget !== sourceZoneKey) {
374
+ // Move element from source zone to global zone
375
+ const idx = sourceZone.elements.indexOf(el);
376
+ if (idx >= 0)
377
+ sourceZone.elements.splice(idx, 1);
378
+ let targetZone = data.zones[globalTarget];
379
+ if (!targetZone) {
380
+ targetZone = {
381
+ relativePosition: globalTarget === "global::toolbar"
382
+ ? { top: 0, left: 0, width: 1, height: 0.08 }
383
+ : { top: 0.08, left: 0, width: 0.15, height: 0.92 },
384
+ type: globalTarget === "global::toolbar" ? "toolbar" : "sidebar",
385
+ elements: [],
386
+ verified: false,
387
+ lastSeen: new Date().toISOString(),
388
+ };
389
+ data.zones[globalTarget] = targetZone;
390
+ }
391
+ // Don't duplicate — check if already in target
392
+ const existing = targetZone.elements.find((e) => e.label === label);
393
+ if (existing) {
394
+ existing.relativeX = relX;
395
+ existing.relativeY = relY;
396
+ existing.lastInteracted = el.lastInteracted;
397
+ existing.sessionsSinceUse = 0;
398
+ existing.successCount += el.successCount;
399
+ existing.failCount += el.failCount;
400
+ }
401
+ else {
402
+ if (targetZone.elements.length < this.config.maxElementsPerZone) {
403
+ targetZone.elements.push(el);
404
+ }
405
+ }
406
+ }
407
+ }
408
+ this.save(data);
409
+ }
410
+ recordElementOutcome(bundleId, zoneKey, label, success, pageContext) {
411
+ // V2: Redact PII from label before persistence
412
+ label = redactPII(label);
413
+ const data = this.ensureLoaded(bundleId);
414
+ if (!data)
415
+ return;
416
+ // Search across all zones if zoneKey is "auto"
417
+ let zone = data.zones[zoneKey];
418
+ if (!zone && zoneKey === "auto") {
419
+ const targetZoneKey = pageContext
420
+ ? `page::${pageContext}`
421
+ : "auto_discovered";
422
+ // When page context is known, prefer the page-specific zone
423
+ // This ensures elements migrate OUT of auto_discovered into proper page zones
424
+ if (pageContext) {
425
+ // First check if element already exists in the target page zone
426
+ const pageZone = data.zones[targetZoneKey];
427
+ if (pageZone) {
428
+ zone = pageZone;
429
+ }
430
+ else {
431
+ // Element might be in auto_discovered — that's OK, we'll create a new
432
+ // entry in the page zone to gradually migrate elements to proper zones
433
+ if (Object.keys(data.zones).length >= this.config.maxZonesPerApp) {
434
+ // At zone limit — fall back to auto_discovered
435
+ zone = data.zones["auto_discovered"];
436
+ if (!zone) {
437
+ zone = {
438
+ relativePosition: { top: 0, left: 0, width: 1, height: 1 },
439
+ type: "other",
440
+ elements: [],
441
+ verified: false,
442
+ lastSeen: new Date().toISOString(),
443
+ };
444
+ data.zones["auto_discovered"] = zone;
445
+ }
446
+ }
447
+ else {
448
+ zone = {
449
+ relativePosition: { top: 0, left: 0, width: 1, height: 1 },
450
+ type: "other",
451
+ elements: [],
452
+ verified: false,
453
+ lastSeen: new Date().toISOString(),
454
+ };
455
+ data.zones[targetZoneKey] = zone;
456
+ }
457
+ }
458
+ }
459
+ else {
460
+ // No page context — search all zones for existing element
461
+ for (const [, z] of Object.entries(data.zones)) {
462
+ const found = z.elements.find((e) => e.label === label);
463
+ if (found) {
464
+ zone = z;
465
+ break;
466
+ }
467
+ }
468
+ // Not found — use auto_discovered
469
+ if (!zone) {
470
+ zone = data.zones["auto_discovered"];
471
+ if (!zone) {
472
+ zone = {
473
+ relativePosition: { top: 0, left: 0, width: 1, height: 1 },
474
+ type: "other",
475
+ elements: [],
476
+ verified: false,
477
+ lastSeen: new Date().toISOString(),
478
+ };
479
+ data.zones["auto_discovered"] = zone;
480
+ }
481
+ }
482
+ }
483
+ }
484
+ if (!zone) {
485
+ // Non-auto zoneKey that doesn't exist — nothing to do
486
+ return;
487
+ }
488
+ let el = zone.elements.find((e) => e.label === label);
489
+ if (!el) {
490
+ if (zone.elements.length >= this.config.maxElementsPerZone)
491
+ return;
492
+ el = {
493
+ label,
494
+ relativeX: 0,
495
+ relativeY: 0,
496
+ anchor: "top-left",
497
+ ocrBackup: label,
498
+ successCount: 0,
499
+ failCount: 0,
500
+ lastInteracted: new Date().toISOString(),
501
+ sessionsSinceUse: 0,
502
+ };
503
+ zone.elements.push(el);
504
+ }
505
+ if (success) {
506
+ el.successCount++;
507
+ }
508
+ else {
509
+ el.failCount++;
510
+ }
511
+ el.lastInteracted = new Date().toISOString();
512
+ el.sessionsSinceUse = 0;
513
+ this.save(data);
514
+ }
515
+ // ── Input/Output Contracts ──────────────────────────────────────────
516
+ /**
517
+ * Record what an element DOES when interacted with.
518
+ * If a contract for the same element+action already exists, merge outcomes:
519
+ * increment seenCount for known outcomes, add new ones.
520
+ * Mark outcomes as reliable when seenCount >= 3.
521
+ */
522
+ recordContract(bundleId, zoneKey, elementLabel, action, outcomes, preconditions) {
523
+ // M3: Reject empty string labels/actions
524
+ if (!elementLabel || !action)
525
+ return;
526
+ // V2: Redact PII from user-facing strings before persistence
527
+ elementLabel = redactPII(elementLabel);
528
+ action = redactPII(action);
529
+ outcomes = redactStrings(outcomes);
530
+ if (preconditions)
531
+ preconditions = redactStrings(preconditions);
532
+ const data = this.ensureLoaded(bundleId);
533
+ if (!data)
534
+ return;
535
+ // Find the zone — search all zones if zoneKey is "auto"
536
+ let zone;
537
+ let resolvedZoneKey = zoneKey;
538
+ if (zoneKey === "auto") {
539
+ // Search across all zones for this element
540
+ for (const [key, z] of Object.entries(data.zones)) {
541
+ if (z.elements.some((e) => e.label === elementLabel)) {
542
+ zone = z;
543
+ resolvedZoneKey = key;
544
+ break;
545
+ }
546
+ }
547
+ // Fall back to auto_discovered — create it if it doesn't exist
548
+ if (!zone) {
549
+ zone = data.zones["auto_discovered"];
550
+ if (!zone) {
551
+ zone = {
552
+ relativePosition: { top: 0, left: 0, width: 1, height: 1 },
553
+ type: "other",
554
+ elements: [],
555
+ verified: false,
556
+ lastSeen: new Date().toISOString(),
557
+ };
558
+ data.zones["auto_discovered"] = zone;
559
+ }
560
+ resolvedZoneKey = "auto_discovered";
561
+ }
562
+ }
563
+ else {
564
+ zone = data.zones[zoneKey];
565
+ }
566
+ if (!zone)
567
+ return;
568
+ // Initialize contracts array if needed
569
+ if (!zone.contracts)
570
+ zone.contracts = [];
571
+ // Find existing contract for same element+action
572
+ let contract = zone.contracts.find((c) => c.elementLabel === elementLabel && c.action === action);
573
+ if (contract) {
574
+ // Merge outcomes
575
+ for (const desc of outcomes) {
576
+ const existing = contract.outcomes.find((o) => o.description === desc);
577
+ if (existing) {
578
+ existing.seenCount++;
579
+ if (existing.seenCount >= 3)
580
+ existing.reliable = true;
581
+ }
582
+ else {
583
+ if (contract.outcomes.length < this.config.maxOutcomesPerContract) {
584
+ contract.outcomes.push({ description: desc, reliable: false, seenCount: 1 });
585
+ }
586
+ }
587
+ }
588
+ // Merge preconditions (deduplicate, cap at 50)
589
+ if (preconditions) {
590
+ for (const pc of preconditions) {
591
+ if (contract.preconditions.length >= 50)
592
+ break;
593
+ if (!contract.preconditions.includes(pc)) {
594
+ contract.preconditions.push(pc);
595
+ }
596
+ }
597
+ }
598
+ contract.validationCount++;
599
+ contract.lastValidated = new Date().toISOString();
600
+ }
601
+ else {
602
+ // Enforce contract limit
603
+ if (zone.contracts.length >= this.config.maxContractsPerZone)
604
+ return;
605
+ contract = {
606
+ elementLabel,
607
+ action,
608
+ preconditions: preconditions ?? [],
609
+ outcomes: outcomes.slice(0, this.config.maxOutcomesPerContract).map((desc) => ({
610
+ description: desc,
611
+ reliable: false,
612
+ seenCount: 1,
613
+ })),
614
+ validationCount: 1,
615
+ lastValidated: new Date().toISOString(),
616
+ };
617
+ zone.contracts.push(contract);
618
+ }
619
+ this.save(data);
620
+ }
621
+ /**
622
+ * Find the contract for an element across all zones.
623
+ * Returns the first matching contract (by elementLabel), or null.
624
+ */
625
+ getContract(bundleId, elementLabel) {
626
+ const data = this.ensureLoaded(bundleId);
627
+ if (!data)
628
+ return null;
629
+ for (const [zoneKey, zone] of Object.entries(data.zones)) {
630
+ if (!zone.contracts)
631
+ continue;
632
+ const contract = zone.contracts.find((c) => c.elementLabel === elementLabel);
633
+ if (contract)
634
+ return { zone: zoneKey, contract };
635
+ }
636
+ return null;
637
+ }
638
+ /**
639
+ * Record an action tool outcome (type, set_value, menu_click, key, drag — not navigation).
640
+ * These represent actually DOING something, not just clicking around.
641
+ */
642
+ recordActionOutcome(bundleId, success) {
643
+ const data = this.ensureLoaded(bundleId);
644
+ if (!data)
645
+ return;
646
+ // Migrate old data
647
+ this.migrateToWeighted(data);
648
+ if (data.actionSuccessCount == null)
649
+ data.actionSuccessCount = 0;
650
+ if (data.actionFailCount == null)
651
+ data.actionFailCount = 0;
652
+ if (success) {
653
+ data.actionSuccessCount++;
654
+ }
655
+ else {
656
+ data.actionFailCount++;
657
+ }
658
+ this.save(data);
659
+ }
660
+ /**
661
+ * Record a feature interaction at a given depth.
662
+ * Depth: 1=navigated, 2=basic action, 3=multi-step workflow, 4=verified outcome.
663
+ * Depth only goes UP — you can't lose depth. Confidence grows with repeats.
664
+ */
665
+ recordFeatureSignal(bundleId, featureKey, depth, success) {
666
+ const data = this.ensureLoaded(bundleId);
667
+ if (!data)
668
+ return;
669
+ this.migrateToWeighted(data);
670
+ let fm = data.featureMastery[featureKey];
671
+ if (!fm) {
672
+ fm = {
673
+ depth: 0,
674
+ confidence: 0,
675
+ repeatCount: 0,
676
+ workflowCount: 0,
677
+ healingCount: 0,
678
+ failCount: 0,
679
+ lastSeen: new Date().toISOString(),
680
+ lastVerified: null,
681
+ };
682
+ data.featureMastery[featureKey] = fm;
683
+ }
684
+ fm.lastSeen = new Date().toISOString();
685
+ if (success) {
686
+ // Depth only goes up
687
+ if (depth > fm.depth) {
688
+ fm.depth = depth;
689
+ }
690
+ fm.repeatCount++;
691
+ if (depth >= 3)
692
+ fm.workflowCount++;
693
+ if (depth === 4)
694
+ fm.lastVerified = new Date().toISOString();
695
+ // Confidence based on evidence: navigation=0.25-0.4, action=0.5-0.6,
696
+ // workflow repeated 3x=0.75-0.85, verified outcome 5x=0.9-1.0
697
+ fm.confidence = this.computeFeatureConfidence(fm);
698
+ }
699
+ else {
700
+ fm.failCount++;
701
+ // Confidence drops slightly on failure
702
+ fm.confidence = Math.max(0, fm.confidence - 0.05);
703
+ }
704
+ // Recompute tier
705
+ this.recomputeTier(data);
706
+ this.save(data);
707
+ }
708
+ /**
709
+ * Record a healing event — recovery from failure on a feature.
710
+ */
711
+ recordHealing(bundleId, featureKey) {
712
+ const data = this.ensureLoaded(bundleId);
713
+ if (!data)
714
+ return;
715
+ this.migrateToWeighted(data);
716
+ const fm = data.featureMastery[featureKey];
717
+ if (fm) {
718
+ fm.healingCount++;
719
+ fm.confidence = Math.min(1, fm.confidence + 0.05);
720
+ this.recomputeTier(data);
721
+ this.save(data);
722
+ }
723
+ }
724
+ /**
725
+ * Record a cross-feature workflow (end-to-end task spanning multiple features).
726
+ */
727
+ recordCrossFeatureWorkflow(bundleId) {
728
+ const data = this.ensureLoaded(bundleId);
729
+ if (!data)
730
+ return;
731
+ this.migrateToWeighted(data);
732
+ data.crossFeatureWorkflows++;
733
+ this.recomputeTier(data);
734
+ this.save(data);
735
+ }
736
+ /** @deprecated Use recordFeatureSignal. Kept for backward compat. */
737
+ recordFeatureCompletion(bundleId, featureKey) {
738
+ this.recordFeatureSignal(bundleId, featureKey, 1, true);
739
+ }
740
+ // ── Navigation Graph ──────────────────────────────────────────────
741
+ /**
742
+ * Record a page/view transition observed during tool execution.
743
+ * Auto-creates NavNodes for both pages and creates or updates the edge.
744
+ * Same-page transitions are ignored. Respects maxEdges config limit.
745
+ */
746
+ recordPageTransition(bundleId, fromPage, toPage, action) {
747
+ // M3: Reject empty page names
748
+ if (!fromPage || !toPage)
749
+ return;
750
+ // V2: Redact PII from page names before persistence
751
+ fromPage = redactPII(fromPage);
752
+ toPage = redactPII(toPage);
753
+ // Re-check after redaction in case both pages redact to the same string
754
+ if (fromPage === toPage)
755
+ return;
756
+ const data = this.ensureLoaded(bundleId);
757
+ if (!data)
758
+ return;
759
+ // Find existing edge with same from/action/to
760
+ const existing = data.navigationGraph.edges.find((e) => e.from === fromPage && e.action === action && e.to === toPage);
761
+ if (existing) {
762
+ existing.successCount++;
763
+ if (existing.successCount >= 2) {
764
+ existing.verified = true;
765
+ }
766
+ existing.lastUsed = new Date().toISOString();
767
+ }
768
+ else {
769
+ // Check limit before creating nodes or edge
770
+ if (data.navigationGraph.edges.length >= this.config.maxEdges)
771
+ return;
772
+ // Auto-create NavNodes only when we know the edge will be added
773
+ if (!data.navigationGraph.nodes[fromPage]) {
774
+ data.navigationGraph.nodes[fromPage] = {
775
+ type: "window",
776
+ description: fromPage,
777
+ };
778
+ }
779
+ if (!data.navigationGraph.nodes[toPage]) {
780
+ data.navigationGraph.nodes[toPage] = {
781
+ type: "window",
782
+ description: toPage,
783
+ };
784
+ }
785
+ data.navigationGraph.edges.push({
786
+ from: fromPage,
787
+ action,
788
+ to: toPage,
789
+ verified: false,
790
+ successCount: 1,
791
+ failCount: 0,
792
+ lastUsed: new Date().toISOString(),
793
+ });
794
+ }
795
+ this.save(data);
796
+ }
797
+ addNavNode(bundleId, nodeKey, node) {
798
+ const data = this.ensureLoaded(bundleId);
799
+ if (!data)
800
+ return;
801
+ data.navigationGraph.nodes[nodeKey] = node;
802
+ this.save(data);
803
+ }
804
+ addNavEdge(bundleId, edge) {
805
+ const data = this.ensureLoaded(bundleId);
806
+ if (!data)
807
+ return;
808
+ if (data.navigationGraph.edges.length >= this.config.maxEdges)
809
+ return;
810
+ // Deduplicate by from+action+to
811
+ const idx = data.navigationGraph.edges.findIndex((e) => e.from === edge.from && e.action === edge.action && e.to === edge.to);
812
+ if (idx >= 0) {
813
+ data.navigationGraph.edges[idx] = edge;
814
+ }
815
+ else {
816
+ data.navigationGraph.edges.push(edge);
817
+ }
818
+ this.save(data);
819
+ }
820
+ recordEdgeOutcome(bundleId, from, action, to, success) {
821
+ const data = this.ensureLoaded(bundleId);
822
+ if (!data)
823
+ return;
824
+ let edge = data.navigationGraph.edges.find((e) => e.from === from && e.action === action && e.to === to);
825
+ if (!edge) {
826
+ if (data.navigationGraph.edges.length >= this.config.maxEdges)
827
+ return;
828
+ edge = {
829
+ from,
830
+ action,
831
+ to,
832
+ verified: false,
833
+ successCount: 0,
834
+ failCount: 0,
835
+ lastUsed: new Date().toISOString(),
836
+ };
837
+ data.navigationGraph.edges.push(edge);
838
+ }
839
+ if (success) {
840
+ edge.successCount++;
841
+ if (edge.successCount >= 2) {
842
+ edge.verified = true;
843
+ }
844
+ }
845
+ else {
846
+ edge.failCount++;
847
+ }
848
+ edge.lastUsed = new Date().toISOString();
849
+ this.save(data);
850
+ }
851
+ // ── Hierarchy ────────────────────────────────────────────────────
852
+ /**
853
+ * Record a parent/child containment relationship within a zone.
854
+ * If the parent already has a hierarchy entry, merges children (no duplicates).
855
+ * Respects maxHierarchyEntriesPerZone limit.
856
+ */
857
+ recordHierarchy(bundleId, zoneKey, parentLabel, children, source) {
858
+ // M3: Reject empty parent label
859
+ if (!parentLabel)
860
+ return;
861
+ // V2: Redact PII from user-facing strings before persistence
862
+ parentLabel = redactPII(parentLabel);
863
+ children = redactStrings(children);
864
+ const data = this.ensureLoaded(bundleId);
865
+ if (!data)
866
+ return;
867
+ // Find or create zone
868
+ let zone = data.zones[zoneKey];
869
+ if (!zone) {
870
+ if (Object.keys(data.zones).length >= this.config.maxZonesPerApp)
871
+ return;
872
+ zone = {
873
+ relativePosition: { top: 0, left: 0, width: 1, height: 1 },
874
+ type: "other",
875
+ elements: [],
876
+ verified: false,
877
+ lastSeen: new Date().toISOString(),
878
+ };
879
+ data.zones[zoneKey] = zone;
880
+ }
881
+ if (!zone.hierarchy) {
882
+ zone.hierarchy = [];
883
+ }
884
+ // Deduplicate: find existing entry by parentLabel + parentZone
885
+ const existing = zone.hierarchy.find((h) => h.parentLabel === parentLabel && h.parentZone === zoneKey);
886
+ if (existing) {
887
+ // Merge children — add new ones, skip duplicates, cap at 200
888
+ const childSet = new Set(existing.children);
889
+ for (const child of children) {
890
+ if (childSet.size >= 200)
891
+ break;
892
+ childSet.add(child);
893
+ }
894
+ existing.children = [...childSet];
895
+ existing.source = source;
896
+ existing.lastSeen = new Date().toISOString();
897
+ }
898
+ else {
899
+ // Enforce limit
900
+ if (zone.hierarchy.length >= this.config.maxHierarchyEntriesPerZone)
901
+ return;
902
+ zone.hierarchy.push({
903
+ parentLabel,
904
+ parentZone: zoneKey,
905
+ children: [...new Set(children)],
906
+ source,
907
+ lastSeen: new Date().toISOString(),
908
+ });
909
+ }
910
+ this.save(data);
911
+ }
912
+ /**
913
+ * Get hierarchy entries for a zone or all zones.
914
+ * Returns empty array if no hierarchy data exists.
915
+ */
916
+ getHierarchy(bundleId, zoneKey) {
917
+ const data = this.ensureLoaded(bundleId);
918
+ if (!data)
919
+ return [];
920
+ if (zoneKey != null) {
921
+ const zone = data.zones[zoneKey];
922
+ return zone?.hierarchy ?? [];
923
+ }
924
+ // Collect from all zones
925
+ const result = [];
926
+ for (const zone of Object.values(data.zones)) {
927
+ if (zone.hierarchy) {
928
+ result.push(...zone.hierarchy);
929
+ }
930
+ }
931
+ return result;
932
+ }
933
+ // ── State Machine ──────────────────────────────────────────────────
934
+ /**
935
+ * Record a UI state change (e.g., sidebar expanded → collapsed).
936
+ * Auto-creates the StateDimension if new. If the transition already
937
+ * exists (same dimension + from + to + trigger), increments observedCount.
938
+ * Auto-detects reversibility: if A→B via trigger1 AND B→A via trigger2
939
+ * exist, sets reverseTrigger on both.
940
+ */
941
+ recordStateChange(bundleId, dimensionKey, fromValue, toValue, trigger) {
942
+ if (fromValue === toValue)
943
+ return; // No-op transitions are meaningless
944
+ // M3: Reject empty string keys/values
945
+ if (!dimensionKey || !fromValue || !toValue)
946
+ return;
947
+ // V2: Redact PII from user-visible state values (NOT dimensionKey — internal)
948
+ fromValue = redactPII(fromValue);
949
+ toValue = redactPII(toValue);
950
+ // Re-check after redaction in case both values redacted to the same string
951
+ if (fromValue === toValue)
952
+ return;
953
+ const data = this.ensureLoaded(bundleId);
954
+ if (!data)
955
+ return;
956
+ // Lazy-init arrays (optional fields on AppMapData)
957
+ if (!data.stateDimensions)
958
+ data.stateDimensions = [];
959
+ if (!data.stateTransitions)
960
+ data.stateTransitions = [];
961
+ const now = new Date().toISOString();
962
+ // ── Update or create StateTransition (BEFORE dimension update — M1 fix) ──
963
+ let tx = data.stateTransitions.find((t) => t.dimensionKey === dimensionKey &&
964
+ t.fromValue === fromValue &&
965
+ t.toValue === toValue &&
966
+ t.trigger === trigger);
967
+ if (tx) {
968
+ tx.observedCount++;
969
+ tx.lastSeen = now;
970
+ }
971
+ else {
972
+ // M1: If transition limit is hit, don't update dimension either
973
+ if (data.stateTransitions.length >= this.config.maxStateTransitions) {
974
+ this.save(data);
975
+ return;
976
+ }
977
+ tx = {
978
+ dimensionKey,
979
+ fromValue,
980
+ toValue,
981
+ trigger,
982
+ observedCount: 1,
983
+ lastSeen: now,
984
+ };
985
+ data.stateTransitions.push(tx);
986
+ }
987
+ // ── Update or create StateDimension (only after transition is accepted) ──
988
+ let dim = data.stateDimensions.find((d) => d.key === dimensionKey);
989
+ if (!dim) {
990
+ if (data.stateDimensions.length >= this.config.maxStateDimensions) {
991
+ this.save(data);
992
+ return;
993
+ }
994
+ dim = {
995
+ key: dimensionKey,
996
+ possibleValues: [],
997
+ currentValue: toValue,
998
+ lastObserved: now,
999
+ };
1000
+ data.stateDimensions.push(dim);
1001
+ }
1002
+ // Add from/to values to possibleValues if not already present (cap at 100)
1003
+ if (!dim.possibleValues.includes(fromValue) && dim.possibleValues.length < 100)
1004
+ dim.possibleValues.push(fromValue);
1005
+ if (!dim.possibleValues.includes(toValue) && dim.possibleValues.length < 100)
1006
+ dim.possibleValues.push(toValue);
1007
+ dim.currentValue = toValue;
1008
+ dim.lastObserved = now;
1009
+ // ── Auto-detect reversibility (M2: collect all reverse triggers) ──
1010
+ // Find ALL reverse transitions (B→A for this A→B)
1011
+ const reverses = data.stateTransitions.filter((t) => t.dimensionKey === dimensionKey &&
1012
+ t.fromValue === toValue &&
1013
+ t.toValue === fromValue);
1014
+ for (const reverse of reverses) {
1015
+ // Add reverse.trigger to tx.reverseTrigger array (deduplicate)
1016
+ // Migration: old persisted data may have reverseTrigger as string, not string[]
1017
+ if (!tx.reverseTrigger || typeof tx.reverseTrigger === "string") {
1018
+ const old = typeof tx.reverseTrigger === "string" ? [tx.reverseTrigger] : [];
1019
+ tx.reverseTrigger = old;
1020
+ }
1021
+ if (!tx.reverseTrigger.includes(reverse.trigger)) {
1022
+ tx.reverseTrigger.push(reverse.trigger);
1023
+ }
1024
+ // Add tx.trigger to reverse.reverseTrigger array (deduplicate)
1025
+ if (!reverse.reverseTrigger || typeof reverse.reverseTrigger === "string") {
1026
+ const old = typeof reverse.reverseTrigger === "string" ? [reverse.reverseTrigger] : [];
1027
+ reverse.reverseTrigger = old;
1028
+ }
1029
+ if (!reverse.reverseTrigger.includes(tx.trigger)) {
1030
+ reverse.reverseTrigger.push(tx.trigger);
1031
+ }
1032
+ }
1033
+ this.save(data);
1034
+ }
1035
+ /**
1036
+ * Get all known state dimensions for an app.
1037
+ * Returns empty array if no state has been recorded.
1038
+ */
1039
+ getStateDimensions(bundleId) {
1040
+ const data = this.ensureLoaded(bundleId);
1041
+ if (!data)
1042
+ return [];
1043
+ return data.stateDimensions ?? [];
1044
+ }
1045
+ /**
1046
+ * Get the current state snapshot: dimension key → current value.
1047
+ * Returns empty record if no state has been recorded.
1048
+ */
1049
+ getCurrentState(bundleId) {
1050
+ const data = this.ensureLoaded(bundleId);
1051
+ if (!data || !data.stateDimensions)
1052
+ return {};
1053
+ const result = {};
1054
+ for (const dim of data.stateDimensions) {
1055
+ result[dim.key] = dim.currentValue;
1056
+ }
1057
+ return result;
1058
+ }
1059
+ /**
1060
+ * Get all state transitions for an app (optionally filtered by dimension).
1061
+ */
1062
+ getStateTransitions(bundleId, dimensionKey) {
1063
+ const data = this.ensureLoaded(bundleId);
1064
+ if (!data || !data.stateTransitions)
1065
+ return [];
1066
+ if (dimensionKey) {
1067
+ return data.stateTransitions.filter((t) => t.dimensionKey === dimensionKey);
1068
+ }
1069
+ return data.stateTransitions;
1070
+ }
1071
+ // ── Query ─────────────────────────────────────────────────────────
1072
+ findElement(bundleId, label) {
1073
+ const data = this.ensureLoaded(bundleId);
1074
+ if (!data)
1075
+ return null;
1076
+ for (const [zoneKey, zone] of Object.entries(data.zones)) {
1077
+ const el = zone.elements.find((e) => e.label === label);
1078
+ if (el)
1079
+ return { zone: zoneKey, element: el };
1080
+ }
1081
+ return null;
1082
+ }
1083
+ resolvePosition(bundleId, label, windowBounds) {
1084
+ const found = this.findElement(bundleId, label);
1085
+ if (!found || found.element.relativeX === 0 && found.element.relativeY === 0)
1086
+ return null;
1087
+ return {
1088
+ x: Math.round(windowBounds.x + found.element.relativeX * windowBounds.width),
1089
+ y: Math.round(windowBounds.y + found.element.relativeY * windowBounds.height),
1090
+ };
1091
+ }
1092
+ /**
1093
+ * BFS pathfinding through the navigation graph.
1094
+ */
1095
+ findPath(bundleId, from, to) {
1096
+ const data = this.ensureLoaded(bundleId);
1097
+ if (!data)
1098
+ return null;
1099
+ if (from === to)
1100
+ return [];
1101
+ const edges = data.navigationGraph.edges;
1102
+ const visited = new Set();
1103
+ const queue = [{ node: from, path: [] }];
1104
+ visited.add(from);
1105
+ while (queue.length > 0) {
1106
+ const current = queue.shift();
1107
+ for (const edge of edges) {
1108
+ if (edge.from !== current.node)
1109
+ continue;
1110
+ if (visited.has(edge.to))
1111
+ continue;
1112
+ const newPath = [...current.path, edge];
1113
+ if (edge.to === to)
1114
+ return newPath;
1115
+ visited.add(edge.to);
1116
+ queue.push({ node: edge.to, path: newPath });
1117
+ }
1118
+ }
1119
+ return null;
1120
+ }
1121
+ // ── Mastery (Gated Weighted System) ─────────────────────────────
1122
+ /**
1123
+ * Compute per-feature confidence from evidence.
1124
+ * - 1 navigation: 0.25-0.4
1125
+ * - 1 basic action: 0.5-0.6
1126
+ * - Multi-step workflow repeated 3x: 0.75-0.85
1127
+ * - Verified outcome repeated 5x: 0.9-1.0
1128
+ */
1129
+ computeFeatureConfidence(fm) {
1130
+ const { depth, repeatCount, workflowCount } = fm;
1131
+ if (depth === 0)
1132
+ return 0;
1133
+ // Base confidence from depth
1134
+ const depthBase = { 1: 0.25, 2: 0.5, 3: 0.7, 4: 0.85 };
1135
+ let conf = depthBase[depth] ?? 0;
1136
+ // Repeat bonus: more repeats = more confidence, diminishing returns
1137
+ // Each repeat adds up to the ceiling for that depth
1138
+ const depthCeiling = { 1: 0.4, 2: 0.6, 3: 0.85, 4: 1.0 };
1139
+ const ceiling = depthCeiling[depth] ?? 0;
1140
+ const repeatBonus = Math.min(ceiling - conf, repeatCount * 0.03);
1141
+ conf += repeatBonus;
1142
+ // Workflow bonus for depth 3+
1143
+ if (depth >= 3 && workflowCount >= 3) {
1144
+ conf = Math.min(ceiling, conf + 0.05);
1145
+ }
1146
+ return Math.min(1, Math.max(0, conf));
1147
+ }
1148
+ /**
1149
+ * Compute aggregate mastery metrics from per-feature data.
1150
+ * criticalFloor is tier-scoped: for pro, only beginner+pro critical features count.
1151
+ * For expert, beginner+pro+expert. For grandmaster, all.
1152
+ */
1153
+ computeMetrics(data, tierScope) {
1154
+ this.migrateToWeighted(data);
1155
+ const ladder = data.featureLadder;
1156
+ if (!ladder.length)
1157
+ return this.emptyMetrics();
1158
+ const totalWeight = ladder.reduce((s, f) => s + f.weight, 0);
1159
+ let breadthWeight = 0;
1160
+ let workflowWeight = 0;
1161
+ let outcomeWeight = 0;
1162
+ let totalSuccesses = 0;
1163
+ let totalAttempts = 0;
1164
+ let totalHealings = 0;
1165
+ let totalFailures = 0;
1166
+ let weightedScore = 0;
1167
+ // Tier-scoped critical floor: only check critical features at or below the target tier
1168
+ const tierOrder = ["beginner", "pro", "expert", "grandmaster"];
1169
+ const scopeIdx = tierScope ? tierOrder.indexOf(tierScope) : 3; // default: all tiers
1170
+ const scopedLevels = new Set(tierOrder.slice(0, scopeIdx + 1));
1171
+ let criticalMinDepth = 999;
1172
+ let hasScopedCritical = false;
1173
+ for (const feature of ladder) {
1174
+ const fm = data.featureMastery[feature.id];
1175
+ const depth = fm?.depth ?? 0;
1176
+ const conf = fm?.confidence ?? 0;
1177
+ // Feature score = (depth/4) * weight * confidence
1178
+ weightedScore += (depth / 4) * feature.weight * conf;
1179
+ if (depth >= 2)
1180
+ breadthWeight += feature.weight;
1181
+ if (depth >= 3)
1182
+ workflowWeight += feature.weight;
1183
+ if (depth === 4)
1184
+ outcomeWeight += feature.weight;
1185
+ if (fm) {
1186
+ totalSuccesses += fm.repeatCount;
1187
+ totalAttempts += fm.repeatCount + fm.failCount;
1188
+ totalHealings += fm.healingCount;
1189
+ totalFailures += fm.failCount;
1190
+ }
1191
+ // Only check critical floor on features at or below the target tier
1192
+ if (feature.critical && scopedLevels.has(feature.level)) {
1193
+ hasScopedCritical = true;
1194
+ criticalMinDepth = Math.min(criticalMinDepth, depth);
1195
+ }
1196
+ }
1197
+ return {
1198
+ breadth: totalWeight > 0 ? breadthWeight / totalWeight : 0,
1199
+ workflowBreadth: totalWeight > 0 ? workflowWeight / totalWeight : 0,
1200
+ outcomeBreadth: totalWeight > 0 ? outcomeWeight / totalWeight : 0,
1201
+ reliability: totalAttempts > 0 ? totalSuccesses / totalAttempts : 0,
1202
+ healingRate: totalFailures > 0 ? totalHealings / totalFailures : 0,
1203
+ crossFeatureWorkflows: data.crossFeatureWorkflows,
1204
+ criticalFloor: hasScopedCritical ? (criticalMinDepth === 999 ? 0 : criticalMinDepth) : 0,
1205
+ weightedScore,
1206
+ };
1207
+ }
1208
+ /**
1209
+ * Determine mastery tier from hard gates.
1210
+ * Like engineering seniority: you can't fake your way to grandmaster.
1211
+ *
1212
+ * | Tier | Breadth(>=2) | Workflow(>=3) | Outcome(=4) | Reliability | Healing | Cross-feat | Critical |
1213
+ * |-------------|-------------|--------------|------------|------------|---------|-----------|---------|
1214
+ * | Beginner | >=20% | >=10% | 0-5% | >=60% | — | 0-1 | — |
1215
+ * | Pro | >=40% | >=25% | >=10% | >=80% | opt | >=2 | some>=2 |
1216
+ * | Expert | >=60% | >=45% | >=25% | >=90% | >=50% | >=4 | all>=3 |
1217
+ * | Grandmaster | >=80% | >=65% | >=40% | >=95% | >=80% | >=8 | all>=3, half=4 |
1218
+ */
1219
+ computeMasteryLevel(metricsOrConfidence, data) {
1220
+ // Backward compat: accept raw confidence number
1221
+ if (typeof metricsOrConfidence === "number") {
1222
+ const c = metricsOrConfidence;
1223
+ if (c >= 0.75)
1224
+ return "grandmaster";
1225
+ if (c >= 0.50)
1226
+ return "expert";
1227
+ if (c >= 0.25)
1228
+ return "pro";
1229
+ return "beginner";
1230
+ }
1231
+ const m = metricsOrConfidence;
1232
+ // Each tier checks critical floor scoped to its own level.
1233
+ // Pro only requires beginner+pro critical features at depth 2.
1234
+ // Expert adds expert-level critical features. Grandmaster checks all.
1235
+ const scopedFloor = (tier) => {
1236
+ if (!data)
1237
+ return m.criticalFloor;
1238
+ return this.computeMetrics(data, tier).criticalFloor;
1239
+ };
1240
+ // Grandmaster: you can operate, verify, recover, and repeat everything
1241
+ if (m.breadth >= 0.80 &&
1242
+ m.workflowBreadth >= 0.65 &&
1243
+ m.outcomeBreadth >= 0.40 &&
1244
+ m.reliability >= 0.95 &&
1245
+ m.healingRate >= 0.80 &&
1246
+ m.crossFeatureWorkflows >= 8 &&
1247
+ scopedFloor("grandmaster") >= 3)
1248
+ return "grandmaster";
1249
+ // Expert: deep operational competence
1250
+ if (m.breadth >= 0.60 &&
1251
+ m.workflowBreadth >= 0.45 &&
1252
+ m.outcomeBreadth >= 0.25 &&
1253
+ m.reliability >= 0.90 &&
1254
+ m.healingRate >= 0.50 &&
1255
+ m.crossFeatureWorkflows >= 4 &&
1256
+ scopedFloor("expert") >= 3)
1257
+ return "expert";
1258
+ // Pro: consistent operational user
1259
+ if (m.breadth >= 0.40 &&
1260
+ m.workflowBreadth >= 0.25 &&
1261
+ m.outcomeBreadth >= 0.10 &&
1262
+ m.reliability >= 0.80 &&
1263
+ m.crossFeatureWorkflows >= 2 &&
1264
+ scopedFloor("pro") >= 2)
1265
+ return "pro";
1266
+ // Beginner: has touched things
1267
+ if (m.breadth >= 0.20 &&
1268
+ m.workflowBreadth >= 0.10 &&
1269
+ m.reliability >= 0.60)
1270
+ return "beginner";
1271
+ return "beginner";
1272
+ }
1273
+ /**
1274
+ * Compute a single confidence number (0-1) for display/backward compat.
1275
+ * Derived from weighted score + metrics quality.
1276
+ */
1277
+ computeConfidence(data) {
1278
+ this.migrateToWeighted(data);
1279
+ const metrics = this.computeMetrics(data);
1280
+ const ladder = data.featureLadder;
1281
+ const maxPossibleScore = ladder.reduce((s, f) => s + f.weight, 0);
1282
+ if (maxPossibleScore === 0)
1283
+ return 0;
1284
+ // Weighted score normalized by max possible
1285
+ let confidence = metrics.weightedScore / maxPossibleScore;
1286
+ // Reliability bonus (up to +10%)
1287
+ if (metrics.reliability > 0) {
1288
+ confidence = Math.min(1, confidence + 0.10 * metrics.reliability);
1289
+ }
1290
+ // Staleness decay
1291
+ const daysSinceValidated = (Date.now() - new Date(data.lastValidated).getTime()) / (1000 * 60 * 60 * 24);
1292
+ if (daysSinceValidated > this.config.staleThresholdDays) {
1293
+ const decay = Math.max(0.3, 1.0 - (daysSinceValidated - this.config.staleThresholdDays) * 0.05);
1294
+ confidence *= decay;
1295
+ }
1296
+ return Math.max(0, Math.min(1, confidence));
1297
+ }
1298
+ /**
1299
+ * Recompute tier and confidence from current feature mastery state.
1300
+ */
1301
+ recomputeTier(data) {
1302
+ const metrics = this.computeMetrics(data);
1303
+ data.masteryMetrics = metrics;
1304
+ data.confidence = this.computeConfidence(data);
1305
+ data.masteryLevel = this.computeMasteryLevel(metrics, data);
1306
+ // Compute game-style rating (F→0)
1307
+ const factors = this.computeRatingFactors(data);
1308
+ data.ratingFactors = factors;
1309
+ data.rating = this.computeRating(factors);
1310
+ data.lastValidated = new Date().toISOString();
1311
+ }
1312
+ refreshMastery(bundleId) {
1313
+ const data = this.ensureLoaded(bundleId);
1314
+ if (!data)
1315
+ return;
1316
+ this.migrateToWeighted(data);
1317
+ this.recomputeTier(data);
1318
+ // Add history entry (deduplicate by date)
1319
+ const today = new Date().toISOString().split("T")[0];
1320
+ const lastHistory = data.masteryHistory[data.masteryHistory.length - 1];
1321
+ if (!lastHistory || lastHistory.date !== today) {
1322
+ data.masteryHistory.push({
1323
+ date: today,
1324
+ level: data.masteryLevel,
1325
+ rating: data.rating,
1326
+ confidence: data.confidence,
1327
+ zonesKnown: Object.keys(data.zones).length,
1328
+ edgesVerified: data.navigationGraph.edges.filter((e) => e.verified).length,
1329
+ });
1330
+ if (data.masteryHistory.length > this.config.maxHistoryEntries) {
1331
+ data.masteryHistory = data.masteryHistory.slice(-this.config.maxHistoryEntries);
1332
+ }
1333
+ }
1334
+ this.save(data);
1335
+ }
1336
+ /** Empty metrics baseline */
1337
+ emptyMetrics() {
1338
+ return {
1339
+ breadth: 0,
1340
+ workflowBreadth: 0,
1341
+ outcomeBreadth: 0,
1342
+ reliability: 0,
1343
+ healingRate: 0,
1344
+ crossFeatureWorkflows: 0,
1345
+ criticalFloor: 0,
1346
+ weightedScore: 0,
1347
+ };
1348
+ }
1349
+ /** Empty rating factors baseline (all zeros) */
1350
+ emptyRatingFactors() {
1351
+ return {
1352
+ featureCoverage: 0,
1353
+ workflowDepth: 0,
1354
+ outcomeVerification: 0,
1355
+ errorRecovery: 0,
1356
+ speedEfficiency: 0,
1357
+ crossFeatureChains: 0,
1358
+ edgeCaseHandling: 0,
1359
+ teachingAbility: 0,
1360
+ platformKnowledge: 0,
1361
+ consistency: 0,
1362
+ };
1363
+ }
1364
+ /**
1365
+ * Compute all 10 rating factors (each 0-100) from current app state.
1366
+ *
1367
+ * ALL evidence-based factors are SESSION-GATED: raw score × sessionMaturity.
1368
+ * sessionMaturity = min(sessionCount, 20) / 20 → maxes at 20 sessions.
1369
+ * This prevents inflating grades by automating everything in one burst.
1370
+ *
1371
+ * Hard-to-fake factors (consistency, platformKnowledge, edgeCaseHandling,
1372
+ * teachingAbility) have their own scaling that inherently requires time.
1373
+ */
1374
+ computeRatingFactors(data) {
1375
+ this.migrateToWeighted(data);
1376
+ const ladder = data.featureLadder;
1377
+ const metrics = this.computeMetrics(data);
1378
+ const totalWeight = ladder.reduce((s, f) => s + f.weight, 0);
1379
+ if (totalWeight === 0)
1380
+ return this.emptyRatingFactors();
1381
+ // Session gate: can't max coverage/workflow/verification in a few sessions.
1382
+ // Requires 20+ sessions to fully unlock. 4 sessions → 20% of raw score.
1383
+ const sessionMaturity = Math.min(data.sessionCount, 20) / 20;
1384
+ // ── Evidence-based factors (session-gated) ──────────────────────
1385
+ // 1. Feature Coverage: raw breadth × session maturity
1386
+ const featureCoverage = metrics.breadth * 100 * sessionMaturity;
1387
+ // 2. Workflow Depth: raw workflow breadth × session maturity
1388
+ const workflowDepth = metrics.workflowBreadth * 100 * sessionMaturity;
1389
+ // 3. Outcome Verification: raw outcome breadth × session maturity
1390
+ const outcomeVerification = metrics.outcomeBreadth * 100 * sessionMaturity;
1391
+ // 4. Error Recovery: healing rate — 0 if untested (no free pass)
1392
+ const totalFailures = Object.values(data.featureMastery).reduce((s, fm) => s + fm.failCount, 0);
1393
+ const errorRecovery = totalFailures > 0
1394
+ ? Math.min(100, metrics.healingRate * 100)
1395
+ : 0;
1396
+ // 5. Repeat Mastery (speedEfficiency field): features with 10+ repeats, session-gated
1397
+ // Measures whether you actually USE features repeatedly, not just touch them
1398
+ let featuresWithRepeats = 0;
1399
+ for (const f of ladder) {
1400
+ const fm = data.featureMastery[f.id];
1401
+ if (fm && fm.repeatCount >= 10)
1402
+ featuresWithRepeats++;
1403
+ }
1404
+ const repeatRaw = ladder.length > 0 ? (featuresWithRepeats / ladder.length) * 100 : 0;
1405
+ const speedEfficiency = repeatRaw * sessionMaturity;
1406
+ // 6. Cross-Feature Chains: need 50+ to max (was 12 — way too easy to inflate)
1407
+ const crossFeatureChains = Math.min(100, (data.crossFeatureWorkflows / 50) * 100);
1408
+ // ── Hard-to-fake factors (inherently time-gated) ────────────────
1409
+ // 7. Edge Case Handling: need 50+ edge cases (dialogs, errors, unexpected states)
1410
+ const edgeCasesHandled = data.edgeCasesHandled ?? 0;
1411
+ const edgeCaseHandling = Math.min(100, (edgeCasesHandled / 50) * 100);
1412
+ // 8. Teaching Ability: need 10+ playbooks exported
1413
+ const playbooksExported = data.playbooksExported ?? 0;
1414
+ const teachingAbility = Math.min(100, (playbooksExported / 10) * 100);
1415
+ // 9. Platform Knowledge: need 50+ shortcuts/hidden features
1416
+ const shortcutsUsed = data.shortcutsUsed ?? 0;
1417
+ const platformKnowledge = Math.min(100, (shortcutsUsed / 50) * 100);
1418
+ // 10. Consistency: need 50+ sessions × sustained reliability
1419
+ // THE hardest factor to game — requires showing up over time
1420
+ const consistencyFactor = Math.min(data.sessionCount, 50) / 50;
1421
+ const consistency = consistencyFactor * metrics.reliability * 100;
1422
+ return {
1423
+ featureCoverage: Math.round(Math.min(100, featureCoverage)),
1424
+ workflowDepth: Math.round(Math.min(100, workflowDepth)),
1425
+ outcomeVerification: Math.round(Math.min(100, outcomeVerification)),
1426
+ errorRecovery: Math.round(errorRecovery),
1427
+ speedEfficiency: Math.round(Math.min(100, speedEfficiency)),
1428
+ crossFeatureChains: Math.round(crossFeatureChains),
1429
+ edgeCaseHandling: Math.round(edgeCaseHandling),
1430
+ teachingAbility: Math.round(teachingAbility),
1431
+ platformKnowledge: Math.round(platformKnowledge),
1432
+ consistency: Math.round(Math.min(100, consistency)),
1433
+ };
1434
+ }
1435
+ /**
1436
+ * Compute game-style rating from the 10 weighted factors.
1437
+ * Returns grade (F→0) and sub-tier (1-3).
1438
+ */
1439
+ computeRating(factors) {
1440
+ // Weighted average of all 10 factors (0-100 scale)
1441
+ let weightedSum = 0;
1442
+ let totalWeight = 0;
1443
+ for (const [key, weight] of Object.entries(RATING_FACTOR_WEIGHTS)) {
1444
+ const score = factors[key];
1445
+ weightedSum += score * weight;
1446
+ totalWeight += weight;
1447
+ }
1448
+ const finalScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
1449
+ // Find grade from thresholds (sorted highest first)
1450
+ let grade = "F";
1451
+ for (const threshold of GRADE_THRESHOLDS) {
1452
+ if (finalScore >= threshold.minScore) {
1453
+ grade = threshold.grade;
1454
+ break;
1455
+ }
1456
+ }
1457
+ // Sub-tier within grade: divide the range into 3 equal parts
1458
+ const gradeIdx = GRADE_THRESHOLDS.findIndex(t => t.grade === grade);
1459
+ const gradeMin = GRADE_THRESHOLDS[gradeIdx].minScore;
1460
+ const gradeMax = gradeIdx > 0 ? GRADE_THRESHOLDS[gradeIdx - 1].minScore : 100;
1461
+ const range = gradeMax - gradeMin;
1462
+ const posInRange = finalScore - gradeMin;
1463
+ const subTier = range > 0
1464
+ ? (posInRange < range / 3 ? 1 : posInRange < (range * 2) / 3 ? 2 : 3)
1465
+ : 1;
1466
+ return { grade, subTier };
1467
+ }
1468
+ /**
1469
+ * Migrate old completedFeatures[] format to new featureMastery{} format.
1470
+ * Old features get depth=1 (navigated), since that's all we knew.
1471
+ */
1472
+ migrateToWeighted(data) {
1473
+ if (!data.featureMastery)
1474
+ data.featureMastery = {};
1475
+ if (!data.masteryMetrics)
1476
+ data.masteryMetrics = this.emptyMetrics();
1477
+ if (data.crossFeatureWorkflows == null)
1478
+ data.crossFeatureWorkflows = 0;
1479
+ if (!data.featureLadder)
1480
+ data.featureLadder = this.getFeatureLadder(data.app);
1481
+ // Migrate to rating system
1482
+ if (!data.rating)
1483
+ data.rating = { grade: "F", subTier: 1 };
1484
+ if (!data.ratingFactors)
1485
+ data.ratingFactors = this.emptyRatingFactors();
1486
+ if (data.shortcutsUsed == null)
1487
+ data.shortcutsUsed = 0;
1488
+ if (data.playbooksExported == null)
1489
+ data.playbooksExported = 0;
1490
+ if (data.edgeCasesHandled == null)
1491
+ data.edgeCasesHandled = 0;
1492
+ // Sync ladder with builtin: add new features, update weights/critical flags
1493
+ const builtin = this.getFeatureLadder(data.app);
1494
+ const existingIds = new Set(data.featureLadder.map((f) => f.id));
1495
+ const builtinIds = new Set(builtin.map((f) => f.id));
1496
+ let ladderChanged = false;
1497
+ // Add features that exist in builtin but not in data
1498
+ for (const bf of builtin) {
1499
+ if (!existingIds.has(bf.id)) {
1500
+ data.featureLadder.push(bf);
1501
+ ladderChanged = true;
1502
+ }
1503
+ else {
1504
+ // Update weight/critical/level from builtin (code is source of truth)
1505
+ const existing = data.featureLadder.find((f) => f.id === bf.id);
1506
+ if (existing && (existing.weight !== bf.weight || existing.critical !== bf.critical || existing.level !== bf.level)) {
1507
+ existing.weight = bf.weight;
1508
+ existing.critical = bf.critical;
1509
+ existing.level = bf.level;
1510
+ ladderChanged = true;
1511
+ }
1512
+ }
1513
+ }
1514
+ // Remove features that no longer exist in builtin (if using builtin ladder)
1515
+ if (builtinIds.size > 0) {
1516
+ // Migrate renamed features: old ID → closest new ID by mastery data
1517
+ const OLD_TO_NEW = {
1518
+ roles_notifications: "roles_permissions",
1519
+ community_design: "community_growth",
1520
+ moderation_tools: "moderation_system",
1521
+ bots_integrations: "bot_ecosystem",
1522
+ };
1523
+ for (const [oldId, newId] of Object.entries(OLD_TO_NEW)) {
1524
+ if (data.featureMastery[oldId] && !data.featureMastery[newId]) {
1525
+ data.featureMastery[newId] = data.featureMastery[oldId];
1526
+ delete data.featureMastery[oldId];
1527
+ ladderChanged = true;
1528
+ }
1529
+ }
1530
+ const toRemove = data.featureLadder.filter((f) => !builtinIds.has(f.id));
1531
+ if (toRemove.length > 0) {
1532
+ data.featureLadder = data.featureLadder.filter((f) => builtinIds.has(f.id));
1533
+ ladderChanged = true;
1534
+ }
1535
+ }
1536
+ if (ladderChanged) {
1537
+ // Sort ladder by builtin order
1538
+ const orderMap = new Map(builtin.map((f, i) => [f.id, i]));
1539
+ data.featureLadder.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
1540
+ }
1541
+ // Migrate old completedFeatures to depth=1 entries
1542
+ if (data.completedFeatures && data.completedFeatures.length > 0) {
1543
+ for (const fid of data.completedFeatures) {
1544
+ if (!data.featureMastery[fid]) {
1545
+ data.featureMastery[fid] = {
1546
+ depth: 1,
1547
+ confidence: 0.3,
1548
+ repeatCount: 1,
1549
+ workflowCount: 0,
1550
+ healingCount: 0,
1551
+ failCount: 0,
1552
+ lastSeen: data.lastValidated,
1553
+ lastVerified: null,
1554
+ };
1555
+ }
1556
+ }
1557
+ // Clear old field after migration
1558
+ data.completedFeatures = [];
1559
+ }
1560
+ }
1561
+ // ── Version / Staleness ───────────────────────────────────────────
1562
+ applyVersionChange(bundleId, newVersion) {
1563
+ const data = this.ensureLoaded(bundleId);
1564
+ if (!data)
1565
+ return;
1566
+ if (data.version === newVersion)
1567
+ return;
1568
+ data.version = newVersion;
1569
+ data.confidence *= this.config.versionDecayFactor;
1570
+ data.masteryLevel = this.computeMasteryLevel(data.confidence);
1571
+ // Unverify all edges on version change
1572
+ for (const edge of data.navigationGraph.edges) {
1573
+ edge.verified = false;
1574
+ }
1575
+ this.save(data);
1576
+ }
1577
+ applyStaleDecay(bundleId) {
1578
+ const data = this.ensureLoaded(bundleId);
1579
+ if (!data)
1580
+ return;
1581
+ data.confidence = this.computeConfidence(data);
1582
+ data.masteryLevel = this.computeMasteryLevel(data.confidence);
1583
+ this.save(data);
1584
+ }
1585
+ // ── Pruning ───────────────────────────────────────────────────────
1586
+ prune(bundleId) {
1587
+ const data = this.ensureLoaded(bundleId);
1588
+ if (!data)
1589
+ return;
1590
+ // Prune elements unused for too many sessions
1591
+ for (const zone of Object.values(data.zones)) {
1592
+ zone.elements = zone.elements.filter((e) => e.sessionsSinceUse < this.config.pruneSessionThreshold);
1593
+ }
1594
+ // Remove empty zones (except auto_discovered)
1595
+ for (const [key, zone] of Object.entries(data.zones)) {
1596
+ if (zone.elements.length === 0 && key !== "auto_discovered") {
1597
+ delete data.zones[key];
1598
+ }
1599
+ }
1600
+ // Cap zones
1601
+ const zoneKeys = Object.keys(data.zones);
1602
+ if (zoneKeys.length > this.config.maxZonesPerApp) {
1603
+ // Keep zones with most elements
1604
+ const sorted = zoneKeys.sort((a, b) => (data.zones[b]?.elements.length ?? 0) - (data.zones[a]?.elements.length ?? 0));
1605
+ for (const key of sorted.slice(this.config.maxZonesPerApp)) {
1606
+ delete data.zones[key];
1607
+ }
1608
+ }
1609
+ // Cap elements per zone
1610
+ for (const zone of Object.values(data.zones)) {
1611
+ if (zone.elements.length > this.config.maxElementsPerZone) {
1612
+ zone.elements.sort((a, b) => new Date(b.lastInteracted).getTime() - new Date(a.lastInteracted).getTime());
1613
+ zone.elements = zone.elements.slice(0, this.config.maxElementsPerZone);
1614
+ }
1615
+ }
1616
+ // Cap edges
1617
+ if (data.navigationGraph.edges.length > this.config.maxEdges) {
1618
+ data.navigationGraph.edges.sort((a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime());
1619
+ data.navigationGraph.edges = data.navigationGraph.edges.slice(0, this.config.maxEdges);
1620
+ }
1621
+ this.save(data);
1622
+ }
1623
+ // ── Session Tracking ──────────────────────────────────────────────
1624
+ incrementSession(bundleId) {
1625
+ const data = this.ensureLoaded(bundleId);
1626
+ if (!data)
1627
+ return;
1628
+ data.sessionCount++;
1629
+ // Increment sessionsSinceUse for all elements
1630
+ for (const zone of Object.values(data.zones)) {
1631
+ for (const el of zone.elements) {
1632
+ el.sessionsSinceUse++;
1633
+ }
1634
+ }
1635
+ this.save(data);
1636
+ }
1637
+ // ── Summary ───────────────────────────────────────────────────────
1638
+ getSummary(bundleId) {
1639
+ const data = this.ensureLoaded(bundleId);
1640
+ if (!data)
1641
+ return null;
1642
+ this.migrateToWeighted(data);
1643
+ const ladder = data.featureLadder ?? this.getFeatureLadder(bundleId);
1644
+ const metrics = this.computeMetrics(data);
1645
+ // Count features at each depth level
1646
+ let d1 = 0, d2 = 0, d3 = 0, d4 = 0;
1647
+ for (const f of ladder) {
1648
+ const fm = data.featureMastery[f.id];
1649
+ const d = fm?.depth ?? 0;
1650
+ if (d >= 1)
1651
+ d1++;
1652
+ if (d >= 2)
1653
+ d2++;
1654
+ if (d >= 3)
1655
+ d3++;
1656
+ if (d >= 4)
1657
+ d4++;
1658
+ }
1659
+ // Show game-style rating as primary, legacy tier in parentheses
1660
+ const rating = data.rating ?? { grade: "F", subTier: 1 };
1661
+ const ratingStr = ratingToString(rating);
1662
+ return (`Map: ${data.appName} — Rating ${ratingStr} [${data.masteryLevel.toUpperCase()}] ` +
1663
+ `(${(data.confidence * 100).toFixed(0)}%, ` +
1664
+ `${ladder.length} features [nav:${d1} act:${d2} wf:${d3} out:${d4}], ` +
1665
+ `rel:${(metrics.reliability * 100).toFixed(0)}% heal:${(metrics.healingRate * 100).toFixed(0)}% ` +
1666
+ `xwf:${metrics.crossFeatureWorkflows} crit:${metrics.criticalFloor})`);
1667
+ }
1668
+ // ── Conditional UI Tracking ─────────────────────────────────────
1669
+ /**
1670
+ * Record whether an element was seen (or absent) on a given page.
1671
+ * Auto-creates a VisibilityCondition if new, updates stats, and
1672
+ * auto-classifies the condition type from the accumulated pattern.
1673
+ */
1674
+ recordElementVisibility(bundleId, elementLabel, pageContext, seen) {
1675
+ // M3: Reject empty element label
1676
+ if (!elementLabel)
1677
+ return;
1678
+ // V2: Redact PII from element label before persistence
1679
+ elementLabel = redactPII(elementLabel);
1680
+ const data = this.ensureLoaded(bundleId);
1681
+ if (!data)
1682
+ return;
1683
+ if (!data.visibilityConditions)
1684
+ data.visibilityConditions = [];
1685
+ let vc = data.visibilityConditions.find((v) => v.elementLabel === elementLabel);
1686
+ const now = new Date().toISOString();
1687
+ if (!vc) {
1688
+ // Enforce limit before creating new entry
1689
+ if (data.visibilityConditions.length >= this.config.maxVisibilityConditions) {
1690
+ return;
1691
+ }
1692
+ vc = {
1693
+ elementLabel,
1694
+ conditionType: "unknown",
1695
+ description: "",
1696
+ seenOnPages: [],
1697
+ absentOnPages: [],
1698
+ seenCount: 0,
1699
+ checkCount: 0,
1700
+ visibilityRate: 0,
1701
+ lastSeen: now,
1702
+ firstSeen: now,
1703
+ };
1704
+ data.visibilityConditions.push(vc);
1705
+ }
1706
+ vc.checkCount++;
1707
+ if (seen) {
1708
+ vc.seenCount++;
1709
+ vc.lastSeen = now;
1710
+ if (pageContext && !vc.seenOnPages.includes(pageContext) && vc.seenOnPages.length < 100) {
1711
+ vc.seenOnPages.push(pageContext);
1712
+ }
1713
+ }
1714
+ else {
1715
+ if (pageContext && !vc.absentOnPages.includes(pageContext) && vc.absentOnPages.length < 100) {
1716
+ vc.absentOnPages.push(pageContext);
1717
+ }
1718
+ }
1719
+ vc.visibilityRate = vc.checkCount > 0 ? vc.seenCount / vc.checkCount : 0;
1720
+ // Auto-classify condition type from accumulated pattern
1721
+ vc.conditionType = this.classifyVisibilityCondition(vc);
1722
+ vc.description = this.describeVisibilityCondition(vc);
1723
+ this.save(data);
1724
+ }
1725
+ /**
1726
+ * Get elements with visibilityRate < 0.9 — elements that are NOT always visible.
1727
+ * These are the conditional UI elements worth tracking.
1728
+ */
1729
+ getConditionalElements(bundleId) {
1730
+ const data = this.ensureLoaded(bundleId);
1731
+ if (!data?.visibilityConditions)
1732
+ return [];
1733
+ return data.visibilityConditions.filter((v) => v.visibilityRate < 0.9);
1734
+ }
1735
+ /**
1736
+ * Get elements that have only been seen on a specific page.
1737
+ * Useful for knowing what UI to expect when navigating to a page.
1738
+ */
1739
+ getPageSpecificElements(bundleId, pageContext) {
1740
+ const data = this.ensureLoaded(bundleId);
1741
+ if (!data?.visibilityConditions)
1742
+ return [];
1743
+ return data.visibilityConditions.filter((v) => v.seenOnPages.includes(pageContext) &&
1744
+ v.seenOnPages.length === 1 &&
1745
+ v.absentOnPages.length > 0);
1746
+ }
1747
+ /**
1748
+ * Classify a visibility condition from its accumulated pattern.
1749
+ * - "page": only seen on specific pages, absent on others
1750
+ * - "session": seen early but not recently (visibilityRate < 0.5)
1751
+ * - "state": intermittent visibility (0.3-0.8), depends on app state
1752
+ * - "unknown": not enough data or doesn't fit other patterns
1753
+ */
1754
+ classifyVisibilityCondition(vc) {
1755
+ // Need at least 3 checks to start classifying
1756
+ if (vc.checkCount < 3)
1757
+ return "unknown";
1758
+ // Page-conditional: seen on some pages, absent on DIFFERENT pages.
1759
+ // If the same page appears in both seen and absent, that's state-dependent,
1760
+ // not page-dependent — the element appears/disappears on the same page.
1761
+ if (vc.seenOnPages.length > 0 &&
1762
+ vc.absentOnPages.length > 0 &&
1763
+ vc.visibilityRate < 0.9) {
1764
+ const hasDistinctPages = vc.absentOnPages.some((p) => !vc.seenOnPages.includes(p));
1765
+ if (hasDistinctPages) {
1766
+ return "page";
1767
+ }
1768
+ }
1769
+ // Session-conditional: seen early, disappeared (rate < 0.5 and
1770
+ // first observation is significantly older than last observation)
1771
+ if (vc.visibilityRate < 0.5 && vc.seenCount > 0) {
1772
+ const firstTime = new Date(vc.firstSeen).getTime();
1773
+ const lastSeenTime = new Date(vc.lastSeen).getTime();
1774
+ const age = Date.now() - firstTime;
1775
+ const recentness = Date.now() - lastSeenTime;
1776
+ // If element was seen long ago but not recently (>50% of its age ago)
1777
+ if (age > 0 && recentness > age * 0.5) {
1778
+ return "session";
1779
+ }
1780
+ }
1781
+ // State-conditional: intermittent visibility, depends on app state
1782
+ if (vc.visibilityRate >= 0.3 && vc.visibilityRate <= 0.8) {
1783
+ return "state";
1784
+ }
1785
+ return "unknown";
1786
+ }
1787
+ /**
1788
+ * Generate a human-readable description of when an element appears.
1789
+ */
1790
+ describeVisibilityCondition(vc) {
1791
+ switch (vc.conditionType) {
1792
+ case "page":
1793
+ return `Only visible on: ${vc.seenOnPages.join(", ")}`;
1794
+ case "session":
1795
+ return `Appeared in early sessions, not seen recently (${Math.round(vc.visibilityRate * 100)}% visibility)`;
1796
+ case "state":
1797
+ return `Intermittently visible (${Math.round(vc.visibilityRate * 100)}% of checks), likely state-dependent`;
1798
+ default:
1799
+ return `Visibility rate: ${Math.round(vc.visibilityRate * 100)}%`;
1800
+ }
1801
+ }
1802
+ // ── Timing & Animation ──────────────────────────────────────────────
1803
+ /**
1804
+ * Record a timing measurement for an element or action.
1805
+ * If an existing profile exists for the same key+type, updates the
1806
+ * running average: newAvg = (oldAvg * (n-1) + newValue) / n.
1807
+ * Respects maxTimingProfiles config limit.
1808
+ */
1809
+ recordTiming(bundleId, key, type, durationMs) {
1810
+ // M3: Reject empty key
1811
+ if (!key)
1812
+ return;
1813
+ // Guard: reject non-finite or negative durations to prevent data corruption
1814
+ if (!Number.isFinite(durationMs) || durationMs < 0)
1815
+ return;
1816
+ // V2: Redact PII from timing key before persistence
1817
+ key = redactPII(key);
1818
+ const data = this.ensureLoaded(bundleId);
1819
+ if (!data)
1820
+ return;
1821
+ if (!data.timingProfiles)
1822
+ data.timingProfiles = [];
1823
+ const now = new Date().toISOString();
1824
+ // Find existing profile for same key+type
1825
+ const existing = data.timingProfiles.find((p) => p.key === key && p.type === type);
1826
+ if (existing) {
1827
+ // Running average: newAvg = (oldAvg * (n-1) + newValue) / n
1828
+ const n = existing.sampleCount;
1829
+ existing.avgMs = (existing.avgMs * n + durationMs) / (n + 1);
1830
+ existing.minMs = Math.min(existing.minMs, durationMs);
1831
+ existing.maxMs = Math.max(existing.maxMs, durationMs);
1832
+ existing.sampleCount++;
1833
+ existing.lastMs = durationMs;
1834
+ existing.lastMeasured = now;
1835
+ }
1836
+ else {
1837
+ // Enforce limit
1838
+ if (data.timingProfiles.length >= this.config.maxTimingProfiles)
1839
+ return;
1840
+ data.timingProfiles.push({
1841
+ key,
1842
+ type,
1843
+ avgMs: durationMs,
1844
+ minMs: durationMs,
1845
+ maxMs: durationMs,
1846
+ sampleCount: 1,
1847
+ lastMs: durationMs,
1848
+ lastMeasured: now,
1849
+ });
1850
+ }
1851
+ this.save(data);
1852
+ }
1853
+ /**
1854
+ * Record a ready-state signal (how to know when UI is ready after an action).
1855
+ * If an existing signal for the same afterAction+signal exists, updates
1856
+ * typicalMs (running average) and maxObservedMs.
1857
+ * Respects maxReadySignals config limit.
1858
+ */
1859
+ recordReadySignal(bundleId, afterAction, signal, waitMs) {
1860
+ // M3: Reject empty action/signal
1861
+ if (!afterAction || !signal)
1862
+ return;
1863
+ // Guard: reject non-finite or negative wait times to prevent data corruption
1864
+ if (!Number.isFinite(waitMs) || waitMs < 0)
1865
+ return;
1866
+ // V2: Redact PII from user-facing strings before persistence
1867
+ afterAction = redactPII(afterAction);
1868
+ signal = redactPII(signal);
1869
+ const data = this.ensureLoaded(bundleId);
1870
+ if (!data)
1871
+ return;
1872
+ if (!data.readySignals)
1873
+ data.readySignals = [];
1874
+ const now = new Date().toISOString();
1875
+ // Find existing signal for same afterAction+signal
1876
+ const existing = data.readySignals.find((s) => s.afterAction === afterAction && s.signal === signal);
1877
+ if (existing) {
1878
+ // Running average for typicalMs
1879
+ const n = existing.sampleCount;
1880
+ existing.typicalMs = (existing.typicalMs * n + waitMs) / (n + 1);
1881
+ existing.maxObservedMs = Math.max(existing.maxObservedMs, waitMs);
1882
+ existing.sampleCount++;
1883
+ existing.lastSeen = now;
1884
+ }
1885
+ else {
1886
+ // Enforce limit
1887
+ if (data.readySignals.length >= this.config.maxReadySignals)
1888
+ return;
1889
+ data.readySignals.push({
1890
+ afterAction,
1891
+ signal,
1892
+ typicalMs: waitMs,
1893
+ maxObservedMs: waitMs,
1894
+ sampleCount: 1,
1895
+ lastSeen: now,
1896
+ });
1897
+ }
1898
+ this.save(data);
1899
+ }
1900
+ /**
1901
+ * Get timing profiles for an app, optionally filtered by key.
1902
+ * Returns empty array if no timing data exists.
1903
+ */
1904
+ getTimingProfile(bundleId, key) {
1905
+ const data = this.ensureLoaded(bundleId);
1906
+ if (!data?.timingProfiles)
1907
+ return [];
1908
+ if (key != null) {
1909
+ return data.timingProfiles.filter((p) => p.key === key);
1910
+ }
1911
+ return data.timingProfiles;
1912
+ }
1913
+ /**
1914
+ * Get all ready-state signals for an app.
1915
+ * Returns empty array if no signals exist.
1916
+ */
1917
+ getReadySignals(bundleId) {
1918
+ const data = this.ensureLoaded(bundleId);
1919
+ if (!data?.readySignals)
1920
+ return [];
1921
+ return data.readySignals;
1922
+ }
1923
+ /**
1924
+ * Get the expected wait time (in ms) for an action, based on recorded ready signals.
1925
+ * Returns null if no signal is recorded for the given action.
1926
+ * If multiple signals exist for the same action, returns the maximum typicalMs.
1927
+ */
1928
+ getExpectedWait(bundleId, action) {
1929
+ const data = this.ensureLoaded(bundleId);
1930
+ if (!data?.readySignals)
1931
+ return null;
1932
+ const matching = data.readySignals.filter((s) => s.afterAction === action);
1933
+ if (matching.length === 0)
1934
+ return null;
1935
+ // Return the max typicalMs across all matching signals
1936
+ let maxTypical = 0;
1937
+ for (const s of matching) {
1938
+ if (s.typicalMs > maxTypical)
1939
+ maxTypical = s.typicalMs;
1940
+ }
1941
+ return maxTypical;
1942
+ }
1943
+ // ── Internals ─────────────────────────────────────────────────────
1944
+ ensureLoaded(bundleId) {
1945
+ return this.cache.get(bundleId) ?? this.load(bundleId);
1946
+ }
1947
+ filePath(bundleId) {
1948
+ // Sanitize bundleId for filesystem safety — strip path traversal sequences first
1949
+ const safe = bundleId.replace(/\.\./g, "_").replace(/[^a-zA-Z0-9._-]/g, "_");
1950
+ return path.join(this.config.mapsDir, `${safe}.json`);
1951
+ }
1952
+ scheduleSave() {
1953
+ if (this.saveTimer)
1954
+ return;
1955
+ this.saveTimer = setTimeout(() => {
1956
+ this.saveTimer = null;
1957
+ this.writeDirty();
1958
+ }, 500);
1959
+ }
1960
+ writeDirty() {
1961
+ for (const bundleId of this.dirty) {
1962
+ const data = this.cache.get(bundleId);
1963
+ if (!data)
1964
+ continue;
1965
+ try {
1966
+ writeFileAtomicSync(this.filePath(bundleId), JSON.stringify(data, null, 2) + "\n");
1967
+ }
1968
+ catch {
1969
+ // Persistence failure is non-fatal
1970
+ }
1971
+ }
1972
+ this.dirty.clear();
1973
+ }
1974
+ }