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.
- package/README.md +193 -109
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +5876 -0
- package/dist/scripts/codex-monitor-daemon.js +335 -0
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/supervisor-daemon.js +272 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/scripts/worker-daemon.js +228 -0
- package/dist/src/agent/cli.js +82 -0
- package/dist/src/agent/loop.js +274 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/{src/config.ts → dist/src/config.js} +5 -10
- package/dist/src/context-tracker.js +489 -0
- package/{src/index.ts → dist/src/index.js} +32 -52
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +305 -0
- package/dist/src/jobs/runner.js +806 -0
- package/dist/src/jobs/store.js +102 -0
- package/dist/src/jobs/types.js +30 -0
- package/dist/src/jobs/worker.js +97 -0
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +48 -0
- package/dist/src/mcp/mcp-stdio-server.js +464 -0
- package/dist/src/mcp/server.js +363 -0
- package/dist/src/mcp-entry.js +60 -0
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +222 -0
- package/dist/src/memory/research.js +104 -0
- package/dist/src/memory/seeds.js +101 -0
- package/dist/src/memory/service.js +446 -0
- package/dist/src/memory/session.js +169 -0
- package/dist/src/memory/store.js +451 -0
- package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
- package/dist/src/monitor/codex-monitor.js +382 -0
- package/dist/src/monitor/task-queue.js +97 -0
- package/dist/src/monitor/types.js +62 -0
- package/dist/src/native/bridge-client.js +412 -0
- package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +486 -0
- package/dist/src/playbook/index.js +20 -0
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +536 -0
- package/dist/src/playbook/runner.js +408 -0
- package/dist/src/playbook/store.js +312 -0
- package/dist/src/playbook/types.js +17 -0
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +430 -0
- package/dist/src/runtime/app-adapter.js +64 -0
- package/dist/src/runtime/applescript-adapter.js +305 -0
- package/dist/src/runtime/ax-role-map.js +96 -0
- package/dist/src/runtime/browser-adapter.js +52 -0
- package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
- package/dist/src/runtime/composite-adapter.js +221 -0
- package/dist/src/runtime/execution-contract.js +159 -0
- package/dist/src/runtime/executor.js +286 -0
- package/dist/src/runtime/locator-cache.js +50 -0
- package/dist/src/runtime/planning-loop.js +63 -0
- package/dist/src/runtime/service.js +432 -0
- package/dist/src/runtime/session-manager.js +63 -0
- package/dist/src/runtime/state-observer.js +121 -0
- package/dist/src/runtime/vision-adapter.js +225 -0
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/supervisor/locks.js +186 -0
- package/dist/src/supervisor/supervisor.js +403 -0
- package/dist/src/supervisor/types.js +30 -0
- package/dist/src/test-mcp-protocol.js +154 -0
- package/dist/src/types.js +17 -0
- package/dist/src/util/atomic-write.js +133 -0
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +1 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
- package/native/macos-bridge/Sources/AppManagement.swift +212 -2
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
- package/native/macos-bridge/Sources/main.swift +169 -16
- package/native/windows-bridge/Program.cs +5 -0
- package/native/windows-bridge/ScreenCapture.cs +124 -0
- package/package.json +29 -4
- package/scripts/postinstall.cjs +127 -0
- package/.claude/commands/automate.md +0 -28
- package/.claude/commands/debug-ui.md +0 -19
- package/.claude/commands/screenshot.md +0 -15
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.mcp.json +0 -8
- package/DESKTOP_MCP_GUIDE.md +0 -92
- package/SECURITY.md +0 -44
- package/docs/architecture.md +0 -47
- package/install-skills.sh +0 -19
- package/mcp-bridge.ts +0 -271
- package/mcp-desktop.ts +0 -1221
- package/playbooks/instagram.json +0 -41
- package/playbooks/instagram_v2.json +0 -201
- package/playbooks/x_v1.json +0 -211
- package/scripts/devpost-live-loop.mjs +0 -421
- package/src/logging/timeline-logger.ts +0 -55
- package/src/mcp/server.ts +0 -449
- package/src/memory/recall.ts +0 -191
- package/src/memory/research.ts +0 -146
- package/src/memory/seeds.ts +0 -123
- package/src/memory/session.ts +0 -201
- package/src/memory/store.ts +0 -434
- package/src/memory/types.ts +0 -69
- package/src/native/bridge-client.ts +0 -239
- package/src/runtime/accessibility-adapter.ts +0 -487
- package/src/runtime/app-adapter.ts +0 -169
- package/src/runtime/applescript-adapter.ts +0 -376
- package/src/runtime/ax-role-map.ts +0 -102
- package/src/runtime/browser-adapter.ts +0 -129
- package/src/runtime/cdp-chrome-adapter.ts +0 -676
- package/src/runtime/composite-adapter.ts +0 -274
- package/src/runtime/executor.ts +0 -396
- package/src/runtime/planning-loop.ts +0 -81
- package/src/runtime/service.ts +0 -448
- package/src/runtime/session-manager.ts +0 -50
- package/src/runtime/state-observer.ts +0 -136
- package/src/runtime/vision-adapter.ts +0 -297
- package/src/types.ts +0 -297
- package/tests/bridge-client.test.ts +0 -176
- package/tests/browser-stealth.test.ts +0 -210
- package/tests/composite-adapter.test.ts +0 -64
- package/tests/mcp-server.test.ts +0 -151
- package/tests/memory-recall.test.ts +0 -339
- package/tests/memory-research.test.ts +0 -159
- package/tests/memory-seeds.test.ts +0 -120
- package/tests/memory-store.test.ts +0 -392
- package/tests/types.test.ts +0 -92
- package/tsconfig.check.json +0 -17
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -8
- /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
|
+
}
|