screenhand 0.3.9 → 0.4.1

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.
@@ -28,18 +28,30 @@ const SKIP_TOOLS = new Set([
28
28
  "codex_monitor_start", "codex_monitor_status", "codex_monitor_add_task",
29
29
  "codex_monitor_tasks", "codex_monitor_assign_now", "codex_monitor_stop",
30
30
  "platform_learn", "platform_explore",
31
+ // Observation tools — not steps
32
+ "read_with_fallback", "locate_with_fallback", "execution_plan",
33
+ "browser_stealth",
34
+ // Observer/orchestrator lifecycle — not steps
35
+ "observer_start", "observer_stop", "observer_status", "observer_ocr_roi",
36
+ "orchestrator_start", "orchestrator_stop", "orchestrator_submit", "orchestrator_status",
37
+ // Ingestion/discovery — learning, not steps
38
+ "scan_menu_bar", "ingest_documentation", "ingest_tutorial",
39
+ "discover_features", "coverage_report",
31
40
  ]);
32
41
  /** Map MCP tool names to PlaybookStep actions */
33
42
  function mapToolToAction(toolName) {
34
43
  switch (toolName) {
35
- case "browser_navigate": return "navigate";
44
+ case "browser_navigate":
45
+ case "browser_open": return "navigate";
36
46
  case "click":
37
47
  case "click_text":
38
48
  case "browser_click":
49
+ case "browser_human_click":
39
50
  case "click_with_fallback":
40
51
  case "ui_press": return "press";
41
52
  case "type_text":
42
53
  case "browser_type":
54
+ case "browser_fill_form":
43
55
  case "type_with_fallback": return "type_into";
44
56
  case "key": return "key";
45
57
  case "menu_click": return "menu_click";
@@ -50,6 +62,9 @@ function mapToolToAction(toolName) {
50
62
  case "screenshot_file": return "screenshot";
51
63
  case "browser_wait":
52
64
  case "wait_for_state": return "wait";
65
+ case "applescript": return "applescript";
66
+ case "ui_set_value": return "type_into";
67
+ case "select_with_fallback": return "press";
53
68
  case "focus":
54
69
  case "launch": return null; // useful context but not a step
55
70
  case "drag": return null; // drag is complex, skip for now
@@ -108,6 +123,10 @@ function buildStep(toolName, params, success) {
108
123
  step.ms = Number(params.timeout ?? params.ms ?? params.timeoutMs ?? 1000);
109
124
  step.description = `Wait ${step.ms}ms`;
110
125
  break;
126
+ case "applescript":
127
+ step.script = String(params.script ?? "");
128
+ step.description = `AppleScript: ${step.script.substring(0, 80)}${step.script.length > 80 ? "..." : ""}`;
129
+ break;
111
130
  }
112
131
  if (!success) {
113
132
  step.optional = true;
@@ -168,6 +187,7 @@ export class McpPlaybookRecorder {
168
187
  ...(typeof s.target === "string" ? { target: redactPII(s.target) } : {}),
169
188
  ...(s.url ? { url: redactPII(s.url) } : {}),
170
189
  ...(s.code ? { code: redactPII(s.code) } : {}),
190
+ ...(s.script ? { script: redactPII(s.script) } : {}),
171
191
  ...(s.description ? { description: redactPII(s.description) } : {}),
172
192
  }));
173
193
  const playbook = {
@@ -11,59 +11,59 @@ import { generateLadderFromReference } from "./ladder-generator.js";
11
11
  // Define what real users do at each level. Used to measure honest mastery.
12
12
  const BUILTIN_LADDERS = {
13
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 },
14
+ // ── F tier: basic consumer actions (weight 1) ──
15
+ { id: "browse_channels", description: "Join servers and browse channels", level: "F", weight: 1, critical: false },
16
+ { id: "send_message", description: "Send messages, replies, emojis, and reactions", level: "F", weight: 1, critical: false },
17
+ { id: "direct_messages", description: "Direct messages and group chats", level: "F", weight: 1, critical: false },
18
+ { id: "voice_video", description: "Voice channels, video calls, and screen share", level: "F", weight: 1, critical: false },
19
+ // ── B tier: operational features (weight 2) ──
20
+ { id: "threads_forums", description: "Create and manage threads and forum channels", level: "B", weight: 2, critical: false },
21
+ { id: "roles_permissions", description: "Configure roles, overrides, inheritance, hidden channels", level: "B", weight: 2, critical: true },
22
+ { id: "events_stage", description: "Schedule events, run Stage channels, manage speakers", level: "B", weight: 2, critical: false },
23
+ { id: "onboarding_funnel", description: "Build join flows: rules screening, role assignment, starter channels", level: "B", weight: 2, critical: true },
24
+ { id: "notification_control", description: "Channel overrides, mention control, suppression settings", level: "B", weight: 1, critical: false },
25
+ // ── S tier: system-level features (weight 2-3) ──
26
+ { id: "moderation_system", description: "Configure AutoMod, mod bots, alert flows, ban appeals, raid defense", level: "S", weight: 3, critical: true },
27
+ { id: "bot_ecosystem", description: "Combine bots, slash commands, webhooks into coherent server OS", level: "S", weight: 3, critical: true },
28
+ { id: "server_architecture", description: "Design categories, channel taxonomy, permissions, escalation paths", level: "S", weight: 3, critical: true },
29
+ { id: "community_growth", description: "Events, role rewards, content loops, announcements, retention mechanics", level: "S", weight: 2, critical: false },
30
+ { id: "analytics_health", description: "Track activity patterns, onboarding drop-off, channel usage, retention", level: "S", weight: 2, critical: true },
31
+ // ── SSS tier: grandmaster operations (weight 3) ──
32
+ { id: "monetization_membership", description: "Premium roles, gated channels, supporter tiers, creator monetization", level: "SSS", weight: 2, critical: false },
33
+ { id: "crisis_handling", description: "Handle raids, harassment, spam, leaks, impersonation, conflicts", level: "SSS", weight: 3, critical: true },
34
+ { id: "cross_platform", description: "Connect Discord with GitHub, Notion, Twitch, Stripe, Zapier, tools", level: "SSS", weight: 2, critical: false },
35
+ { id: "staff_system", description: "Structure mod roles, escalation, internal channels, review processes", level: "SSS", weight: 3, critical: true },
36
+ { id: "brand_culture", description: "Shape tone, rituals, norms, recognition systems, community identity", level: "SSS", weight: 2, critical: false },
37
+ { id: "governance_policy", description: "Define rules, enforcement, appeals, social boundaries that hold up", level: "SSS", weight: 3, critical: true },
38
38
  ],
39
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 },
40
+ { id: "browse_navigate", description: "Open URLs and navigate pages", level: "F", weight: 1, critical: false },
41
+ { id: "tabs_windows", description: "Manage tabs and windows", level: "F", weight: 1, critical: false },
42
+ { id: "bookmarks", description: "Bookmarks and reading list", level: "F", weight: 1, critical: false },
43
+ { id: "history_search", description: "History and search", level: "F", weight: 1, critical: false },
44
+ { id: "tab_groups", description: "Tab groups and profiles", level: "B", weight: 2, critical: false },
45
+ { id: "extensions", description: "Install and use extensions", level: "B", weight: 2, critical: false },
46
+ { id: "dev_tools", description: "Web Inspector and developer tools", level: "S", weight: 2, critical: true },
47
+ { id: "privacy_settings", description: "Privacy, cookies, and content blockers", level: "S", weight: 2, critical: false },
48
+ { id: "web_apps", description: "Add to Dock, web apps, notifications", level: "SSS", weight: 2, critical: false },
49
49
  ],
50
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 },
51
+ { id: "browse_files", description: "Browse and open files/folders", level: "F", weight: 1, critical: false },
52
+ { id: "copy_move", description: "Copy, move, rename, delete files", level: "F", weight: 1, critical: false },
53
+ { id: "search", description: "Spotlight and Finder search", level: "F", weight: 1, critical: false },
54
+ { id: "views_sort", description: "Change views, sort, and organize", level: "B", weight: 2, critical: false },
55
+ { id: "tags_favorites", description: "Tags, favorites, and sidebar", level: "B", weight: 2, critical: false },
56
+ { id: "quick_actions", description: "Quick Look, Quick Actions, and Services", level: "S", weight: 2, critical: true },
57
+ { id: "automator_scripts", description: "Automator, terminal, and scripting", level: "SSS", weight: 2, critical: false },
58
58
  ],
59
59
  };
60
60
  /** Generic fallback ladder — used when no builtin AND no reference-generated ladder exists. */
61
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 },
62
+ { id: "basic_navigation", description: "Open, navigate, and browse the app", level: "F", weight: 1, critical: false },
63
+ { id: "core_action", description: "Perform the app's primary action", level: "F", weight: 1, critical: false },
64
+ { id: "settings", description: "Configure settings and preferences", level: "B", weight: 2, critical: false },
65
+ { id: "advanced_features", description: "Use advanced/power-user features", level: "S", weight: 2, critical: true },
66
+ { id: "automation", description: "Automate or customize workflows", level: "SSS", weight: 3, critical: true },
67
67
  ];
68
68
  /** Redact an array of user-facing strings in place, returning a new array. */
69
69
  function redactStrings(strings) {
@@ -289,11 +289,11 @@ export class AppMap {
289
289
  if (discoveredCount >= 30)
290
290
  return null;
291
291
  // Assign level based on tool complexity
292
- let level = "beginner";
292
+ let level = "F";
293
293
  if (toolName === "menu_click" || toolName === "key")
294
- level = "pro";
294
+ level = "B";
295
295
  if (toolName === "applescript" || toolName === "browser_js")
296
- level = "expert";
296
+ level = "S";
297
297
  const feature = {
298
298
  id: featureId,
299
299
  description: `[auto] ${target}`,
@@ -1435,9 +1435,14 @@ export class AppMap {
1435
1435
  let totalFailures = 0;
1436
1436
  let weightedScore = 0;
1437
1437
  // Tier-scoped critical floor: only check critical features at or below the target tier
1438
- const tierOrder = ["beginner", "pro", "expert", "grandmaster"];
1439
- const scopeIdx = tierScope ? tierOrder.indexOf(tierScope) : 3; // default: all tiers
1440
- const scopedLevels = new Set(tierOrder.slice(0, scopeIdx + 1));
1438
+ // MasteryLevel (deprecated) maps to FeatureTier: beginner→F, pro→B, expert→S, grandmaster→SSS
1439
+ const masteryToFeatureTier = {
1440
+ beginner: "F", pro: "B", expert: "S", grandmaster: "SSS",
1441
+ };
1442
+ const featureTierOrder = ["F", "B", "S", "SSS"];
1443
+ const scopedFeatureTier = tierScope ? masteryToFeatureTier[tierScope] : "SSS";
1444
+ const scopeIdx = featureTierOrder.indexOf(scopedFeatureTier);
1445
+ const scopedLevels = new Set(featureTierOrder.slice(0, scopeIdx + 1));
1441
1446
  let criticalMinDepth = 999;
1442
1447
  let hasScopedCritical = false;
1443
1448
  for (const feature of ladder) {
@@ -1,19 +1,19 @@
1
1
  // Copyright (C) 2025 Clazro Technology Private Limited
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
- // ── Keyword sets for level assignment ────────────────────────────
4
- const BEGINNER_KEYWORDS = new Set([
3
+ // ── Keyword sets for tier assignment (F=entry, B=proficient, S=expert, SSS=grandmaster) ──
4
+ const F_KEYWORDS = new Set([
5
5
  "navigation", "browse", "basic", "search", "header", "page", "nav", "home",
6
6
  "page_header", "quick_find",
7
7
  ]);
8
- const PRO_KEYWORDS = new Set([
8
+ const B_KEYWORDS = new Set([
9
9
  "settings", "create", "views", "sort", "filter", "template", "new", "sidebar",
10
10
  "create_new", "slash_commands", "notification", "preferences",
11
11
  ]);
12
- const EXPERT_KEYWORDS = new Set([
12
+ const S_KEYWORDS = new Set([
13
13
  "database", "admin", "moderation", "permissions", "automation", "advanced",
14
14
  "server", "import", "export", "workflow", "security", "form",
15
15
  ]);
16
- const GRANDMASTER_KEYWORDS = new Set([
16
+ const SSS_KEYWORDS = new Set([
17
17
  "ai", "api", "integration", "custom", "governance", "crisis", "identity",
18
18
  "billing", "orchestrat", "pipeline",
19
19
  ]);
@@ -32,8 +32,10 @@ export function generateLadderFromReference(ref) {
32
32
  const selectorGroups = ref.selectors ?? {};
33
33
  const flows = ref.flows ?? {};
34
34
  // Minimum threshold: need at least 2 meaningful selector groups
35
+ // (but website features can stand alone — they come from official sources)
35
36
  const meaningfulGroups = Object.keys(selectorGroups).filter(k => !SKIP_GROUPS.has(k));
36
- if (meaningfulGroups.length < 2 && Object.keys(flows).length < 2) {
37
+ const hasWebsiteFeatures = (ref.websiteFeatures?.length ?? 0) > 0 || (ref.valueAddFeatures?.length ?? 0) > 0;
38
+ if (meaningfulGroups.length < 2 && Object.keys(flows).length < 2 && !hasWebsiteFeatures) {
37
39
  return { ladder: [], signals: {}, hash: computeHash(ref) };
38
40
  }
39
41
  // Track which flow names are already covered by selector groups
@@ -76,9 +78,53 @@ export function generateLadderFromReference(ref) {
76
78
  const keywords = extractKeywordsFromFlow(flowName, flow);
77
79
  signals[featureId] = keywords;
78
80
  }
81
+ // ── Step 2.5: Features from website extraction ─────────────────
82
+ if (ref.websiteFeatures) {
83
+ for (const wf of ref.websiteFeatures) {
84
+ const featureId = `web_${wf.id}`;
85
+ if (features.some((f) => f.id === featureId))
86
+ continue;
87
+ // Skip if an exact selector group or flow already covers this feature
88
+ // (use exact key match, not fuzzy flowNamesRelated — avoids false positives
89
+ // where a short website feature id like "export" matches "export_pdf_flow")
90
+ if (selectorGroups[wf.id] !== undefined || flows[wf.id] !== undefined)
91
+ continue;
92
+ const level = (["F", "B", "S", "SSS"].includes(wf.level)
93
+ ? wf.level
94
+ : "F");
95
+ const weight = assignWeight(wf.id, 0, level);
96
+ features.push({
97
+ id: featureId,
98
+ description: wf.description || wf.name,
99
+ level,
100
+ weight,
101
+ critical: false,
102
+ });
103
+ signals[featureId] = extractKeywordsFromName(wf.name);
104
+ }
105
+ }
106
+ // ── Step 2.6: ScreenHand value-add features ───────────────────
107
+ if (ref.valueAddFeatures) {
108
+ for (const va of ref.valueAddFeatures) {
109
+ const featureId = `va_${va.id}`;
110
+ if (features.some((f) => f.id === featureId))
111
+ continue;
112
+ const level = (["B", "S", "SSS"].includes(va.level)
113
+ ? va.level
114
+ : "S");
115
+ features.push({
116
+ id: featureId,
117
+ description: va.description,
118
+ level,
119
+ weight: 2,
120
+ critical: false,
121
+ });
122
+ signals[featureId] = extractKeywordsFromName(va.name);
123
+ }
124
+ }
79
125
  // ── Step 3: Sort by level progression ──────────────────────────
80
126
  const levelOrder = {
81
- beginner: 0, pro: 1, expert: 2, grandmaster: 3,
127
+ F: 0, B: 1, S: 2, SSS: 3,
82
128
  };
83
129
  features.sort((a, b) => levelOrder[a.level] - levelOrder[b.level]);
84
130
  return { ladder: features, signals, hash: computeHash(ref) };
@@ -87,37 +133,37 @@ export function generateLadderFromReference(ref) {
87
133
  function assignLevel(name, selectorCount, isFlow, complexity) {
88
134
  const nameLower = name.toLowerCase();
89
135
  const parts = nameLower.split(/[_\s-]+/);
90
- // Check grandmaster first (most specific)
91
- if (parts.some(p => GRANDMASTER_KEYWORDS.has(p)))
92
- return "grandmaster";
93
- if (parts.some(p => EXPERT_KEYWORDS.has(p)))
94
- return "expert";
95
- if (parts.some(p => PRO_KEYWORDS.has(p)))
96
- return "pro";
97
- if (parts.some(p => BEGINNER_KEYWORDS.has(p)))
98
- return "beginner";
136
+ // Check SSS first (most specific)
137
+ if (parts.some(p => SSS_KEYWORDS.has(p)))
138
+ return "SSS";
139
+ if (parts.some(p => S_KEYWORDS.has(p)))
140
+ return "S";
141
+ if (parts.some(p => B_KEYWORDS.has(p)))
142
+ return "B";
143
+ if (parts.some(p => F_KEYWORDS.has(p)))
144
+ return "F";
99
145
  // Fallback based on complexity
100
146
  if (isFlow) {
101
147
  if (complexity >= 8)
102
- return "expert";
148
+ return "S";
103
149
  if (complexity >= 5)
104
- return "pro";
105
- return "beginner";
150
+ return "B";
151
+ return "F";
106
152
  }
107
153
  // Selector group: more selectors = more complex
108
154
  if (selectorCount >= 8)
109
- return "expert";
155
+ return "S";
110
156
  if (selectorCount >= 4)
111
- return "pro";
112
- return "beginner";
157
+ return "B";
158
+ return "F";
113
159
  }
114
160
  // ── Weight assignment ────────────────────────────────────────────
115
161
  function assignWeight(name, complexity, level) {
116
- if (level === "grandmaster")
162
+ if (level === "SSS")
117
163
  return 3;
118
- if (level === "expert" && complexity >= 6)
164
+ if (level === "S" && complexity >= 6)
119
165
  return 3;
120
- if (level === "expert" || level === "pro")
166
+ if (level === "S" || level === "B")
121
167
  return 2;
122
168
  return 1;
123
169
  }
@@ -204,6 +250,17 @@ function extractKeywordsFromFlow(flowName, flow) {
204
250
  }
205
251
  return deduplicateArray(keywords);
206
252
  }
253
+ // ── Keyword extraction from feature name ──────────────────────
254
+ function extractKeywordsFromName(name) {
255
+ const keywords = [];
256
+ for (const part of name.split(/[\s_-]+/)) {
257
+ const lower = part.toLowerCase().replace(/[^a-z0-9]/g, "");
258
+ if (lower.length > 2 && !STOP_WORDS.has(lower)) {
259
+ keywords.push(lower);
260
+ }
261
+ }
262
+ return deduplicateArray(keywords);
263
+ }
207
264
  // ── Flow-to-selector group name matching ─────────────────────────
208
265
  function flowNamesRelated(groupName, flowName) {
209
266
  const gParts = new Set(groupName.split("_"));
@@ -222,6 +279,8 @@ function computeHash(ref) {
222
279
  const keys = [
223
280
  ...Object.keys(ref.selectors ?? {}).sort(),
224
281
  ...Object.keys(ref.flows ?? {}).sort(),
282
+ ...(ref.websiteFeatures ?? []).map((f) => f.id).sort(),
283
+ ...(ref.valueAddFeatures ?? []).map((f) => f.id).sort(),
225
284
  ].join("|");
226
285
  // Simple string hash (djb2)
227
286
  let hash = 5381;