neuron-inspector 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +345 -42
  2. package/dist/automations/executor.d.ts +21 -0
  3. package/dist/automations/executor.js +835 -0
  4. package/dist/automations/executor.js.map +1 -0
  5. package/dist/automations/index.d.ts +7 -0
  6. package/dist/automations/index.js +21 -0
  7. package/dist/automations/index.js.map +1 -0
  8. package/dist/automations/store.d.ts +15 -0
  9. package/dist/automations/store.js +193 -0
  10. package/dist/automations/store.js.map +1 -0
  11. package/dist/automations/tools.d.ts +14 -0
  12. package/dist/automations/tools.js +735 -0
  13. package/dist/automations/tools.js.map +1 -0
  14. package/dist/automations/types.d.ts +211 -0
  15. package/dist/automations/types.js +4 -0
  16. package/dist/automations/types.js.map +1 -0
  17. package/dist/business-tools.d.ts +17 -0
  18. package/dist/business-tools.js +3065 -0
  19. package/dist/business-tools.js.map +1 -0
  20. package/dist/collections/__tests__/assertions.test.js +203 -0
  21. package/dist/collections/__tests__/assertions.test.js.map +1 -0
  22. package/dist/collections/__tests__/dynamic-vars.test.d.ts +1 -0
  23. package/dist/collections/__tests__/dynamic-vars.test.js +169 -0
  24. package/dist/collections/__tests__/dynamic-vars.test.js.map +1 -0
  25. package/dist/collections/__tests__/execution.test.d.ts +1 -0
  26. package/dist/collections/__tests__/execution.test.js +271 -0
  27. package/dist/collections/__tests__/execution.test.js.map +1 -0
  28. package/dist/collections/__tests__/integration.test.d.ts +1 -0
  29. package/dist/collections/__tests__/integration.test.js +478 -0
  30. package/dist/collections/__tests__/integration.test.js.map +1 -0
  31. package/dist/collections/__tests__/resolver.test.d.ts +1 -0
  32. package/dist/collections/__tests__/resolver.test.js +282 -0
  33. package/dist/collections/__tests__/resolver.test.js.map +1 -0
  34. package/dist/collections/__tests__/scenario.test.d.ts +1 -0
  35. package/dist/collections/__tests__/scenario.test.js +367 -0
  36. package/dist/collections/__tests__/scenario.test.js.map +1 -0
  37. package/dist/collections/__tests__/scripts.test.d.ts +1 -0
  38. package/dist/collections/__tests__/scripts.test.js +245 -0
  39. package/dist/collections/__tests__/scripts.test.js.map +1 -0
  40. package/dist/collections/__tests__/store.test.d.ts +1 -0
  41. package/dist/collections/__tests__/store.test.js +313 -0
  42. package/dist/collections/__tests__/store.test.js.map +1 -0
  43. package/dist/collections/assertions.d.ts +16 -0
  44. package/dist/collections/assertions.js +107 -0
  45. package/dist/collections/assertions.js.map +1 -0
  46. package/dist/collections/dynamic-vars.d.ts +75 -0
  47. package/dist/collections/dynamic-vars.js +187 -0
  48. package/dist/collections/dynamic-vars.js.map +1 -0
  49. package/dist/collections/executor.d.ts +100 -0
  50. package/dist/collections/executor.js +301 -0
  51. package/dist/collections/executor.js.map +1 -0
  52. package/dist/collections/http.d.ts +9 -0
  53. package/dist/collections/http.js +194 -0
  54. package/dist/collections/http.js.map +1 -0
  55. package/dist/collections/index.d.ts +12 -0
  56. package/dist/collections/index.js +27 -0
  57. package/dist/collections/index.js.map +1 -0
  58. package/dist/collections/postman.d.ts +10 -0
  59. package/dist/collections/postman.js +288 -0
  60. package/dist/collections/postman.js.map +1 -0
  61. package/dist/collections/resolver.d.ts +74 -0
  62. package/dist/collections/resolver.js +330 -0
  63. package/dist/collections/resolver.js.map +1 -0
  64. package/dist/collections/scripts.d.ts +20 -0
  65. package/dist/collections/scripts.js +225 -0
  66. package/dist/collections/scripts.js.map +1 -0
  67. package/dist/collections/store.d.ts +68 -0
  68. package/dist/collections/store.js +507 -0
  69. package/dist/collections/store.js.map +1 -0
  70. package/dist/collections/tools.d.ts +8 -0
  71. package/dist/collections/tools.js +1276 -0
  72. package/dist/collections/tools.js.map +1 -0
  73. package/dist/collections/types.d.ts +226 -0
  74. package/dist/collections/types.js +3 -0
  75. package/dist/collections/types.js.map +1 -0
  76. package/dist/compound-tools.js +136 -2
  77. package/dist/compound-tools.js.map +1 -1
  78. package/dist/killswitch.d.ts +8 -0
  79. package/dist/killswitch.js +304 -0
  80. package/dist/killswitch.js.map +1 -0
  81. package/dist/monitor.d.ts +36 -2
  82. package/dist/monitor.js +213 -38
  83. package/dist/monitor.js.map +1 -1
  84. package/dist/paths.d.ts +16 -0
  85. package/dist/paths.js +39 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/recipe-tools.js +67 -9
  88. package/dist/recipe-tools.js.map +1 -1
  89. package/dist/recipes.d.ts +6 -0
  90. package/dist/recipes.js +83 -8
  91. package/dist/recipes.js.map +1 -1
  92. package/dist/resilience.d.ts +112 -0
  93. package/dist/resilience.js +319 -0
  94. package/dist/resilience.js.map +1 -0
  95. package/dist/scheduler.js +4 -8
  96. package/dist/scheduler.js.map +1 -1
  97. package/dist/server.js +221 -60
  98. package/dist/server.js.map +1 -1
  99. package/dist/session-state.js +2 -2
  100. package/dist/session-state.js.map +1 -1
  101. package/dist/tools.js +54 -0
  102. package/dist/tools.js.map +1 -1
  103. package/package.json +4 -3
  104. package/recipes/autopilot/agent.md +316 -0
  105. package/recipes/autopilot/learnings.md +33 -0
  106. package/recipes/autopilot/recipe.yaml +96 -0
  107. package/recipes/inbox-responder/agent.md +268 -0
  108. package/recipes/inbox-responder/learnings.md +25 -0
  109. package/recipes/inbox-responder/recipe.yaml +82 -0
  110. package/recipes/linkedin-job-scraper/agent.md +144 -0
  111. package/recipes/linkedin-job-scraper/learnings.md +3 -0
  112. package/recipes/linkedin-job-scraper/recipe.yaml +71 -0
  113. package/recipes/linkedin-outreach/learnings.md +17 -1
  114. package/recipes/planner/agent.md +66 -1
  115. package/recipes/planner/learnings.md +49 -1
  116. package/recipes/sheets-data-entry/agent.md +166 -0
  117. package/recipes/sheets-data-entry/learnings.md +3 -0
  118. package/recipes/sheets-data-entry/recipe.yaml +78 -0
  119. package/recipes/social-ops/learnings.md +33 -13
  120. package/recipes/x-job-scraper/agent.md +156 -0
  121. package/recipes/x-job-scraper/learnings.md +3 -0
  122. package/recipes/x-job-scraper/recipe.yaml +72 -0
  123. package/dist/__tests__/correlation.test.js +0 -53
  124. package/dist/__tests__/correlation.test.js.map +0 -1
  125. /package/dist/{__tests__/correlation.test.d.ts → collections/__tests__/assertions.test.d.ts} +0 -0
@@ -0,0 +1,3065 @@
1
+ /**
2
+ * Business intelligence tools — high-level compound tools that turn social
3
+ * media activity into leads, opportunities, and structured data.
4
+ *
5
+ * These chain existing extension primitives (openTab, scrollPage, extractData,
6
+ * etc.) from the bridge server. No new extension primitives needed.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import YAML from "yaml";
11
+ import { NEURON_DIR } from "./paths.js";
12
+ // ── Low-level helpers ───────────────────────────────────────
13
+ async function call(ctx, primitive, args, timeoutMs = 15000) {
14
+ return ctx.pending.call(ctx.ws, primitive, args, timeoutMs);
15
+ }
16
+ function sleep(ms) {
17
+ return new Promise((r) => setTimeout(r, ms));
18
+ }
19
+ // ── Shared helpers ──────────────────────────────────────────
20
+ function ensureDir(dir) {
21
+ if (!fs.existsSync(dir)) {
22
+ fs.mkdirSync(dir, { recursive: true });
23
+ }
24
+ }
25
+ function loadYaml(file) {
26
+ if (!fs.existsSync(file))
27
+ return null;
28
+ try {
29
+ return YAML.parse(fs.readFileSync(file, "utf-8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function saveYaml(file, data) {
36
+ ensureDir(path.dirname(file));
37
+ fs.writeFileSync(file, YAML.stringify(data), "utf-8");
38
+ }
39
+ function detectPlatform(url) {
40
+ const u = url.toLowerCase();
41
+ if (u.includes("instagram.com"))
42
+ return "ig";
43
+ if (u.includes("x.com") || u.includes("twitter.com"))
44
+ return "x";
45
+ if (u.includes("tiktok.com"))
46
+ return "tk";
47
+ if (u.includes("linkedin.com"))
48
+ return "li";
49
+ if (u.includes("facebook.com") || u.includes("fb.com"))
50
+ return "fb";
51
+ if (u.includes("youtube.com") || u.includes("youtu.be"))
52
+ return "yt";
53
+ if (u.includes("threads.net"))
54
+ return "threads";
55
+ return "web";
56
+ }
57
+ function filterByKeywords(items, keywords) {
58
+ if (!keywords.length)
59
+ return items;
60
+ const lower = keywords.map((k) => k.toLowerCase());
61
+ return items.filter((item) => {
62
+ const text = JSON.stringify(item).toLowerCase();
63
+ return lower.some((kw) => text.includes(kw));
64
+ });
65
+ }
66
+ async function researchSingleProfile(ctx, url, scrolls = 2) {
67
+ try {
68
+ const tab = (await call(ctx, "openTab", { url }));
69
+ const tabId = tab?.tabId;
70
+ if (!tabId)
71
+ return { url, platform: detectPlatform(url), data: null, error: "Failed to open tab" };
72
+ await sleep(2500);
73
+ for (let i = 0; i < scrolls; i++) {
74
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
75
+ await sleep(500);
76
+ }
77
+ const data = await call(ctx, "extractData", { tabId });
78
+ try {
79
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
80
+ }
81
+ catch { /* ok */ }
82
+ return { url, platform: detectPlatform(url), data, error: null };
83
+ }
84
+ catch (err) {
85
+ return { url, platform: detectPlatform(url), data: null, error: err.message };
86
+ }
87
+ }
88
+ /** Per-platform selector map for social actions */
89
+ const PLATFORM_SELECTORS = {
90
+ ig: {
91
+ like: ["span[class*='like'] button", "button svg[aria-label*='Like']", "[aria-label='Like']"],
92
+ comment: ["textarea[aria-label*='comment']", "textarea[placeholder*='comment']"],
93
+ follow: ["button:has-text('Follow')", "[aria-label='Follow']"],
94
+ compose: ["[aria-label='New post']", "svg[aria-label='New post']"],
95
+ dm: ["textarea[placeholder*='Message']"],
96
+ },
97
+ x: {
98
+ like: ["button[data-testid='like']", "[aria-label*='Like']"],
99
+ reply: ["div[data-testid='tweetTextarea_0']", "[aria-label*='Post your reply']"],
100
+ follow: ["button[data-testid*='follow']", "[aria-label*='Follow']"],
101
+ compose: ["a[data-testid='SideNav_NewTweet_Button']", "[aria-label='Post']"],
102
+ dm: ["div[data-testid='dmComposerTextInput']"],
103
+ },
104
+ li: {
105
+ like: ["button[aria-label*='Like']", "button.react-button__trigger"],
106
+ comment: ["div.ql-editor[data-placeholder*='comment']", "[aria-label*='comment']"],
107
+ follow: ["button:has-text('Connect')", "button:has-text('Follow')"],
108
+ compose: ["button.share-box-feed-entry__trigger", "[aria-label='Start a post']"],
109
+ dm: ["div.msg-form__contenteditable", "[aria-label*='message']"],
110
+ },
111
+ fb: {
112
+ like: ["div[aria-label='Like']", "[data-testid='UFI2ReactionActionLink']"],
113
+ comment: ["div[contenteditable='true'][aria-label*='comment']"],
114
+ follow: ["[aria-label*='Follow']"],
115
+ compose: ["div[aria-label*='What\\'s on your mind']", "[data-testid='status-attachment-mentions-input']"],
116
+ dm: ["div[contenteditable='true'][aria-label*='message']"],
117
+ },
118
+ tk: {
119
+ like: ["span[data-e2e='like-icon']", "[aria-label*='Like']"],
120
+ comment: ["div[data-e2e='comment-input']", "[contenteditable='true']"],
121
+ follow: ["button[data-e2e='follow-button']"],
122
+ compose: [],
123
+ dm: [],
124
+ },
125
+ };
126
+ // ── Tool definitions ────────────────────────────────────────
127
+ export const BUSINESS_TOOLS = [
128
+ // ── Phase 1: Foundation ─────────────────────────────────
129
+ {
130
+ name: "neuron_profile_scanner",
131
+ description: "Scan a list of social media profile URLs — visit each, scroll to load content, extract structured " +
132
+ "profile data (name, bio, followers, posts). Supports session resume for large batches. Use for " +
133
+ "lead enrichment, competitor research, or building prospect lists from profile URLs.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ urls: {
138
+ type: "array",
139
+ items: { type: "string" },
140
+ description: "Profile URLs to scan (max 20 per call)",
141
+ },
142
+ scrolls: { type: "number", description: "Scroll-downs per profile to load content (default: 2)" },
143
+ session_id: { type: "string", description: "Session ID for resume support on large batches (optional)" },
144
+ delay_ms: { type: "number", description: "Delay between profiles in ms (default: 2000)" },
145
+ },
146
+ required: ["urls"],
147
+ },
148
+ },
149
+ {
150
+ name: "neuron_hashtag_scout",
151
+ description: "Scout a hashtag or topic feed — open the feed URL, scroll to collect posts, extract post data " +
152
+ "and authors. Optionally enrich by visiting each unique author's profile. Use for finding active " +
153
+ "creators in a niche, discovering trending content, or building outreach lists from hashtag feeds.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ url: { type: "string", description: "Hashtag or topic feed URL (e.g. https://instagram.com/explore/tags/startup/)" },
158
+ scrolls: { type: "number", description: "Number of scroll-downs to collect posts (default: 5)" },
159
+ enrich_profiles: { type: "boolean", description: "Visit each unique author's profile for full data (default: false)" },
160
+ max_profiles: { type: "number", description: "Max profiles to enrich (default: 10)" },
161
+ keywords: {
162
+ type: "array",
163
+ items: { type: "string" },
164
+ description: "Filter posts by keywords (optional)",
165
+ },
166
+ },
167
+ required: ["url"],
168
+ },
169
+ },
170
+ {
171
+ name: "neuron_site_xray",
172
+ description: "Full network analysis of any URL. Captures requests, performance metrics, security scan, API " +
173
+ "discovery, and waterfall timing in parallel. Post-processes into a categorized report: first-party " +
174
+ "vs third-party services, tracking pixels, CDN usage, API endpoints found, performance score, and " +
175
+ "security findings. Use for competitive analysis, tech stack discovery, or site auditing.",
176
+ inputSchema: {
177
+ type: "object",
178
+ properties: {
179
+ url: { type: "string", description: "URL to analyze" },
180
+ scrolls: { type: "number", description: "Scroll-downs to trigger lazy-loaded resources (default: 3)" },
181
+ },
182
+ required: ["url"],
183
+ },
184
+ },
185
+ // ── Phase 2: Collection tools ──────────────────────────
186
+ {
187
+ name: "neuron_collection_harvest",
188
+ description: "One-shot processing of a saved collection or feed URL. Deep-scrolls to load all items, extracts " +
189
+ "structured data, filters by keywords, and optionally enriches author profiles. Use for processing " +
190
+ "saved/bookmarked collections, liked-posts feeds, or any paginated content list.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ url: { type: "string", description: "Collection or feed URL to harvest" },
195
+ scrolls: { type: "number", description: "Number of deep scroll-downs (default: 10)" },
196
+ keywords: {
197
+ type: "array",
198
+ items: { type: "string" },
199
+ description: "Filter items by keywords (optional)",
200
+ },
201
+ enrich_profiles: { type: "boolean", description: "Visit each unique author's profile (default: false)" },
202
+ max_profiles: { type: "number", description: "Max profiles to enrich (default: 10)" },
203
+ selector: { type: "string", description: "CSS selector for items (optional — auto-detects)" },
204
+ },
205
+ required: ["url"],
206
+ },
207
+ },
208
+ {
209
+ name: "neuron_collection_watch",
210
+ description: "Watch a collection or feed URL for new items. Creates a persistent monitor that checks at " +
211
+ "intervals and alerts when new content matching your keywords appears. Use for watching competitor " +
212
+ "feeds, saved collections, or topic pages for new content.",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ url: { type: "string", description: "Collection or feed URL to watch" },
217
+ name: { type: "string", description: "Human-readable name for this watch" },
218
+ keywords: {
219
+ type: "array",
220
+ items: { type: "string" },
221
+ description: "Only alert on items matching these keywords (optional)",
222
+ },
223
+ check_interval_minutes: { type: "number", description: "How often to check (default: 60)" },
224
+ selector: { type: "string", description: "CSS selector for items (optional)" },
225
+ },
226
+ required: ["url", "name"],
227
+ },
228
+ },
229
+ // ── Phase 3: Prospecting ───────────────────────────────
230
+ {
231
+ name: "neuron_local_scout",
232
+ description: "Search location or explore feeds for businesses and creators. Navigates to a search/explore URL, " +
233
+ "optionally types a query, scrolls to collect results, and enriches unique author profiles. Filters " +
234
+ "by criteria like minimum followers, has-website, or keyword presence. Use for local business " +
235
+ "discovery or geographic lead generation.",
236
+ inputSchema: {
237
+ type: "object",
238
+ properties: {
239
+ url: { type: "string", description: "Search or explore URL (e.g. https://instagram.com/explore/locations/...)" },
240
+ query: { type: "string", description: "Search query to type (optional — some URLs pre-filter)" },
241
+ scrolls: { type: "number", description: "Scroll-downs to collect results (default: 5)" },
242
+ enrich_profiles: { type: "boolean", description: "Visit each author's profile for full data (default: true)" },
243
+ max_profiles: { type: "number", description: "Max profiles to enrich (default: 15)" },
244
+ min_followers: { type: "number", description: "Filter: minimum follower count (optional)" },
245
+ require_website: { type: "boolean", description: "Filter: only profiles with a website link (default: false)" },
246
+ keywords: {
247
+ type: "array",
248
+ items: { type: "string" },
249
+ description: "Filter by keywords in bio/posts (optional)",
250
+ },
251
+ },
252
+ required: ["url"],
253
+ },
254
+ },
255
+ {
256
+ name: "neuron_demand_scanner",
257
+ description: "Scan feeds for posts matching trigger phrases — people asking for help, recommendations, or " +
258
+ "services. Uses search + keyword matching to find demand signals. Optionally creates a recurring " +
259
+ "monitor for scheduled checks. Use for finding inbound leads, customer pain points, or service requests.",
260
+ inputSchema: {
261
+ type: "object",
262
+ properties: {
263
+ url: { type: "string", description: "Feed or search URL to scan" },
264
+ triggers: {
265
+ type: "array",
266
+ items: { type: "string" },
267
+ description: "Trigger phrases to match (e.g. 'looking for', 'anyone know', 'recommend')",
268
+ },
269
+ scrolls: { type: "number", description: "Scroll-downs to collect posts (default: 5)" },
270
+ schedule: { type: "boolean", description: "Create a recurring monitor for this scan (default: false)" },
271
+ check_interval_minutes: { type: "number", description: "Monitor check interval if scheduled (default: 120)" },
272
+ enrich_profiles: { type: "boolean", description: "Enrich matched post authors' profiles (default: false)" },
273
+ },
274
+ required: ["url", "triggers"],
275
+ },
276
+ },
277
+ {
278
+ name: "neuron_opportunity_radar",
279
+ description: "Aggregator — reads recent alerts from all business monitors (Collection Watches, Demand Scanners, " +
280
+ "Page Watches). Merges, deduplicates, and sorts by recency. Returns a unified opportunity feed. " +
281
+ "Use as a daily digest of leads and opportunities across all your active monitors.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ limit: { type: "number", description: "Max opportunities to return (default: 50)" },
286
+ since_hours: { type: "number", description: "Only show alerts from the last N hours (default: 24)" },
287
+ keywords: {
288
+ type: "array",
289
+ items: { type: "string" },
290
+ description: "Filter opportunities by keywords (optional)",
291
+ },
292
+ },
293
+ },
294
+ },
295
+ // ── Phase 4: Platform & Extraction ─────────────────────
296
+ {
297
+ name: "neuron_quick_action",
298
+ description: "Perform a social media action (like, comment, follow, DM, post) on the current tab or a URL. " +
299
+ "Uses per-platform selector registry. Auto-detects platform from tab URL. Use for quick engagement " +
300
+ "actions without remembering platform-specific selectors.",
301
+ inputSchema: {
302
+ type: "object",
303
+ properties: {
304
+ tabId: { type: "number", description: "Chrome tab ID (optional if url provided)" },
305
+ url: { type: "string", description: "URL to navigate to first (optional if tabId provided)" },
306
+ action: {
307
+ type: "string",
308
+ enum: ["like", "comment", "reply", "follow", "dm", "compose"],
309
+ description: "Social action to perform",
310
+ },
311
+ text: { type: "string", description: "Text content for comment, reply, dm, or compose actions" },
312
+ platform: { type: "string", description: "Override platform detection (ig, x, li, fb, tk)" },
313
+ },
314
+ required: ["action"],
315
+ },
316
+ },
317
+ {
318
+ name: "neuron_pattern_learner",
319
+ description: "Learn extraction patterns from examples. Give it 2-3 example text values from a page, and it " +
320
+ "identifies the common DOM structure, infers a CSS selector, and stores it for reuse. Then extracts " +
321
+ "all matching items. Use when auto-detection misses or you need a custom extraction pattern.",
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {
325
+ tabId: { type: "number", description: "Chrome tab ID" },
326
+ examples: {
327
+ type: "array",
328
+ items: { type: "string" },
329
+ description: "2-3 example text values visible on the page",
330
+ },
331
+ name: { type: "string", description: "Name for this pattern (stored for reuse)" },
332
+ extract: { type: "boolean", description: "Extract all matches immediately (default: true)" },
333
+ },
334
+ required: ["tabId", "examples"],
335
+ },
336
+ },
337
+ // ── Phase 5: Network & Monitoring ──────────────────────
338
+ {
339
+ name: "neuron_traffic_replay",
340
+ description: "Capture, save, and replay network traffic. Three modes: 'capture' saves current traffic as a " +
341
+ "HAR file; 'replay' loads a saved HAR and sets mock rules to simulate it; 'list' shows saved " +
342
+ "replays. Use for offline testing, reproducing bugs, or comparing API responses over time.",
343
+ inputSchema: {
344
+ type: "object",
345
+ properties: {
346
+ mode: {
347
+ type: "string",
348
+ enum: ["capture", "replay", "list"],
349
+ description: "Operation mode",
350
+ },
351
+ name: { type: "string", description: "Name for the capture/replay (required for capture and replay)" },
352
+ tabId: { type: "number", description: "Tab ID to capture traffic from (for capture mode)" },
353
+ platform: { type: "string", description: "Filter by platform (for capture mode)" },
354
+ navigate_url: { type: "string", description: "URL to navigate to after setting up mock rules (for replay mode)" },
355
+ },
356
+ required: ["mode"],
357
+ },
358
+ },
359
+ {
360
+ name: "neuron_api_blueprint",
361
+ description: "Enhanced API discovery — analyzes captured traffic, infers request/response schemas, detects auth " +
362
+ "patterns, and creates a Neuron API collection with all discovered endpoints. Use to reverse-engineer " +
363
+ "an API from observed traffic and create a ready-to-use collection for testing.",
364
+ inputSchema: {
365
+ type: "object",
366
+ properties: {
367
+ collection_name: { type: "string", description: "Name for the API collection to create" },
368
+ url: { type: "string", description: "Optional: navigate to this URL first to capture fresh traffic" },
369
+ tabId: { type: "number", description: "Tab ID to analyze traffic from (optional)" },
370
+ include_auth: { type: "boolean", description: "Include auth headers in collection (default: true)" },
371
+ },
372
+ required: ["collection_name"],
373
+ },
374
+ },
375
+ {
376
+ name: "neuron_page_watch",
377
+ description: "Semantic page diffing monitor. Creates a persistent monitor that uses structured DOM snapshots " +
378
+ "instead of raw text comparison. Produces human-readable diff reports showing exactly what changed. " +
379
+ "Use to track changes on product pages, competitor sites, job boards, or any page with dynamic content.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ url: { type: "string", description: "URL to watch for changes" },
384
+ name: { type: "string", description: "Human-readable name for this watch" },
385
+ selector: { type: "string", description: "CSS selector to scope the watch (optional — watches full page)" },
386
+ check_interval_minutes: { type: "number", description: "How often to check (default: 60)" },
387
+ keywords: {
388
+ type: "array",
389
+ items: { type: "string" },
390
+ description: "Only alert on changes containing these keywords (optional)",
391
+ },
392
+ },
393
+ required: ["url", "name"],
394
+ },
395
+ },
396
+ // ── Wave 2: Revenue & Lead Management ─────────────────────
397
+ {
398
+ name: "neuron_revenue_tracker",
399
+ description: "CRM-lite funnel tracker. Add leads with source attribution (which tool found them), move them " +
400
+ "through stages (discovered → contacted → replied → interested → negotiating → closed-won/lost), " +
401
+ "tag revenue, and see conversion rates per source. Persists to ~/.neuron/pipeline.yaml.",
402
+ inputSchema: {
403
+ type: "object",
404
+ properties: {
405
+ action: {
406
+ type: "string",
407
+ enum: ["add_lead", "update_stage", "tag_revenue", "list", "stats"],
408
+ description: "Pipeline action",
409
+ },
410
+ lead: {
411
+ type: "object",
412
+ description: "Lead data (for add_lead): { url, name, handle, platform, source, source_tool }",
413
+ },
414
+ lead_id: { type: "string", description: "Lead ID (for update_stage, tag_revenue)" },
415
+ stage: {
416
+ type: "string",
417
+ enum: ["discovered", "contacted", "replied", "interested", "negotiating", "closed_won", "closed_lost"],
418
+ description: "New stage (for update_stage)",
419
+ },
420
+ revenue: { type: "number", description: "Revenue amount (for tag_revenue)" },
421
+ filters: {
422
+ type: "object",
423
+ description: "Filters for list: { stage?, source?, platform?, since_days? }",
424
+ },
425
+ },
426
+ required: ["action"],
427
+ },
428
+ },
429
+ {
430
+ name: "neuron_lead_scorer",
431
+ description: "Score a list of leads based on configurable criteria: follower count, bio keywords (founder, CEO, " +
432
+ "business owner), engagement rate, posting recency, website presence, location match. Returns leads " +
433
+ "sorted by score with tier (hot/warm/cold) and recommended action per tier.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ leads: {
438
+ type: "array",
439
+ items: { type: "object" },
440
+ description: "Lead objects with profile data (from profile_scanner, collection_harvest, etc.)",
441
+ },
442
+ rubric: {
443
+ type: "object",
444
+ description: "Scoring weights: { follower_min?, follower_max?, bio_keywords?: string[], " +
445
+ "require_website?: boolean, location_keywords?: string[], max_days_since_post?: number }",
446
+ },
447
+ hot_threshold: { type: "number", description: "Score above this = hot lead (default: 80)" },
448
+ warm_threshold: { type: "number", description: "Score above this = warm lead (default: 50)" },
449
+ },
450
+ required: ["leads"],
451
+ },
452
+ },
453
+ {
454
+ name: "neuron_social_proof",
455
+ description: "Monitor and collect social proof — positive mentions, testimonials, and user-generated content " +
456
+ "about your brand. Scans posts, comments, and tags for positive sentiment. Captures the quote, " +
457
+ "poster profile, and post link into a testimonial bank at ~/.neuron/testimonials.yaml.",
458
+ inputSchema: {
459
+ type: "object",
460
+ properties: {
461
+ action: {
462
+ type: "string",
463
+ enum: ["scan", "list", "export"],
464
+ description: "Action: scan a URL for testimonials, list saved ones, or export",
465
+ },
466
+ url: { type: "string", description: "URL to scan for social proof (for scan action)" },
467
+ brand_keywords: {
468
+ type: "array",
469
+ items: { type: "string" },
470
+ description: "Brand/product names to look for",
471
+ },
472
+ positive_signals: {
473
+ type: "array",
474
+ items: { type: "string" },
475
+ description: "Positive phrases (default: 'changed my', 'love this', 'best', 'recommend', 'game changer')",
476
+ },
477
+ scrolls: { type: "number", description: "Scroll-downs to collect content (default: 5)" },
478
+ },
479
+ required: ["action"],
480
+ },
481
+ },
482
+ // ── Wave 2: Outreach & Engagement ────────────────────────
483
+ {
484
+ name: "neuron_dm_outreach",
485
+ description: "Paced DM outreach from a lead list. Composes personalized messages from a template with merge " +
486
+ "fields ({name}, {handle}, {context}), sends one at a time with configurable delays to avoid " +
487
+ "platform limits, and tracks sent/replied status. Requires explicit start confirmation.",
488
+ inputSchema: {
489
+ type: "object",
490
+ properties: {
491
+ action: {
492
+ type: "string",
493
+ enum: ["preview", "start", "status", "pause"],
494
+ description: "Outreach action",
495
+ },
496
+ campaign_id: { type: "string", description: "Campaign ID (for status/pause)" },
497
+ leads: {
498
+ type: "array",
499
+ items: { type: "object" },
500
+ description: "Lead list with profile URLs and merge data (for start)",
501
+ },
502
+ template: { type: "string", description: "Message template with {name}, {handle}, {context} merge fields" },
503
+ delay_minutes: { type: "number", description: "Minutes between messages (default: 5)" },
504
+ max_per_day: { type: "number", description: "Max messages per day (default: 20)" },
505
+ platform: { type: "string", description: "Target platform (ig, x, li)" },
506
+ },
507
+ required: ["action"],
508
+ },
509
+ },
510
+ {
511
+ name: "neuron_post_to_lead",
512
+ description: "Monitor a post's comments for a trigger keyword (e.g. 'GUIDE', 'INTERESTED'). Extract each " +
513
+ "matching commenter's profile, push as a lead. Optionally auto-queue a DM with a resource link. " +
514
+ "Use to turn engagement CTAs into a lead funnel automatically.",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: {
518
+ post_url: { type: "string", description: "URL of the post to monitor" },
519
+ trigger: { type: "string", description: "Keyword to match in comments (e.g. 'GUIDE')" },
520
+ enrich_profiles: { type: "boolean", description: "Visit each commenter's profile (default: true)" },
521
+ dm_template: { type: "string", description: "Optional DM template to queue for matched commenters" },
522
+ scrolls: { type: "number", description: "Scroll-downs on comments section (default: 5)" },
523
+ schedule: { type: "boolean", description: "Create a recurring monitor (default: false)" },
524
+ check_interval_minutes: { type: "number", description: "Monitor interval if scheduled (default: 30)" },
525
+ },
526
+ required: ["post_url", "trigger"],
527
+ },
528
+ },
529
+ {
530
+ name: "neuron_comment_miner",
531
+ description: "Extract every commenter from a post (yours or anyone's) with their profile data. Filter by " +
532
+ "keyword in their comment text. Returns a structured lead list. Use to turn a viral post's " +
533
+ "comment section into qualified leads.",
534
+ inputSchema: {
535
+ type: "object",
536
+ properties: {
537
+ post_url: { type: "string", description: "URL of the post to mine comments from" },
538
+ keywords: {
539
+ type: "array",
540
+ items: { type: "string" },
541
+ description: "Filter commenters by keywords in their comment (optional)",
542
+ },
543
+ enrich_profiles: { type: "boolean", description: "Visit each commenter's profile (default: true)" },
544
+ max_profiles: { type: "number", description: "Max profiles to enrich (default: 20)" },
545
+ scrolls: { type: "number", description: "Scroll-downs on comments (default: 5)" },
546
+ },
547
+ required: ["post_url"],
548
+ },
549
+ },
550
+ // ── Wave 2: Competitive Intelligence ─────────────────────
551
+ {
552
+ name: "neuron_competitor_mapper",
553
+ description: "Map a competitor's engaged audience. Visit their profile, scan recent posts' comments, rank " +
554
+ "commenters by frequency, and enrich top commenters' profiles. Categorizes each as potential " +
555
+ "customer, fellow business, influencer, or casual follower.",
556
+ inputSchema: {
557
+ type: "object",
558
+ properties: {
559
+ profile_url: { type: "string", description: "Competitor's profile URL" },
560
+ posts_to_scan: { type: "number", description: "Number of recent posts to scan comments from (default: 5)" },
561
+ max_commenters: { type: "number", description: "Max commenters to enrich (default: 20)" },
562
+ scrolls_per_post: { type: "number", description: "Scroll-downs per post's comments (default: 3)" },
563
+ your_handle: { type: "string", description: "Your handle — to detect overlap (people who engage with both)" },
564
+ },
565
+ required: ["profile_url"],
566
+ },
567
+ },
568
+ {
569
+ name: "neuron_influencer_vetter",
570
+ description: "Vet an influencer profile for authenticity. Calculates engagement rate, analyzes comment quality " +
571
+ "(generic vs substantive), checks posting consistency, and flags red flags like purchased followers. " +
572
+ "Returns a trust score (0-100) with breakdown.",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ profile_url: { type: "string", description: "Influencer's profile URL" },
577
+ posts_to_analyze: { type: "number", description: "Number of recent posts to analyze (default: 10)" },
578
+ scrolls: { type: "number", description: "Scroll-downs on profile (default: 3)" },
579
+ },
580
+ required: ["profile_url"],
581
+ },
582
+ },
583
+ {
584
+ name: "neuron_engagement_analyzer",
585
+ description: "Analyze your own posting performance. Scans your last N posts, calculates best posting times, " +
586
+ "top content types, effective hashtags, engagement trends, and returns specific recommendations. " +
587
+ "Like Sprout Social analytics but free.",
588
+ inputSchema: {
589
+ type: "object",
590
+ properties: {
591
+ profile_url: { type: "string", description: "Your profile URL" },
592
+ posts_to_analyze: { type: "number", description: "Number of recent posts to analyze (default: 20)" },
593
+ scrolls: { type: "number", description: "Scroll-downs on profile (default: 5)" },
594
+ },
595
+ required: ["profile_url"],
596
+ },
597
+ },
598
+ // ── Wave 2: Brand & Content ──────────────────────────────
599
+ {
600
+ name: "neuron_mention_tracker",
601
+ description: "Track who mentions your brand, handle, or keywords across platforms. Scan a search/hashtag URL, " +
602
+ "extract posts mentioning your brand, capture poster profile and sentiment. Optionally schedule " +
603
+ "recurring checks. Persists mentions to ~/.neuron/mentions.yaml.",
604
+ inputSchema: {
605
+ type: "object",
606
+ properties: {
607
+ url: { type: "string", description: "Search or feed URL to scan for mentions" },
608
+ brand_terms: {
609
+ type: "array",
610
+ items: { type: "string" },
611
+ description: "Brand names, handles, or keywords to track",
612
+ },
613
+ scrolls: { type: "number", description: "Scroll-downs to collect posts (default: 5)" },
614
+ enrich_profiles: { type: "boolean", description: "Enrich mention authors' profiles (default: false)" },
615
+ schedule: { type: "boolean", description: "Create recurring monitor (default: false)" },
616
+ check_interval_minutes: { type: "number", description: "Monitor interval if scheduled (default: 60)" },
617
+ },
618
+ required: ["url", "brand_terms"],
619
+ },
620
+ },
621
+ {
622
+ name: "neuron_post_analyzer",
623
+ description: "Analyze a social post or batch of posts. Extract text, media type, engagement metrics, hashtags, " +
624
+ "mentions, posting time, and content classification (educational, promotional, storytelling). " +
625
+ "Batch mode: analyze a profile's feed and return a spreadsheet-ready breakdown.",
626
+ inputSchema: {
627
+ type: "object",
628
+ properties: {
629
+ url: { type: "string", description: "Post URL or profile URL (for batch mode)" },
630
+ batch: { type: "boolean", description: "Analyze multiple posts from a profile feed (default: false)" },
631
+ posts_count: { type: "number", description: "Number of posts for batch mode (default: 20)" },
632
+ scrolls: { type: "number", description: "Scroll-downs to load posts (default: 3)" },
633
+ },
634
+ required: ["url"],
635
+ },
636
+ },
637
+ {
638
+ name: "neuron_content_pipeline",
639
+ description: "Extract content from a source URL (article, thread, post) and restructure it for a target " +
640
+ "platform format. Returns platform-optimized content with hooks and formatting. Use for content " +
641
+ "repurposing across platforms.",
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ source_url: { type: "string", description: "Source content URL (article, post, thread)" },
646
+ target_platform: {
647
+ type: "string",
648
+ enum: ["linkedin", "x", "instagram", "tiktok", "newsletter", "blog"],
649
+ description: "Target platform format",
650
+ },
651
+ scrolls: { type: "number", description: "Scroll-downs on source page (default: 5)" },
652
+ tone: { type: "string", description: "Tone override: professional, casual, educational (optional)" },
653
+ },
654
+ required: ["source_url", "target_platform"],
655
+ },
656
+ },
657
+ // ── Wave 2: Data Collection ──────────────────────────────
658
+ {
659
+ name: "neuron_group_link_scanner",
660
+ description: "Scan posts, comments, bios for WhatsApp and Telegram group invite links (wa.me, chat.whatsapp.com, " +
661
+ "t.me). Extract the link, context, and poster profile. Push to ~/.neuron/group-links.yaml. " +
662
+ "Optionally validate if links are still active.",
663
+ inputSchema: {
664
+ type: "object",
665
+ properties: {
666
+ url: { type: "string", description: "URL to scan (feed, post, profile)" },
667
+ scrolls: { type: "number", description: "Scroll-downs to collect content (default: 5)" },
668
+ validate_links: { type: "boolean", description: "Check if group links are still active (default: false)" },
669
+ enrich_profiles: { type: "boolean", description: "Enrich link poster profiles (default: false)" },
670
+ },
671
+ required: ["url"],
672
+ },
673
+ },
674
+ {
675
+ name: "neuron_bio_harvester",
676
+ description: "Scan a list of profile URLs and extract every link from their bios — websites, Linktree, WhatsApp, " +
677
+ "Calendly, email, phone. Follows link-in-bio pages (Linktree, Beacons) to extract destination URLs. " +
678
+ "Categorizes each link type.",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ urls: {
683
+ type: "array",
684
+ items: { type: "string" },
685
+ description: "Profile URLs to harvest bio links from (max 20)",
686
+ },
687
+ follow_linktree: { type: "boolean", description: "Follow and expand link-in-bio pages (default: true)" },
688
+ delay_ms: { type: "number", description: "Delay between profiles in ms (default: 2000)" },
689
+ },
690
+ required: ["urls"],
691
+ },
692
+ },
693
+ {
694
+ name: "neuron_cross_platform_finder",
695
+ description: "Find a person across platforms. Given a handle on one platform, search for matching accounts on " +
696
+ "others by handle, name, and bio similarity. Returns a unified profile card with confidence scores.",
697
+ inputSchema: {
698
+ type: "object",
699
+ properties: {
700
+ handle: { type: "string", description: "Handle to search for (e.g. 'rasheedalabi')" },
701
+ source_platform: { type: "string", description: "Platform the handle is from (ig, x, li, tk, fb)" },
702
+ search_platforms: {
703
+ type: "array",
704
+ items: { type: "string" },
705
+ description: "Platforms to search on (default: all except source)",
706
+ },
707
+ },
708
+ required: ["handle", "source_platform"],
709
+ },
710
+ },
711
+ {
712
+ name: "neuron_page_weight",
713
+ description: "Break down total page weight by category (JS, CSS, images, fonts, video, third-party). Flag " +
714
+ "oversized assets with specific recommendations. Compare against performance budgets. Returns a " +
715
+ "prioritized action list for optimization.",
716
+ inputSchema: {
717
+ type: "object",
718
+ properties: {
719
+ url: { type: "string", description: "URL to profile" },
720
+ budget: {
721
+ type: "object",
722
+ description: "Performance budget: { max_js_kb?, max_css_kb?, max_images_kb?, max_total_kb?, max_single_asset_kb? }",
723
+ },
724
+ },
725
+ required: ["url"],
726
+ },
727
+ },
728
+ ];
729
+ // ── Handler dispatcher ──────────────────────────────────────
730
+ export async function handleBusinessTool(name, args, ctx) {
731
+ switch (name) {
732
+ case "neuron_profile_scanner":
733
+ return profileScanner(args, ctx);
734
+ case "neuron_hashtag_scout":
735
+ return hashtagScout(args, ctx);
736
+ case "neuron_site_xray":
737
+ return siteXray(args, ctx);
738
+ case "neuron_collection_harvest":
739
+ return collectionHarvest(args, ctx);
740
+ case "neuron_collection_watch":
741
+ return collectionWatch(args);
742
+ case "neuron_local_scout":
743
+ return localScout(args, ctx);
744
+ case "neuron_demand_scanner":
745
+ return demandScanner(args, ctx);
746
+ case "neuron_opportunity_radar":
747
+ return opportunityRadar(args);
748
+ case "neuron_quick_action":
749
+ return quickAction(args, ctx);
750
+ case "neuron_pattern_learner":
751
+ return patternLearner(args, ctx);
752
+ case "neuron_traffic_replay":
753
+ return trafficReplay(args, ctx);
754
+ case "neuron_api_blueprint":
755
+ return apiBlueprint(args, ctx);
756
+ case "neuron_page_watch":
757
+ return pageWatch(args);
758
+ // Wave 2
759
+ case "neuron_revenue_tracker":
760
+ return revenueTracker(args);
761
+ case "neuron_lead_scorer":
762
+ return leadScorer(args);
763
+ case "neuron_social_proof":
764
+ return socialProof(args, ctx);
765
+ case "neuron_dm_outreach":
766
+ return dmOutreach(args, ctx);
767
+ case "neuron_post_to_lead":
768
+ return postToLead(args, ctx);
769
+ case "neuron_comment_miner":
770
+ return commentMiner(args, ctx);
771
+ case "neuron_competitor_mapper":
772
+ return competitorMapper(args, ctx);
773
+ case "neuron_influencer_vetter":
774
+ return influencerVetter(args, ctx);
775
+ case "neuron_engagement_analyzer":
776
+ return engagementAnalyzer(args, ctx);
777
+ case "neuron_mention_tracker":
778
+ return mentionTracker(args, ctx);
779
+ case "neuron_post_analyzer":
780
+ return postAnalyzer(args, ctx);
781
+ case "neuron_content_pipeline":
782
+ return contentPipeline(args, ctx);
783
+ case "neuron_group_link_scanner":
784
+ return groupLinkScanner(args, ctx);
785
+ case "neuron_bio_harvester":
786
+ return bioHarvester(args, ctx);
787
+ case "neuron_cross_platform_finder":
788
+ return crossPlatformFinder(args, ctx);
789
+ case "neuron_page_weight":
790
+ return pageWeight(args, ctx);
791
+ default:
792
+ throw new Error(`Unknown business tool: ${name}`);
793
+ }
794
+ }
795
+ // ── Implementations ─────────────────────────────────────────
796
+ // ── Phase 1 ─────────────────────────────────────────────────
797
+ async function profileScanner(args, ctx) {
798
+ const urls = args.urls.slice(0, 20);
799
+ const scrolls = args.scrolls ?? 2;
800
+ const sessionId = args.session_id;
801
+ const delayMs = args.delay_ms ?? 2000;
802
+ // Session resume support
803
+ let startIndex = 0;
804
+ const completed = [];
805
+ if (sessionId) {
806
+ const sessionFile = path.join(NEURON_DIR, "sessions", `${sessionId}.yaml`);
807
+ const existing = loadYaml(sessionFile);
808
+ if (existing?.cursor != null) {
809
+ startIndex = existing.cursor;
810
+ if (existing.completed)
811
+ completed.push(...existing.completed);
812
+ }
813
+ }
814
+ const results = [];
815
+ for (let i = startIndex; i < urls.length; i++) {
816
+ const result = await researchSingleProfile(ctx, urls[i], scrolls);
817
+ results.push(result);
818
+ completed.push(result);
819
+ // Save progress if session tracking
820
+ if (sessionId) {
821
+ const sessionFile = path.join(NEURON_DIR, "sessions", `${sessionId}.yaml`);
822
+ saveYaml(sessionFile, {
823
+ session_id: sessionId,
824
+ cursor: i + 1,
825
+ total: urls.length,
826
+ completed,
827
+ updated_at: new Date().toISOString(),
828
+ });
829
+ }
830
+ if (i < urls.length - 1 && delayMs > 0)
831
+ await sleep(delayMs);
832
+ }
833
+ return {
834
+ total: urls.length,
835
+ scanned: results.length,
836
+ successful: results.filter((r) => r.data && !r.error).length,
837
+ failed: results.filter((r) => r.error).length,
838
+ session_id: sessionId ?? null,
839
+ profiles: results,
840
+ };
841
+ }
842
+ async function hashtagScout(args, ctx) {
843
+ const url = args.url;
844
+ const scrollCount = args.scrolls ?? 5;
845
+ const enrichProfiles = args.enrich_profiles ?? false;
846
+ const maxProfiles = args.max_profiles ?? 10;
847
+ const keywords = args.keywords ?? [];
848
+ // Open the feed
849
+ const tab = (await call(ctx, "openTab", { url }));
850
+ const tabId = tab?.tabId;
851
+ if (!tabId)
852
+ throw new Error("Failed to open tab");
853
+ await sleep(3000);
854
+ // Scroll to collect posts
855
+ for (let i = 0; i < scrollCount; i++) {
856
+ await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
857
+ await sleep(700);
858
+ }
859
+ // Extract posts
860
+ const rawData = (await call(ctx, "extractData", { tabId }));
861
+ let items = Array.isArray(rawData)
862
+ ? rawData
863
+ : rawData?.items ?? [rawData];
864
+ // Filter by keywords
865
+ if (keywords.length > 0) {
866
+ items = filterByKeywords(items, keywords);
867
+ }
868
+ // Extract unique author URLs from items
869
+ const authorUrls = new Set();
870
+ for (const item of items) {
871
+ const obj = item;
872
+ // Look for profile links in the extracted data
873
+ const links = (obj.links ?? obj.url ?? obj.href ?? obj.author_url ?? "");
874
+ const linkList = Array.isArray(links) ? links : [links];
875
+ for (const link of linkList) {
876
+ if (typeof link === "string" && link.includes("/") && !link.includes("/p/") && !link.includes("/reel/")) {
877
+ authorUrls.add(link);
878
+ }
879
+ }
880
+ }
881
+ // Enrich profiles if requested
882
+ let enriched = [];
883
+ if (enrichProfiles && authorUrls.size > 0) {
884
+ const toEnrich = [...authorUrls].slice(0, maxProfiles);
885
+ for (const profileUrl of toEnrich) {
886
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
887
+ await sleep(1500);
888
+ }
889
+ }
890
+ // Close the feed tab
891
+ try {
892
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
893
+ }
894
+ catch { /* ok */ }
895
+ return {
896
+ url,
897
+ platform: detectPlatform(url),
898
+ total_posts: items.length,
899
+ keywords_applied: keywords,
900
+ posts: items.slice(0, 50),
901
+ unique_authors: authorUrls.size,
902
+ enriched_profiles: enriched.length > 0 ? enriched : undefined,
903
+ };
904
+ }
905
+ async function siteXray(args, ctx) {
906
+ const url = args.url;
907
+ const scrollCount = args.scrolls ?? 3;
908
+ // Open the page
909
+ const tab = (await call(ctx, "openTab", { url }));
910
+ const tabId = tab?.tabId;
911
+ if (!tabId)
912
+ throw new Error("Failed to open tab");
913
+ await sleep(3000);
914
+ // Scroll to trigger lazy resources
915
+ for (let i = 0; i < scrollCount; i++) {
916
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
917
+ await sleep(500);
918
+ }
919
+ // Run all analyses in parallel
920
+ const [requests, waterfallData, perf, security, apis] = await Promise.allSettled([
921
+ call(ctx, "getRecentRequests", { tabId, limit: 200 }),
922
+ call(ctx, "waterfall", { tabId }),
923
+ call(ctx, "perfSnapshot", { tabId }),
924
+ call(ctx, "securityScan", { tabId }),
925
+ call(ctx, "discoverApis", {}),
926
+ ]);
927
+ // Categorize requests
928
+ const requestList = requests.status === "fulfilled"
929
+ ? (requests.value?.requests ?? [])
930
+ : [];
931
+ const hostname = new URL(url).hostname;
932
+ const categories = {
933
+ first_party: [],
934
+ third_party: [],
935
+ tracking: [],
936
+ cdn: [],
937
+ api: [],
938
+ };
939
+ const trackingDomains = [
940
+ "google-analytics", "googletagmanager", "facebook.com/tr", "pixel",
941
+ "analytics", "hotjar", "mixpanel", "segment", "amplitude",
942
+ "doubleclick", "adsense", "adservice", "tracking",
943
+ ];
944
+ const cdnDomains = [
945
+ "cloudfront", "cdn", "akamai", "fastly", "cloudflare", "jsdelivr",
946
+ "unpkg", "cdnjs",
947
+ ];
948
+ for (const req of requestList) {
949
+ const r = req;
950
+ const reqUrl = String(r.url ?? "");
951
+ try {
952
+ const reqHost = new URL(reqUrl).hostname;
953
+ const lower = reqUrl.toLowerCase();
954
+ if (trackingDomains.some((d) => lower.includes(d))) {
955
+ categories.tracking.push({ url: reqUrl, host: reqHost });
956
+ }
957
+ else if (cdnDomains.some((d) => lower.includes(d))) {
958
+ categories.cdn.push({ url: reqUrl, host: reqHost });
959
+ }
960
+ else if (lower.includes("/api/") || lower.includes("/graphql") || lower.includes("/v1/") || lower.includes("/v2/")) {
961
+ categories.api.push({ url: reqUrl, host: reqHost });
962
+ }
963
+ else if (reqHost === hostname || reqHost.endsWith(`.${hostname}`)) {
964
+ categories.first_party.push({ url: reqUrl, host: reqHost });
965
+ }
966
+ else {
967
+ categories.third_party.push({ url: reqUrl, host: reqHost });
968
+ }
969
+ }
970
+ catch {
971
+ // skip malformed URLs
972
+ }
973
+ }
974
+ // Close tab
975
+ try {
976
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
977
+ }
978
+ catch { /* ok */ }
979
+ return {
980
+ url,
981
+ total_requests: requestList.length,
982
+ categories: {
983
+ first_party: categories.first_party.length,
984
+ third_party: categories.third_party.length,
985
+ tracking: categories.tracking.length,
986
+ cdn: categories.cdn.length,
987
+ api: categories.api.length,
988
+ },
989
+ tracking_services: [...new Set(categories.tracking.map((t) => t.host))],
990
+ third_party_services: [...new Set(categories.third_party.map((t) => t.host))].slice(0, 20),
991
+ api_endpoints: categories.api.slice(0, 20),
992
+ waterfall: waterfallData.status === "fulfilled" ? waterfallData.value : { error: "unavailable" },
993
+ performance: perf.status === "fulfilled" ? perf.value : { error: "unavailable" },
994
+ security: security.status === "fulfilled" ? security.value : { error: "unavailable" },
995
+ discovered_apis: apis.status === "fulfilled" ? apis.value : { error: "unavailable" },
996
+ };
997
+ }
998
+ // ── Phase 2 ─────────────────────────────────────────────────
999
+ async function collectionHarvest(args, ctx) {
1000
+ const url = args.url;
1001
+ const scrollCount = args.scrolls ?? 10;
1002
+ const keywords = args.keywords ?? [];
1003
+ const enrichProfiles = args.enrich_profiles ?? false;
1004
+ const maxProfiles = args.max_profiles ?? 10;
1005
+ const selector = args.selector;
1006
+ // Open the collection
1007
+ const tab = (await call(ctx, "openTab", { url }));
1008
+ const tabId = tab?.tabId;
1009
+ if (!tabId)
1010
+ throw new Error("Failed to open tab");
1011
+ await sleep(3000);
1012
+ // Deep scroll to load all items
1013
+ for (let i = 0; i < scrollCount; i++) {
1014
+ await call(ctx, "scrollPage", { tabId, deltaY: 1000, smooth: true });
1015
+ await sleep(800);
1016
+ }
1017
+ // Extract all items
1018
+ const extractArgs = { tabId };
1019
+ if (selector)
1020
+ extractArgs.selector = selector;
1021
+ const rawData = (await call(ctx, "extractData", extractArgs));
1022
+ let items = Array.isArray(rawData)
1023
+ ? rawData
1024
+ : rawData?.items ?? [rawData];
1025
+ // Filter by keywords
1026
+ const preFilterCount = items.length;
1027
+ if (keywords.length > 0) {
1028
+ items = filterByKeywords(items, keywords);
1029
+ }
1030
+ // Extract unique author URLs
1031
+ const authorUrls = new Set();
1032
+ for (const item of items) {
1033
+ const obj = item;
1034
+ const links = (obj.links ?? obj.url ?? obj.href ?? obj.author_url ?? "");
1035
+ const linkList = Array.isArray(links) ? links : [links];
1036
+ for (const link of linkList) {
1037
+ if (typeof link === "string" && link.startsWith("http")) {
1038
+ authorUrls.add(link);
1039
+ }
1040
+ }
1041
+ }
1042
+ // Enrich profiles
1043
+ let enriched = [];
1044
+ if (enrichProfiles && authorUrls.size > 0) {
1045
+ const toEnrich = [...authorUrls].slice(0, maxProfiles);
1046
+ for (const profileUrl of toEnrich) {
1047
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
1048
+ await sleep(1500);
1049
+ }
1050
+ }
1051
+ // Close tab
1052
+ try {
1053
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
1054
+ }
1055
+ catch { /* ok */ }
1056
+ return {
1057
+ url,
1058
+ total_extracted: preFilterCount,
1059
+ after_filter: items.length,
1060
+ keywords_applied: keywords,
1061
+ items: items.slice(0, 100),
1062
+ unique_authors: authorUrls.size,
1063
+ enriched_profiles: enriched.length > 0 ? enriched : undefined,
1064
+ };
1065
+ }
1066
+ async function collectionWatch(args) {
1067
+ const url = args.url;
1068
+ const name = args.name;
1069
+ const keywords = args.keywords ?? [];
1070
+ const intervalMin = args.check_interval_minutes ?? 60;
1071
+ const selector = args.selector;
1072
+ // Load existing monitors
1073
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
1074
+ const store = loadYaml(monitorsFile) ?? { monitors: [] };
1075
+ const monitorId = `cw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1076
+ const monitor = {
1077
+ id: monitorId,
1078
+ url,
1079
+ name,
1080
+ check_interval_minutes: intervalMin,
1081
+ condition: {
1082
+ type: "new_items",
1083
+ selector: selector ?? undefined,
1084
+ metadata: { keywords, source: "collection_watch" },
1085
+ },
1086
+ enabled: true,
1087
+ created_at: new Date().toISOString(),
1088
+ alert_count: 0,
1089
+ };
1090
+ store.monitors.push(monitor);
1091
+ saveYaml(monitorsFile, store);
1092
+ return {
1093
+ status: "created",
1094
+ monitor_id: monitorId,
1095
+ name,
1096
+ url,
1097
+ check_interval_minutes: intervalMin,
1098
+ keywords,
1099
+ message: `Collection watch "${name}" created. It will check every ${intervalMin} minutes for new items.`,
1100
+ };
1101
+ }
1102
+ // ── Phase 3 ─────────────────────────────────────────────────
1103
+ async function localScout(args, ctx) {
1104
+ const url = args.url;
1105
+ const query = args.query;
1106
+ const scrollCount = args.scrolls ?? 5;
1107
+ const enrichProfiles = args.enrich_profiles ?? true;
1108
+ const maxProfiles = args.max_profiles ?? 15;
1109
+ const minFollowers = args.min_followers;
1110
+ const requireWebsite = args.require_website ?? false;
1111
+ const keywords = args.keywords ?? [];
1112
+ // Open the page
1113
+ const tab = (await call(ctx, "openTab", { url }));
1114
+ const tabId = tab?.tabId;
1115
+ if (!tabId)
1116
+ throw new Error("Failed to open tab");
1117
+ await sleep(3000);
1118
+ // Type query if provided
1119
+ if (query) {
1120
+ const searchSelectors = [
1121
+ "input[type='search']", "input[name='q']", "input[name='keywords']",
1122
+ "input[placeholder*='earch']", "input[aria-label*='earch']",
1123
+ ];
1124
+ try {
1125
+ await call(ctx, "typeText", { tabId, selectors: searchSelectors, value: query });
1126
+ await sleep(500);
1127
+ await call(ctx, "pressKey", { tabId, key: "Enter" });
1128
+ await sleep(2500);
1129
+ }
1130
+ catch {
1131
+ // search box might not exist on explore pages
1132
+ }
1133
+ }
1134
+ // Scroll to collect results
1135
+ for (let i = 0; i < scrollCount; i++) {
1136
+ await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
1137
+ await sleep(700);
1138
+ }
1139
+ // Extract results
1140
+ const rawData = (await call(ctx, "extractData", { tabId }));
1141
+ let items = Array.isArray(rawData)
1142
+ ? rawData
1143
+ : rawData?.items ?? [rawData];
1144
+ // Filter by keywords
1145
+ if (keywords.length > 0) {
1146
+ items = filterByKeywords(items, keywords);
1147
+ }
1148
+ // Extract unique profile URLs
1149
+ const authorUrls = new Set();
1150
+ for (const item of items) {
1151
+ const obj = item;
1152
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
1153
+ const linkList = Array.isArray(links) ? links : [links];
1154
+ for (const link of linkList) {
1155
+ if (typeof link === "string" && link.startsWith("http")) {
1156
+ authorUrls.add(link);
1157
+ }
1158
+ }
1159
+ }
1160
+ // Enrich profiles
1161
+ let enriched = [];
1162
+ if (enrichProfiles && authorUrls.size > 0) {
1163
+ const toEnrich = [...authorUrls].slice(0, maxProfiles);
1164
+ for (const profileUrl of toEnrich) {
1165
+ const profile = await researchSingleProfile(ctx, profileUrl, 2);
1166
+ enriched.push(profile);
1167
+ await sleep(1500);
1168
+ }
1169
+ }
1170
+ // Apply profile-level filters
1171
+ if (enriched.length > 0 && (minFollowers != null || requireWebsite)) {
1172
+ enriched = enriched.filter((p) => {
1173
+ if (!p.data)
1174
+ return false;
1175
+ const text = JSON.stringify(p.data).toLowerCase();
1176
+ if (minFollowers != null) {
1177
+ // Try to find follower count in data
1178
+ const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
1179
+ if (followerMatch) {
1180
+ const count = parseInt(followerMatch[1].replace(/,/g, ""), 10);
1181
+ if (count < minFollowers)
1182
+ return false;
1183
+ }
1184
+ }
1185
+ if (requireWebsite) {
1186
+ if (!text.includes("http") && !text.includes("www.") && !text.includes(".com"))
1187
+ return false;
1188
+ }
1189
+ return true;
1190
+ });
1191
+ }
1192
+ // Close tab
1193
+ try {
1194
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
1195
+ }
1196
+ catch { /* ok */ }
1197
+ return {
1198
+ url,
1199
+ query: query ?? null,
1200
+ total_results: items.length,
1201
+ profiles_enriched: enriched.length,
1202
+ filters_applied: {
1203
+ keywords: keywords.length > 0 ? keywords : null,
1204
+ min_followers: minFollowers ?? null,
1205
+ require_website: requireWebsite,
1206
+ },
1207
+ results: items.slice(0, 30),
1208
+ profiles: enriched,
1209
+ };
1210
+ }
1211
+ async function demandScanner(args, ctx) {
1212
+ const url = args.url;
1213
+ const triggers = args.triggers;
1214
+ const scrollCount = args.scrolls ?? 5;
1215
+ const schedule = args.schedule ?? false;
1216
+ const intervalMin = args.check_interval_minutes ?? 120;
1217
+ const enrichProfiles = args.enrich_profiles ?? false;
1218
+ // Open the feed
1219
+ const tab = (await call(ctx, "openTab", { url }));
1220
+ const tabId = tab?.tabId;
1221
+ if (!tabId)
1222
+ throw new Error("Failed to open tab");
1223
+ await sleep(3000);
1224
+ // Scroll to collect posts
1225
+ for (let i = 0; i < scrollCount; i++) {
1226
+ await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
1227
+ await sleep(700);
1228
+ }
1229
+ // Extract posts
1230
+ const rawData = (await call(ctx, "extractData", { tabId }));
1231
+ const items = Array.isArray(rawData)
1232
+ ? rawData
1233
+ : rawData?.items ?? [rawData];
1234
+ // Match against triggers
1235
+ const matched = filterByKeywords(items, triggers);
1236
+ // Enrich matched authors
1237
+ let enriched = [];
1238
+ if (enrichProfiles && matched.length > 0) {
1239
+ const authorUrls = new Set();
1240
+ for (const item of matched) {
1241
+ const obj = item;
1242
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
1243
+ const linkList = Array.isArray(links) ? links : [links];
1244
+ for (const link of linkList) {
1245
+ if (typeof link === "string" && link.startsWith("http")) {
1246
+ authorUrls.add(link);
1247
+ }
1248
+ }
1249
+ }
1250
+ for (const profileUrl of [...authorUrls].slice(0, 10)) {
1251
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
1252
+ await sleep(1500);
1253
+ }
1254
+ }
1255
+ // Close tab
1256
+ try {
1257
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
1258
+ }
1259
+ catch { /* ok */ }
1260
+ // Create recurring monitor if scheduled
1261
+ let monitorId = null;
1262
+ if (schedule) {
1263
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
1264
+ const store = loadYaml(monitorsFile) ?? { monitors: [] };
1265
+ monitorId = `ds-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1266
+ store.monitors.push({
1267
+ id: monitorId,
1268
+ url,
1269
+ name: `Demand Scanner: ${triggers.slice(0, 3).join(", ")}`,
1270
+ check_interval_minutes: intervalMin,
1271
+ condition: {
1272
+ type: "new_items",
1273
+ metadata: { triggers, source: "demand_scanner" },
1274
+ },
1275
+ enabled: true,
1276
+ created_at: new Date().toISOString(),
1277
+ alert_count: 0,
1278
+ });
1279
+ saveYaml(monitorsFile, store);
1280
+ }
1281
+ return {
1282
+ url,
1283
+ platform: detectPlatform(url),
1284
+ total_posts: items.length,
1285
+ trigger_matches: matched.length,
1286
+ triggers,
1287
+ matches: matched.slice(0, 30),
1288
+ enriched_profiles: enriched.length > 0 ? enriched : undefined,
1289
+ monitor: monitorId
1290
+ ? { id: monitorId, check_interval_minutes: intervalMin, status: "active" }
1291
+ : undefined,
1292
+ };
1293
+ }
1294
+ async function opportunityRadar(args) {
1295
+ const limit = args.limit ?? 50;
1296
+ const sinceHours = args.since_hours ?? 24;
1297
+ const keywords = args.keywords ?? [];
1298
+ const alertsFile = path.join(NEURON_DIR, "monitor-alerts.yaml");
1299
+ const store = loadYaml(alertsFile);
1300
+ let alerts = store?.alerts ?? [];
1301
+ // Filter by time
1302
+ const cutoff = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString();
1303
+ alerts = alerts.filter((a) => {
1304
+ const alert = a;
1305
+ return alert.triggered_at >= cutoff;
1306
+ });
1307
+ // Filter by keywords
1308
+ if (keywords.length > 0) {
1309
+ alerts = filterByKeywords(alerts, keywords);
1310
+ }
1311
+ // Sort by recency (newest first)
1312
+ alerts.sort((a, b) => {
1313
+ const aTime = a.triggered_at;
1314
+ const bTime = b.triggered_at;
1315
+ return bTime.localeCompare(aTime);
1316
+ });
1317
+ // Deduplicate by URL + condition type
1318
+ const seen = new Set();
1319
+ alerts = alerts.filter((a) => {
1320
+ const alert = a;
1321
+ const key = `${alert.url}:${alert.condition_type}`;
1322
+ if (seen.has(key))
1323
+ return false;
1324
+ seen.add(key);
1325
+ return true;
1326
+ });
1327
+ // Load monitor names for context
1328
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
1329
+ const monitorStore = loadYaml(monitorsFile);
1330
+ const monitorNames = new Map();
1331
+ for (const m of monitorStore?.monitors ?? []) {
1332
+ monitorNames.set(m.id, m.name);
1333
+ }
1334
+ const enrichedAlerts = alerts.slice(0, limit).map((a) => {
1335
+ const alert = a;
1336
+ return {
1337
+ ...alert,
1338
+ monitor_name: alert.monitor_name ?? monitorNames.get(alert.monitor_id) ?? "unknown",
1339
+ };
1340
+ });
1341
+ return {
1342
+ total_alerts: alerts.length,
1343
+ showing: enrichedAlerts.length,
1344
+ since_hours: sinceHours,
1345
+ keywords_applied: keywords,
1346
+ opportunities: enrichedAlerts,
1347
+ };
1348
+ }
1349
+ // ── Phase 4 ─────────────────────────────────────────────────
1350
+ async function quickAction(args, ctx) {
1351
+ const action = args.action;
1352
+ const text = args.text;
1353
+ let tabId = args.tabId;
1354
+ const url = args.url;
1355
+ // Navigate if URL provided
1356
+ if (url && !tabId) {
1357
+ const tab = (await call(ctx, "openTab", { url }));
1358
+ tabId = tab?.tabId;
1359
+ if (!tabId)
1360
+ throw new Error("Failed to open tab");
1361
+ await sleep(3000);
1362
+ }
1363
+ if (!tabId)
1364
+ throw new Error("Either tabId or url is required");
1365
+ // Detect platform
1366
+ let platform = args.platform;
1367
+ if (!platform) {
1368
+ try {
1369
+ const tabUrl = (await call(ctx, "evaluateJS", {
1370
+ tabId,
1371
+ expression: "window.location.href",
1372
+ }));
1373
+ platform = detectPlatform(tabUrl);
1374
+ }
1375
+ catch {
1376
+ platform = "web";
1377
+ }
1378
+ }
1379
+ // Focus the tab
1380
+ await call(ctx, "focusTab", { tabId });
1381
+ await sleep(300);
1382
+ // Get platform selectors
1383
+ const selectors = PLATFORM_SELECTORS[platform] ?? {};
1384
+ const actionMap = {
1385
+ like: "like",
1386
+ comment: "comment",
1387
+ reply: "reply",
1388
+ follow: "follow",
1389
+ dm: "dm",
1390
+ compose: "compose",
1391
+ };
1392
+ const selectorKey = actionMap[action] ?? action;
1393
+ const targetSelectors = selectors[selectorKey] ?? [];
1394
+ if (targetSelectors.length === 0) {
1395
+ return {
1396
+ status: "unsupported",
1397
+ action,
1398
+ platform,
1399
+ message: `No selectors registered for "${action}" on platform "${platform}"`,
1400
+ };
1401
+ }
1402
+ // For actions that just need a click (like, follow)
1403
+ if (action === "like" || action === "follow") {
1404
+ try {
1405
+ await call(ctx, "clickElement", { tabId, selectors: targetSelectors });
1406
+ await sleep(500);
1407
+ return { status: "success", action, platform, tabId };
1408
+ }
1409
+ catch (err) {
1410
+ return { status: "error", action, platform, error: err.message };
1411
+ }
1412
+ }
1413
+ // For actions that need text input (comment, reply, dm, compose)
1414
+ if (!text) {
1415
+ return {
1416
+ status: "text_required",
1417
+ action,
1418
+ platform,
1419
+ message: `The "${action}" action requires a "text" parameter`,
1420
+ };
1421
+ }
1422
+ try {
1423
+ // Click the input area
1424
+ await call(ctx, "clickElement", { tabId, selectors: targetSelectors });
1425
+ await sleep(500);
1426
+ // Type the text
1427
+ await call(ctx, "typeText", { tabId, selectors: targetSelectors, value: text });
1428
+ await sleep(500);
1429
+ return {
1430
+ status: "typed",
1431
+ action,
1432
+ platform,
1433
+ tabId,
1434
+ message: `Text typed into ${action} field. Use neuron_smart_click to find and click the submit/send button.`,
1435
+ };
1436
+ }
1437
+ catch (err) {
1438
+ return { status: "error", action, platform, error: err.message };
1439
+ }
1440
+ }
1441
+ async function patternLearner(args, ctx) {
1442
+ const tabId = args.tabId;
1443
+ const examples = args.examples;
1444
+ const patternName = args.name;
1445
+ const shouldExtract = args.extract ?? true;
1446
+ if (examples.length < 2) {
1447
+ throw new Error("At least 2 example text values are required");
1448
+ }
1449
+ // Use evaluateJS to find DOM elements matching example text and infer a common selector
1450
+ const result = await call(ctx, "evaluateJS", {
1451
+ tabId,
1452
+ expression: `
1453
+ (function() {
1454
+ const examples = ${JSON.stringify(examples)};
1455
+ const matchingElements = [];
1456
+
1457
+ // Find elements containing each example text
1458
+ for (const example of examples) {
1459
+ const walker = document.createTreeWalker(
1460
+ document.body,
1461
+ NodeFilter.SHOW_ELEMENT,
1462
+ null
1463
+ );
1464
+ let node;
1465
+ while (node = walker.nextNode()) {
1466
+ const el = node;
1467
+ if (el.textContent && el.textContent.trim().includes(example.trim())) {
1468
+ // Prefer the deepest element that contains the text
1469
+ let deepest = el;
1470
+ for (const child of el.querySelectorAll('*')) {
1471
+ if (child.textContent && child.textContent.trim().includes(example.trim()) && child.children.length < el.children.length) {
1472
+ deepest = child;
1473
+ }
1474
+ }
1475
+ matchingElements.push({
1476
+ tag: deepest.tagName.toLowerCase(),
1477
+ classes: [...deepest.classList],
1478
+ id: deepest.id || null,
1479
+ parentTag: deepest.parentElement?.tagName.toLowerCase() || null,
1480
+ parentClasses: deepest.parentElement ? [...deepest.parentElement.classList] : [],
1481
+ });
1482
+ break;
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ if (matchingElements.length < 2) {
1488
+ return JSON.stringify({ error: 'Could not find matching elements for all examples', found: matchingElements.length });
1489
+ }
1490
+
1491
+ // Find common ancestor pattern
1492
+ const tags = matchingElements.map(e => e.tag);
1493
+ const commonTag = tags.every(t => t === tags[0]) ? tags[0] : '*';
1494
+
1495
+ // Find common classes
1496
+ const classSets = matchingElements.map(e => new Set(e.classes));
1497
+ const commonClasses = [...classSets[0]].filter(c =>
1498
+ classSets.every(s => s.has(c))
1499
+ );
1500
+
1501
+ // Find common parent pattern
1502
+ const parentTags = matchingElements.map(e => e.parentTag);
1503
+ const commonParentTag = parentTags.every(t => t === parentTags[0]) ? parentTags[0] : null;
1504
+ const parentClassSets = matchingElements.map(e => new Set(e.parentClasses));
1505
+ const commonParentClasses = parentClassSets[0] ? [...parentClassSets[0]].filter(c =>
1506
+ parentClassSets.every(s => s.has(c))
1507
+ ) : [];
1508
+
1509
+ // Build selector
1510
+ let selector = commonTag;
1511
+ if (commonClasses.length > 0) {
1512
+ selector = commonTag + '.' + commonClasses.join('.');
1513
+ }
1514
+
1515
+ let parentSelector = null;
1516
+ if (commonParentTag) {
1517
+ parentSelector = commonParentTag;
1518
+ if (commonParentClasses.length > 0) {
1519
+ parentSelector += '.' + commonParentClasses.join('.');
1520
+ }
1521
+ }
1522
+
1523
+ // Count how many elements match
1524
+ const fullSelector = parentSelector ? parentSelector + ' > ' + selector : selector;
1525
+ const matchCount = document.querySelectorAll(fullSelector).length;
1526
+
1527
+ return JSON.stringify({
1528
+ selector: fullSelector,
1529
+ item_selector: selector,
1530
+ parent_selector: parentSelector,
1531
+ match_count: matchCount,
1532
+ elements: matchingElements,
1533
+ });
1534
+ })()
1535
+ `,
1536
+ });
1537
+ const parsed = JSON.parse(String(result ?? "{}"));
1538
+ if (parsed.error) {
1539
+ return { status: "failed", error: parsed.error };
1540
+ }
1541
+ const inferredSelector = parsed.selector;
1542
+ // Save pattern if named
1543
+ if (patternName && inferredSelector) {
1544
+ const extractorsFile = path.join(NEURON_DIR, "extractors.yaml");
1545
+ const extractors = loadYaml(extractorsFile) ?? {};
1546
+ extractors[patternName] = {
1547
+ selector: inferredSelector,
1548
+ item_selector: parsed.item_selector,
1549
+ parent_selector: parsed.parent_selector,
1550
+ examples,
1551
+ learned_at: new Date().toISOString(),
1552
+ };
1553
+ saveYaml(extractorsFile, extractors);
1554
+ }
1555
+ // Extract if requested
1556
+ let extracted = null;
1557
+ if (shouldExtract && inferredSelector) {
1558
+ extracted = await call(ctx, "extractData", { tabId, selector: inferredSelector });
1559
+ }
1560
+ return {
1561
+ status: "success",
1562
+ pattern_name: patternName ?? null,
1563
+ selector: inferredSelector,
1564
+ match_count: parsed.match_count,
1565
+ saved: !!patternName,
1566
+ extracted: extracted,
1567
+ };
1568
+ }
1569
+ // ── Phase 5 ─────────────────────────────────────────────────
1570
+ async function trafficReplay(args, ctx) {
1571
+ const mode = args.mode;
1572
+ const name = args.name;
1573
+ const replaysDir = path.join(NEURON_DIR, "replays");
1574
+ ensureDir(replaysDir);
1575
+ switch (mode) {
1576
+ case "capture": {
1577
+ if (!name)
1578
+ throw new Error("Name is required for capture mode");
1579
+ const tabId = args.tabId;
1580
+ const platform = args.platform;
1581
+ const harArgs = { includeBody: true };
1582
+ if (tabId)
1583
+ harArgs.tabId = tabId;
1584
+ if (platform)
1585
+ harArgs.platform = platform;
1586
+ const har = await call(ctx, "exportHAR", harArgs);
1587
+ const filePath = path.join(replaysDir, `${name}.har.json`);
1588
+ fs.writeFileSync(filePath, JSON.stringify(har, null, 2), "utf-8");
1589
+ const entries = har?.log?.entries;
1590
+ const entryCount = Array.isArray(entries) ? entries.length : 0;
1591
+ return {
1592
+ status: "captured",
1593
+ name,
1594
+ file: filePath,
1595
+ entries: entryCount,
1596
+ };
1597
+ }
1598
+ case "replay": {
1599
+ if (!name)
1600
+ throw new Error("Name is required for replay mode");
1601
+ const filePath = path.join(replaysDir, `${name}.har.json`);
1602
+ if (!fs.existsSync(filePath)) {
1603
+ throw new Error(`Replay "${name}" not found at ${filePath}`);
1604
+ }
1605
+ const harContent = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1606
+ const log = harContent.log;
1607
+ const entries = (log?.entries ?? []);
1608
+ // Set mock rules for each captured URL
1609
+ let rulesSet = 0;
1610
+ for (const entry of entries.slice(0, 50)) {
1611
+ const request = entry.request;
1612
+ const response = entry.response;
1613
+ const content = response?.content;
1614
+ const body = content?.text;
1615
+ if (request?.url && body) {
1616
+ try {
1617
+ await call(ctx, "setMockRule", {
1618
+ urlPattern: request.url,
1619
+ matchType: "glob",
1620
+ responseStatus: response?.status ?? 200,
1621
+ responseBody: body,
1622
+ });
1623
+ rulesSet++;
1624
+ }
1625
+ catch {
1626
+ // skip if mock fails
1627
+ }
1628
+ }
1629
+ }
1630
+ // Navigate if URL provided
1631
+ const navigateUrl = args.navigate_url;
1632
+ let tabId;
1633
+ if (navigateUrl) {
1634
+ const tab = (await call(ctx, "openTab", { url: navigateUrl }));
1635
+ tabId = tab?.tabId;
1636
+ }
1637
+ return {
1638
+ status: "replaying",
1639
+ name,
1640
+ mock_rules_set: rulesSet,
1641
+ total_entries: entries.length,
1642
+ tabId: tabId ?? null,
1643
+ message: `${rulesSet} mock rules set from "${name}". Traffic will be intercepted.`,
1644
+ };
1645
+ }
1646
+ case "list": {
1647
+ const files = fs.existsSync(replaysDir)
1648
+ ? fs.readdirSync(replaysDir).filter((f) => f.endsWith(".har.json"))
1649
+ : [];
1650
+ const replays = files.map((f) => {
1651
+ const stat = fs.statSync(path.join(replaysDir, f));
1652
+ return {
1653
+ name: f.replace(".har.json", ""),
1654
+ file: f,
1655
+ size_kb: Math.round(stat.size / 1024),
1656
+ captured_at: stat.mtime.toISOString(),
1657
+ };
1658
+ });
1659
+ return { replays, count: replays.length };
1660
+ }
1661
+ default:
1662
+ throw new Error(`Unknown traffic replay mode: ${mode}. Use 'capture', 'replay', or 'list'.`);
1663
+ }
1664
+ }
1665
+ async function apiBlueprint(args, ctx) {
1666
+ const collectionName = args.collection_name;
1667
+ const url = args.url;
1668
+ const includeAuth = args.include_auth ?? true;
1669
+ // Navigate to URL if provided to capture fresh traffic
1670
+ let tabId = args.tabId;
1671
+ if (url) {
1672
+ const tab = (await call(ctx, "openTab", { url }));
1673
+ tabId = tab?.tabId;
1674
+ if (!tabId)
1675
+ throw new Error("Failed to open tab");
1676
+ await sleep(3000);
1677
+ // Scroll to trigger API calls
1678
+ for (let i = 0; i < 3; i++) {
1679
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
1680
+ await sleep(500);
1681
+ }
1682
+ }
1683
+ // Discover APIs
1684
+ const apis = (await call(ctx, "discoverApis", {}));
1685
+ // Get recent requests for auth headers and response details
1686
+ const requestArgs = { limit: 200 };
1687
+ if (tabId)
1688
+ requestArgs.tabId = tabId;
1689
+ const requests = (await call(ctx, "getRecentRequests", requestArgs));
1690
+ const requestList = (requests?.requests ?? []);
1691
+ // Build auth header map
1692
+ const authHeaders = {};
1693
+ if (includeAuth) {
1694
+ for (const req of requestList) {
1695
+ const headers = req.requestHeaders;
1696
+ if (headers) {
1697
+ for (const [key, value] of Object.entries(headers)) {
1698
+ const lk = key.toLowerCase();
1699
+ if (lk === "authorization" || lk === "x-api-key" || lk.includes("token") || lk.includes("csrf")) {
1700
+ authHeaders[key] = value;
1701
+ }
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+ // Group endpoints by base URL
1707
+ const endpoints = (apis?.endpoints ?? apis?.api_map ?? []);
1708
+ const folders = new Map();
1709
+ for (const ep of endpoints) {
1710
+ const epUrl = String(ep.url ?? ep.pattern ?? "");
1711
+ try {
1712
+ const parsed = new URL(epUrl.startsWith("http") ? epUrl : `https://example.com${epUrl}`);
1713
+ const folder = parsed.hostname.replace(/\./g, "-");
1714
+ if (!folders.has(folder))
1715
+ folders.set(folder, []);
1716
+ folders.get(folder).push(ep);
1717
+ }
1718
+ catch {
1719
+ if (!folders.has("misc"))
1720
+ folders.set("misc", []);
1721
+ folders.get("misc").push(ep);
1722
+ }
1723
+ }
1724
+ // Create the collection file
1725
+ const slug = collectionName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1726
+ const collectionsDir = path.join(NEURON_DIR, "collections");
1727
+ ensureDir(collectionsDir);
1728
+ const collection = {
1729
+ name: collectionName,
1730
+ description: `API blueprint auto-generated from traffic${url ? ` on ${url}` : ""}`,
1731
+ version: "1.0.0",
1732
+ created_at: new Date().toISOString(),
1733
+ defaults: {
1734
+ headers: includeAuth ? authHeaders : {},
1735
+ },
1736
+ folders: {},
1737
+ };
1738
+ const foldersObj = collection.folders;
1739
+ for (const [folderName, eps] of folders) {
1740
+ foldersObj[folderName] = {
1741
+ description: `Endpoints for ${folderName}`,
1742
+ endpoints: eps.map((ep) => ({
1743
+ method: String(ep.method ?? ep.methods ?? "GET").split(",")[0].trim().toUpperCase(),
1744
+ path: String(ep.url ?? ep.pattern ?? "/"),
1745
+ name: String(ep.name ?? ep.pattern ?? ep.url ?? "endpoint"),
1746
+ description: `${ep.call_count ?? 0} calls observed`,
1747
+ })),
1748
+ };
1749
+ }
1750
+ saveYaml(path.join(collectionsDir, `${slug}.yaml`), collection);
1751
+ // Close tab if we opened one
1752
+ if (url && tabId) {
1753
+ try {
1754
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
1755
+ }
1756
+ catch { /* ok */ }
1757
+ }
1758
+ return {
1759
+ status: "created",
1760
+ collection_name: collectionName,
1761
+ collection_file: `${slug}.yaml`,
1762
+ total_endpoints: endpoints.length,
1763
+ folders: [...folders.entries()].map(([name, eps]) => ({
1764
+ name,
1765
+ endpoint_count: eps.length,
1766
+ })),
1767
+ auth_headers_found: Object.keys(authHeaders).length,
1768
+ source_url: url ?? "existing traffic",
1769
+ };
1770
+ }
1771
+ // ── Wave 2 helper: extract posts from a page ────────────────
1772
+ async function extractPosts(ctx, url, scrollCount, selector) {
1773
+ const tab = (await call(ctx, "openTab", { url }));
1774
+ const tabId = tab?.tabId;
1775
+ if (!tabId)
1776
+ throw new Error("Failed to open tab");
1777
+ await sleep(3000);
1778
+ for (let i = 0; i < scrollCount; i++) {
1779
+ await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
1780
+ await sleep(700);
1781
+ }
1782
+ const extractArgs = { tabId };
1783
+ if (selector)
1784
+ extractArgs.selector = selector;
1785
+ const rawData = (await call(ctx, "extractData", extractArgs));
1786
+ const items = Array.isArray(rawData)
1787
+ ? rawData
1788
+ : rawData?.items ?? [rawData];
1789
+ return { tabId, items };
1790
+ }
1791
+ async function closeTab(ctx, tabId) {
1792
+ try {
1793
+ await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
1794
+ }
1795
+ catch { /* ok */ }
1796
+ }
1797
+ async function pageWatch(args) {
1798
+ const url = args.url;
1799
+ const name = args.name;
1800
+ const selector = args.selector;
1801
+ const intervalMin = args.check_interval_minutes ?? 60;
1802
+ const keywords = args.keywords ?? [];
1803
+ // Load existing monitors
1804
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
1805
+ const store = loadYaml(monitorsFile) ?? { monitors: [] };
1806
+ const monitorId = `pw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1807
+ const monitor = {
1808
+ id: monitorId,
1809
+ url,
1810
+ name,
1811
+ check_interval_minutes: intervalMin,
1812
+ condition: {
1813
+ type: "content_change",
1814
+ selector: selector ?? undefined,
1815
+ metadata: { keywords, source: "page_watch", use_structured_diff: true },
1816
+ },
1817
+ enabled: true,
1818
+ created_at: new Date().toISOString(),
1819
+ alert_count: 0,
1820
+ };
1821
+ store.monitors.push(monitor);
1822
+ saveYaml(monitorsFile, store);
1823
+ return {
1824
+ status: "created",
1825
+ monitor_id: monitorId,
1826
+ name,
1827
+ url,
1828
+ selector: selector ?? "full page",
1829
+ check_interval_minutes: intervalMin,
1830
+ keywords,
1831
+ message: `Page watch "${name}" created. It will check ${url} every ${intervalMin} minutes for content changes.`,
1832
+ };
1833
+ }
1834
+ // ── Wave 2 implementations ──────────────────────────────────
1835
+ async function revenueTracker(args) {
1836
+ const action = args.action;
1837
+ const pipelineFile = path.join(NEURON_DIR, "pipeline.yaml");
1838
+ const pipeline = loadYaml(pipelineFile) ?? { leads: [] };
1839
+ switch (action) {
1840
+ case "add_lead": {
1841
+ const lead = args.lead;
1842
+ if (!lead)
1843
+ throw new Error("Lead data required for add_lead");
1844
+ const id = `lead-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1845
+ pipeline.leads.push({
1846
+ id,
1847
+ ...lead,
1848
+ stage: "discovered",
1849
+ revenue: 0,
1850
+ created_at: new Date().toISOString(),
1851
+ updated_at: new Date().toISOString(),
1852
+ history: [{ stage: "discovered", at: new Date().toISOString() }],
1853
+ });
1854
+ saveYaml(pipelineFile, pipeline);
1855
+ return { status: "added", lead_id: id, stage: "discovered" };
1856
+ }
1857
+ case "update_stage": {
1858
+ const leadId = args.lead_id;
1859
+ const stage = args.stage;
1860
+ if (!leadId || !stage)
1861
+ throw new Error("lead_id and stage required");
1862
+ const lead = pipeline.leads.find((l) => l.id === leadId);
1863
+ if (!lead)
1864
+ throw new Error(`Lead ${leadId} not found`);
1865
+ lead.stage = stage;
1866
+ lead.updated_at = new Date().toISOString();
1867
+ const history = lead.history ?? [];
1868
+ history.push({ stage, at: new Date().toISOString() });
1869
+ lead.history = history;
1870
+ saveYaml(pipelineFile, pipeline);
1871
+ return { status: "updated", lead_id: leadId, stage };
1872
+ }
1873
+ case "tag_revenue": {
1874
+ const leadId = args.lead_id;
1875
+ const revenue = args.revenue;
1876
+ if (!leadId || revenue == null)
1877
+ throw new Error("lead_id and revenue required");
1878
+ const lead = pipeline.leads.find((l) => l.id === leadId);
1879
+ if (!lead)
1880
+ throw new Error(`Lead ${leadId} not found`);
1881
+ lead.revenue = revenue;
1882
+ lead.stage = "closed_won";
1883
+ lead.updated_at = new Date().toISOString();
1884
+ const history = lead.history ?? [];
1885
+ history.push({ stage: "closed_won", revenue, at: new Date().toISOString() });
1886
+ lead.history = history;
1887
+ saveYaml(pipelineFile, pipeline);
1888
+ return { status: "revenue_tagged", lead_id: leadId, revenue };
1889
+ }
1890
+ case "list": {
1891
+ const filters = (args.filters ?? {});
1892
+ let leads = pipeline.leads;
1893
+ if (filters.stage)
1894
+ leads = leads.filter((l) => l.stage === filters.stage);
1895
+ if (filters.source)
1896
+ leads = leads.filter((l) => String(l.source_tool ?? l.source ?? "").includes(filters.source));
1897
+ if (filters.platform)
1898
+ leads = leads.filter((l) => l.platform === filters.platform);
1899
+ if (filters.since_days) {
1900
+ const cutoff = new Date(Date.now() - filters.since_days * 86400000).toISOString();
1901
+ leads = leads.filter((l) => l.created_at >= cutoff);
1902
+ }
1903
+ return { total: leads.length, leads };
1904
+ }
1905
+ case "stats": {
1906
+ const stages = {};
1907
+ const sources = {};
1908
+ let totalRevenue = 0;
1909
+ for (const lead of pipeline.leads) {
1910
+ const s = lead.stage;
1911
+ stages[s] = (stages[s] ?? 0) + 1;
1912
+ const src = String(lead.source_tool ?? lead.source ?? "unknown");
1913
+ sources[src] = (sources[src] ?? 0) + 1;
1914
+ totalRevenue += lead.revenue ?? 0;
1915
+ }
1916
+ return {
1917
+ total_leads: pipeline.leads.length,
1918
+ by_stage: stages,
1919
+ by_source: sources,
1920
+ total_revenue: totalRevenue,
1921
+ conversion_rate: pipeline.leads.length > 0
1922
+ ? `${Math.round((stages.closed_won ?? 0) / pipeline.leads.length * 100)}%`
1923
+ : "0%",
1924
+ };
1925
+ }
1926
+ default:
1927
+ throw new Error(`Unknown revenue tracker action: ${action}`);
1928
+ }
1929
+ }
1930
+ async function leadScorer(args) {
1931
+ const leads = args.leads;
1932
+ const rubric = (args.rubric ?? {});
1933
+ const hotThreshold = args.hot_threshold ?? 80;
1934
+ const warmThreshold = args.warm_threshold ?? 50;
1935
+ const bioKeywords = rubric.bio_keywords ?? ["founder", "ceo", "business", "owner", "entrepreneur"];
1936
+ const locationKeywords = rubric.location_keywords ?? [];
1937
+ const followerMin = rubric.follower_min ?? 0;
1938
+ const followerMax = rubric.follower_max ?? Infinity;
1939
+ const requireWebsite = rubric.require_website ?? false;
1940
+ const maxDaysSincePost = rubric.max_days_since_post;
1941
+ const scored = leads.map((lead) => {
1942
+ let score = 50; // base score
1943
+ const text = JSON.stringify(lead).toLowerCase();
1944
+ const breakdown = {};
1945
+ // Bio keyword matching (+5 per match, max 25)
1946
+ let bioMatch = 0;
1947
+ for (const kw of bioKeywords) {
1948
+ if (text.includes(kw.toLowerCase()))
1949
+ bioMatch++;
1950
+ }
1951
+ const bioScore = Math.min(bioMatch * 5, 25);
1952
+ score += bioScore;
1953
+ breakdown.bio_keywords = bioScore;
1954
+ // Follower count
1955
+ const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
1956
+ if (followerMatch) {
1957
+ const count = parseInt(followerMatch[1].replace(/,/g, ""), 10);
1958
+ if (count >= followerMin && count <= followerMax) {
1959
+ const followerScore = Math.min(Math.floor(count / 1000), 15);
1960
+ score += followerScore;
1961
+ breakdown.followers = followerScore;
1962
+ }
1963
+ else {
1964
+ score -= 20;
1965
+ breakdown.followers = -20;
1966
+ }
1967
+ }
1968
+ // Website presence
1969
+ if (text.includes("http") || text.includes("www.") || text.includes(".com")) {
1970
+ score += 10;
1971
+ breakdown.website = 10;
1972
+ }
1973
+ else if (requireWebsite) {
1974
+ score -= 30;
1975
+ breakdown.website = -30;
1976
+ }
1977
+ // Location match
1978
+ if (locationKeywords.length > 0) {
1979
+ const locMatch = locationKeywords.some((l) => text.includes(l.toLowerCase()));
1980
+ if (locMatch) {
1981
+ score += 10;
1982
+ breakdown.location = 10;
1983
+ }
1984
+ }
1985
+ // Engagement signals
1986
+ if (text.includes("engaged") || text.includes("active")) {
1987
+ score += 5;
1988
+ breakdown.engagement = 5;
1989
+ }
1990
+ // Clamp score
1991
+ score = Math.max(0, Math.min(100, score));
1992
+ const tier = score >= hotThreshold ? "hot" : score >= warmThreshold ? "warm" : "cold";
1993
+ const recommended = tier === "hot" ? "DM immediately"
1994
+ : tier === "warm" ? "Comment/engage first"
1995
+ : "Follow and nurture";
1996
+ return { ...lead, score, tier, recommended_action: recommended, score_breakdown: breakdown };
1997
+ });
1998
+ // Sort by score descending
1999
+ scored.sort((a, b) => b.score - a.score);
2000
+ return {
2001
+ total: scored.length,
2002
+ hot: scored.filter((s) => s.tier === "hot").length,
2003
+ warm: scored.filter((s) => s.tier === "warm").length,
2004
+ cold: scored.filter((s) => s.tier === "cold").length,
2005
+ thresholds: { hot: hotThreshold, warm: warmThreshold },
2006
+ leads: scored,
2007
+ };
2008
+ }
2009
+ async function socialProof(args, ctx) {
2010
+ const action = args.action;
2011
+ const testimonialsFile = path.join(NEURON_DIR, "testimonials.yaml");
2012
+ switch (action) {
2013
+ case "scan": {
2014
+ const url = args.url;
2015
+ if (!url)
2016
+ throw new Error("URL required for scan action");
2017
+ const brandKeywords = args.brand_keywords ?? [];
2018
+ const positiveSignals = args.positive_signals ?? [
2019
+ "changed my", "love this", "best", "recommend", "game changer",
2020
+ "amazing", "thank you", "incredible", "life saver", "must have",
2021
+ ];
2022
+ const scrollCount = args.scrolls ?? 5;
2023
+ const { tabId, items } = await extractPosts(ctx, url, scrollCount);
2024
+ // Filter for positive mentions
2025
+ const allSignals = [...positiveSignals, ...brandKeywords];
2026
+ const matches = filterByKeywords(items, allSignals);
2027
+ // Save testimonials
2028
+ const store = loadYaml(testimonialsFile) ?? { testimonials: [] };
2029
+ for (const match of matches) {
2030
+ store.testimonials.push({
2031
+ ...match,
2032
+ source_url: url,
2033
+ captured_at: new Date().toISOString(),
2034
+ matched_signals: allSignals.filter((s) => JSON.stringify(match).toLowerCase().includes(s.toLowerCase())),
2035
+ });
2036
+ }
2037
+ saveYaml(testimonialsFile, store);
2038
+ await closeTab(ctx, tabId);
2039
+ return {
2040
+ url,
2041
+ total_scanned: items.length,
2042
+ testimonials_found: matches.length,
2043
+ saved: matches.length,
2044
+ testimonials: matches.slice(0, 20),
2045
+ };
2046
+ }
2047
+ case "list": {
2048
+ const store = loadYaml(testimonialsFile);
2049
+ const testimonials = store?.testimonials ?? [];
2050
+ return { total: testimonials.length, testimonials: testimonials.slice(-50) };
2051
+ }
2052
+ case "export": {
2053
+ const store = loadYaml(testimonialsFile);
2054
+ return { testimonials: store?.testimonials ?? [], format: "json" };
2055
+ }
2056
+ default:
2057
+ throw new Error(`Unknown social proof action: ${action}`);
2058
+ }
2059
+ }
2060
+ async function dmOutreach(args, ctx) {
2061
+ const action = args.action;
2062
+ const campaignsFile = path.join(NEURON_DIR, "outreach-campaigns.yaml");
2063
+ switch (action) {
2064
+ case "preview": {
2065
+ const template = args.template;
2066
+ const leads = args.leads?.slice(0, 3) ?? [];
2067
+ if (!template)
2068
+ throw new Error("Template required for preview");
2069
+ const previews = leads.map((lead) => {
2070
+ let msg = template;
2071
+ for (const [key, value] of Object.entries(lead)) {
2072
+ msg = msg.replace(new RegExp(`\\{${key}\\}`, "g"), String(value ?? ""));
2073
+ }
2074
+ return { lead, message: msg };
2075
+ });
2076
+ return { action: "preview", previews };
2077
+ }
2078
+ case "start": {
2079
+ const template = args.template;
2080
+ const leads = args.leads;
2081
+ const platform = args.platform;
2082
+ const delayMin = args.delay_minutes ?? 5;
2083
+ const maxPerDay = args.max_per_day ?? 20;
2084
+ if (!template || !leads?.length || !platform) {
2085
+ throw new Error("template, leads, and platform required");
2086
+ }
2087
+ const campaignId = `dm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2088
+ const campaigns = loadYaml(campaignsFile) ?? { campaigns: [] };
2089
+ campaigns.campaigns.push({
2090
+ id: campaignId,
2091
+ platform,
2092
+ template,
2093
+ total_leads: leads.length,
2094
+ delay_minutes: delayMin,
2095
+ max_per_day: maxPerDay,
2096
+ status: "active",
2097
+ sent: 0,
2098
+ replied: 0,
2099
+ created_at: new Date().toISOString(),
2100
+ leads: leads.map((l) => ({ ...l, status: "queued" })),
2101
+ });
2102
+ saveYaml(campaignsFile, campaigns);
2103
+ // Send first message
2104
+ const firstLead = leads[0];
2105
+ let msg = template;
2106
+ for (const [key, value] of Object.entries(firstLead)) {
2107
+ msg = msg.replace(new RegExp(`\\{${key}\\}`, "g"), String(value ?? ""));
2108
+ }
2109
+ const selectors = PLATFORM_SELECTORS[platform]?.dm ?? [];
2110
+ if (selectors.length > 0 && firstLead.url) {
2111
+ try {
2112
+ const tab = (await call(ctx, "openTab", { url: firstLead.url }));
2113
+ if (tab?.tabId) {
2114
+ await sleep(3000);
2115
+ await call(ctx, "focusTab", { tabId: tab.tabId });
2116
+ await sleep(300);
2117
+ // Navigate to DM
2118
+ await call(ctx, "clickElement", { tabId: tab.tabId, selectors });
2119
+ await sleep(1000);
2120
+ await call(ctx, "typeText", { tabId: tab.tabId, selectors, value: msg });
2121
+ }
2122
+ }
2123
+ catch {
2124
+ // first message send failed, campaign still created
2125
+ }
2126
+ }
2127
+ return {
2128
+ status: "started",
2129
+ campaign_id: campaignId,
2130
+ total_leads: leads.length,
2131
+ delay_minutes: delayMin,
2132
+ first_message: msg,
2133
+ message: `Campaign started. ${leads.length} leads queued. Sending 1 every ${delayMin} minutes (max ${maxPerDay}/day).`,
2134
+ };
2135
+ }
2136
+ case "status": {
2137
+ const campaignId = args.campaign_id;
2138
+ const campaigns = loadYaml(campaignsFile);
2139
+ if (!campaignId) {
2140
+ return { campaigns: campaigns?.campaigns?.map((c) => ({ id: c.id, status: c.status, sent: c.sent, total: c.total_leads })) ?? [] };
2141
+ }
2142
+ const campaign = campaigns?.campaigns?.find((c) => c.id === campaignId);
2143
+ if (!campaign)
2144
+ throw new Error(`Campaign ${campaignId} not found`);
2145
+ return campaign;
2146
+ }
2147
+ case "pause": {
2148
+ const campaignId = args.campaign_id;
2149
+ if (!campaignId)
2150
+ throw new Error("campaign_id required");
2151
+ const campaigns = loadYaml(campaignsFile);
2152
+ const campaign = campaigns?.campaigns?.find((c) => c.id === campaignId);
2153
+ if (!campaign)
2154
+ throw new Error(`Campaign ${campaignId} not found`);
2155
+ campaign.status = "paused";
2156
+ saveYaml(campaignsFile, campaigns);
2157
+ return { status: "paused", campaign_id: campaignId };
2158
+ }
2159
+ default:
2160
+ throw new Error(`Unknown dm outreach action: ${action}`);
2161
+ }
2162
+ }
2163
+ async function postToLead(args, ctx) {
2164
+ const postUrl = args.post_url;
2165
+ const trigger = args.trigger;
2166
+ const enrichProfiles = args.enrich_profiles ?? true;
2167
+ const scrollCount = args.scrolls ?? 5;
2168
+ const schedule = args.schedule ?? false;
2169
+ const intervalMin = args.check_interval_minutes ?? 30;
2170
+ // Open the post
2171
+ const { tabId, items } = await extractPosts(ctx, postUrl, scrollCount);
2172
+ // Find comments matching the trigger keyword
2173
+ const matched = filterByKeywords(items, [trigger]);
2174
+ // Enrich matched commenters
2175
+ let enriched = [];
2176
+ if (enrichProfiles && matched.length > 0) {
2177
+ const authorUrls = new Set();
2178
+ for (const item of matched) {
2179
+ const obj = item;
2180
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
2181
+ const linkList = Array.isArray(links) ? links : [links];
2182
+ for (const link of linkList) {
2183
+ if (typeof link === "string" && link.startsWith("http"))
2184
+ authorUrls.add(link);
2185
+ }
2186
+ }
2187
+ for (const profileUrl of [...authorUrls].slice(0, 20)) {
2188
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
2189
+ await sleep(1500);
2190
+ }
2191
+ }
2192
+ await closeTab(ctx, tabId);
2193
+ // Create monitor if scheduled
2194
+ let monitorId = null;
2195
+ if (schedule) {
2196
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
2197
+ const store = loadYaml(monitorsFile) ?? { monitors: [] };
2198
+ monitorId = `ptl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2199
+ store.monitors.push({
2200
+ id: monitorId,
2201
+ url: postUrl,
2202
+ name: `Post-to-Lead: "${trigger}" on ${postUrl.slice(0, 50)}`,
2203
+ check_interval_minutes: intervalMin,
2204
+ condition: { type: "new_items", metadata: { trigger, source: "post_to_lead" } },
2205
+ enabled: true,
2206
+ created_at: new Date().toISOString(),
2207
+ alert_count: 0,
2208
+ });
2209
+ saveYaml(monitorsFile, store);
2210
+ }
2211
+ return {
2212
+ post_url: postUrl,
2213
+ trigger,
2214
+ total_comments: items.length,
2215
+ trigger_matches: matched.length,
2216
+ leads: enriched.length > 0 ? enriched : matched.slice(0, 30),
2217
+ monitor: monitorId ? { id: monitorId, interval: intervalMin } : undefined,
2218
+ };
2219
+ }
2220
+ async function commentMiner(args, ctx) {
2221
+ const postUrl = args.post_url;
2222
+ const keywords = args.keywords ?? [];
2223
+ const enrichProfiles = args.enrich_profiles ?? true;
2224
+ const maxProfiles = args.max_profiles ?? 20;
2225
+ const scrollCount = args.scrolls ?? 5;
2226
+ const { tabId, items } = await extractPosts(ctx, postUrl, scrollCount);
2227
+ let filtered = items;
2228
+ if (keywords.length > 0) {
2229
+ filtered = filterByKeywords(items, keywords);
2230
+ }
2231
+ // Extract unique commenter URLs
2232
+ const authorUrls = new Set();
2233
+ for (const item of filtered) {
2234
+ const obj = item;
2235
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
2236
+ const linkList = Array.isArray(links) ? links : [links];
2237
+ for (const link of linkList) {
2238
+ if (typeof link === "string" && link.startsWith("http"))
2239
+ authorUrls.add(link);
2240
+ }
2241
+ }
2242
+ let enriched = [];
2243
+ if (enrichProfiles && authorUrls.size > 0) {
2244
+ for (const profileUrl of [...authorUrls].slice(0, maxProfiles)) {
2245
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
2246
+ await sleep(1500);
2247
+ }
2248
+ }
2249
+ await closeTab(ctx, tabId);
2250
+ return {
2251
+ post_url: postUrl,
2252
+ total_comments: items.length,
2253
+ filtered: filtered.length,
2254
+ keywords_applied: keywords,
2255
+ unique_commenters: authorUrls.size,
2256
+ comments: filtered.slice(0, 50),
2257
+ profiles: enriched,
2258
+ };
2259
+ }
2260
+ async function competitorMapper(args, ctx) {
2261
+ const profileUrl = args.profile_url;
2262
+ const postsToScan = args.posts_to_scan ?? 5;
2263
+ const maxCommenters = args.max_commenters ?? 20;
2264
+ const scrollsPerPost = args.scrolls_per_post ?? 3;
2265
+ const yourHandle = args.your_handle;
2266
+ // Visit competitor profile
2267
+ const tab = (await call(ctx, "openTab", { url: profileUrl }));
2268
+ const tabId = tab?.tabId;
2269
+ if (!tabId)
2270
+ throw new Error("Failed to open tab");
2271
+ await sleep(3000);
2272
+ // Scroll to load posts
2273
+ for (let i = 0; i < 3; i++) {
2274
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
2275
+ await sleep(500);
2276
+ }
2277
+ // Extract post links
2278
+ const profileData = (await call(ctx, "extractData", { tabId }));
2279
+ const profileItems = Array.isArray(profileData)
2280
+ ? profileData
2281
+ : profileData?.items ?? [profileData];
2282
+ // Find post URLs
2283
+ const postUrls = [];
2284
+ for (const item of profileItems) {
2285
+ const text = JSON.stringify(item);
2286
+ const urlMatches = text.match(/https?:\/\/[^\s"',]+\/p\/[^\s"',]+/g) ??
2287
+ text.match(/https?:\/\/[^\s"',]+\/status\/[^\s"',]+/g) ?? [];
2288
+ for (const u of urlMatches) {
2289
+ if (!postUrls.includes(u))
2290
+ postUrls.push(u);
2291
+ }
2292
+ }
2293
+ await closeTab(ctx, tabId);
2294
+ // Visit each post and extract commenters
2295
+ const commenterFrequency = new Map();
2296
+ const commenterData = new Map();
2297
+ for (const postUrl of postUrls.slice(0, postsToScan)) {
2298
+ try {
2299
+ const { tabId: postTabId, items } = await extractPosts(ctx, postUrl, scrollsPerPost);
2300
+ for (const item of items) {
2301
+ const obj = item;
2302
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
2303
+ const linkList = Array.isArray(links) ? links : [links];
2304
+ for (const link of linkList) {
2305
+ if (typeof link === "string" && link.startsWith("http")) {
2306
+ commenterFrequency.set(link, (commenterFrequency.get(link) ?? 0) + 1);
2307
+ if (!commenterData.has(link))
2308
+ commenterData.set(link, item);
2309
+ }
2310
+ }
2311
+ }
2312
+ await closeTab(ctx, postTabId);
2313
+ await sleep(1500);
2314
+ }
2315
+ catch {
2316
+ // skip failed posts
2317
+ }
2318
+ }
2319
+ // Sort by frequency and enrich top commenters
2320
+ const sorted = [...commenterFrequency.entries()]
2321
+ .sort((a, b) => b[1] - a[1])
2322
+ .slice(0, maxCommenters);
2323
+ const enriched = [];
2324
+ for (const [url, frequency] of sorted) {
2325
+ const profile = await researchSingleProfile(ctx, url, 2);
2326
+ const text = JSON.stringify(profile.data).toLowerCase();
2327
+ // Categorize
2328
+ let category = "casual_follower";
2329
+ if (text.includes("founder") || text.includes("ceo") || text.includes("business") || text.includes("owner")) {
2330
+ category = "potential_customer";
2331
+ }
2332
+ else if (text.includes("agency") || text.includes("marketing") || text.includes("consulting")) {
2333
+ category = "fellow_business";
2334
+ }
2335
+ else if (text.match(/(\d[\d,]*)\s*followers/) && parseInt((text.match(/(\d[\d,]*)\s*followers/) ?? ["0", "0"])[1].replace(/,/g, ""), 10) > 10000) {
2336
+ category = "influencer";
2337
+ }
2338
+ const isOverlap = yourHandle ? text.includes(yourHandle.toLowerCase()) : false;
2339
+ enriched.push({
2340
+ url,
2341
+ frequency,
2342
+ category,
2343
+ overlap_with_you: isOverlap,
2344
+ profile: profile.data,
2345
+ error: profile.error,
2346
+ });
2347
+ await sleep(1500);
2348
+ }
2349
+ return {
2350
+ competitor: profileUrl,
2351
+ posts_scanned: Math.min(postsToScan, postUrls.length),
2352
+ unique_commenters: commenterFrequency.size,
2353
+ enriched: enriched.length,
2354
+ commenters: enriched,
2355
+ };
2356
+ }
2357
+ async function influencerVetter(args, ctx) {
2358
+ const profileUrl = args.profile_url;
2359
+ const postsToAnalyze = args.posts_to_analyze ?? 10;
2360
+ const scrollCount = args.scrolls ?? 3;
2361
+ // Visit profile
2362
+ const tab = (await call(ctx, "openTab", { url: profileUrl }));
2363
+ const tabId = tab?.tabId;
2364
+ if (!tabId)
2365
+ throw new Error("Failed to open tab");
2366
+ await sleep(3000);
2367
+ for (let i = 0; i < scrollCount; i++) {
2368
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
2369
+ await sleep(500);
2370
+ }
2371
+ const profileData = (await call(ctx, "extractData", { tabId }));
2372
+ const text = JSON.stringify(profileData).toLowerCase();
2373
+ // Extract metrics
2374
+ const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
2375
+ const followingMatch = text.match(/(\d[\d,]*)\s*following/);
2376
+ const postsMatch = text.match(/(\d[\d,]*)\s*posts/);
2377
+ const followers = followerMatch ? parseInt(followerMatch[1].replace(/,/g, ""), 10) : 0;
2378
+ const following = followingMatch ? parseInt(followingMatch[1].replace(/,/g, ""), 10) : 0;
2379
+ const postCount = postsMatch ? parseInt(postsMatch[1].replace(/,/g, ""), 10) : 0;
2380
+ // Analyze engagement from visible posts
2381
+ const items = Array.isArray(profileData)
2382
+ ? profileData
2383
+ : profileData?.items ?? [profileData];
2384
+ let totalEngagement = 0;
2385
+ let genericComments = 0;
2386
+ let substantiveComments = 0;
2387
+ for (const item of items.slice(0, postsToAnalyze)) {
2388
+ const itemText = JSON.stringify(item).toLowerCase();
2389
+ const likeMatch = itemText.match(/(\d[\d,]*)\s*likes?/);
2390
+ const commentMatch = itemText.match(/(\d[\d,]*)\s*comments?/);
2391
+ totalEngagement += likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : 0;
2392
+ totalEngagement += commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : 0;
2393
+ // Simple comment quality heuristic
2394
+ const genericPatterns = /^(nice|great|love|wow|fire|amazing|beautiful|❤|🔥|👏|💯|😍)\s*[!.]?$/;
2395
+ if (genericPatterns.test(itemText.trim()))
2396
+ genericComments++;
2397
+ else if (itemText.length > 20)
2398
+ substantiveComments++;
2399
+ }
2400
+ await closeTab(ctx, tabId);
2401
+ // Calculate scores
2402
+ const engagementRate = followers > 0 ? (totalEngagement / Math.max(items.length, 1)) / followers * 100 : 0;
2403
+ const followRatio = following > 0 ? followers / following : 0;
2404
+ const commentQuality = (genericComments + substantiveComments) > 0
2405
+ ? substantiveComments / (genericComments + substantiveComments) * 100
2406
+ : 50;
2407
+ // Build trust score
2408
+ let trustScore = 50;
2409
+ const redFlags = [];
2410
+ const positives = [];
2411
+ // Engagement rate scoring
2412
+ if (engagementRate > 3) {
2413
+ trustScore += 15;
2414
+ positives.push(`Good engagement rate: ${engagementRate.toFixed(2)}%`);
2415
+ }
2416
+ else if (engagementRate > 1) {
2417
+ trustScore += 5;
2418
+ }
2419
+ else if (engagementRate < 0.5 && followers > 10000) {
2420
+ trustScore -= 20;
2421
+ redFlags.push(`Very low engagement (${engagementRate.toFixed(2)}%) for ${followers.toLocaleString()} followers — possible purchased audience`);
2422
+ }
2423
+ // Follow ratio
2424
+ if (followRatio > 5) {
2425
+ trustScore += 10;
2426
+ positives.push("Healthy follower-to-following ratio");
2427
+ }
2428
+ else if (followRatio < 1) {
2429
+ trustScore -= 10;
2430
+ redFlags.push("Following more people than followers — may use follow/unfollow tactics");
2431
+ }
2432
+ // Comment quality
2433
+ if (commentQuality > 60) {
2434
+ trustScore += 10;
2435
+ positives.push("Substantive comment quality");
2436
+ }
2437
+ else if (commentQuality < 30) {
2438
+ trustScore -= 10;
2439
+ redFlags.push("Most engagement appears generic/bot-like");
2440
+ }
2441
+ // Posting consistency
2442
+ if (postCount > 100) {
2443
+ trustScore += 5;
2444
+ positives.push("Consistent posting history");
2445
+ }
2446
+ trustScore = Math.max(0, Math.min(100, trustScore));
2447
+ return {
2448
+ profile_url: profileUrl,
2449
+ platform: detectPlatform(profileUrl),
2450
+ trust_score: trustScore,
2451
+ verdict: trustScore >= 70 ? "likely_authentic" : trustScore >= 40 ? "moderate_risk" : "high_risk",
2452
+ metrics: {
2453
+ followers,
2454
+ following,
2455
+ posts: postCount,
2456
+ engagement_rate: `${engagementRate.toFixed(2)}%`,
2457
+ follow_ratio: followRatio.toFixed(1),
2458
+ comment_quality: `${commentQuality.toFixed(0)}%`,
2459
+ },
2460
+ red_flags: redFlags,
2461
+ positives,
2462
+ posts_analyzed: Math.min(postsToAnalyze, items.length),
2463
+ };
2464
+ }
2465
+ async function engagementAnalyzer(args, ctx) {
2466
+ const profileUrl = args.profile_url;
2467
+ const postsToAnalyze = args.posts_to_analyze ?? 20;
2468
+ const scrollCount = args.scrolls ?? 5;
2469
+ const tab = (await call(ctx, "openTab", { url: profileUrl }));
2470
+ const tabId = tab?.tabId;
2471
+ if (!tabId)
2472
+ throw new Error("Failed to open tab");
2473
+ await sleep(3000);
2474
+ for (let i = 0; i < scrollCount; i++) {
2475
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
2476
+ await sleep(500);
2477
+ }
2478
+ const profileData = (await call(ctx, "extractData", { tabId }));
2479
+ const items = Array.isArray(profileData)
2480
+ ? profileData
2481
+ : profileData?.items ?? [profileData];
2482
+ await closeTab(ctx, tabId);
2483
+ // Analyze posts
2484
+ const postAnalysis = [];
2485
+ let totalLikes = 0;
2486
+ let totalComments = 0;
2487
+ const hashtags = {};
2488
+ for (const item of items.slice(0, postsToAnalyze)) {
2489
+ const text = JSON.stringify(item).toLowerCase();
2490
+ const likeMatch = text.match(/(\d[\d,]*)\s*likes?/);
2491
+ const commentMatch = text.match(/(\d[\d,]*)\s*comments?/);
2492
+ const likes = likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : 0;
2493
+ const comments = commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : 0;
2494
+ totalLikes += likes;
2495
+ totalComments += comments;
2496
+ // Extract hashtags
2497
+ const hashMatches = text.match(/#\w+/g) ?? [];
2498
+ for (const h of hashMatches) {
2499
+ hashtags[h] = (hashtags[h] ?? 0) + 1;
2500
+ }
2501
+ // Detect content type
2502
+ let contentType = "text";
2503
+ if (text.includes("video") || text.includes("reel"))
2504
+ contentType = "video";
2505
+ else if (text.includes("carousel") || text.includes("slides"))
2506
+ contentType = "carousel";
2507
+ else if (text.includes("image") || text.includes("photo"))
2508
+ contentType = "image";
2509
+ postAnalysis.push({ likes, comments, engagement: likes + comments, content_type: contentType });
2510
+ }
2511
+ const avgEngagement = postAnalysis.length > 0
2512
+ ? (totalLikes + totalComments) / postAnalysis.length
2513
+ : 0;
2514
+ // Top performing content types
2515
+ const byType = {};
2516
+ for (const p of postAnalysis) {
2517
+ const ct = p.content_type;
2518
+ if (!byType[ct])
2519
+ byType[ct] = { count: 0, avg_engagement: 0 };
2520
+ byType[ct].count++;
2521
+ byType[ct].avg_engagement += p.engagement;
2522
+ }
2523
+ for (const ct of Object.keys(byType)) {
2524
+ byType[ct].avg_engagement = Math.round(byType[ct].avg_engagement / byType[ct].count);
2525
+ }
2526
+ // Top hashtags
2527
+ const topHashtags = Object.entries(hashtags)
2528
+ .sort((a, b) => b[1] - a[1])
2529
+ .slice(0, 10)
2530
+ .map(([tag, count]) => ({ tag, uses: count }));
2531
+ // Recommendations
2532
+ const recommendations = [];
2533
+ const bestType = Object.entries(byType).sort((a, b) => b[1].avg_engagement - a[1].avg_engagement)[0];
2534
+ if (bestType) {
2535
+ recommendations.push(`${bestType[0]} posts average ${bestType[1].avg_engagement} engagement — lean into this format`);
2536
+ }
2537
+ if (topHashtags.length > 0) {
2538
+ recommendations.push(`Top hashtags: ${topHashtags.slice(0, 3).map((h) => h.tag).join(", ")}`);
2539
+ }
2540
+ if (avgEngagement > 0) {
2541
+ recommendations.push(`Average engagement per post: ${Math.round(avgEngagement)}`);
2542
+ }
2543
+ return {
2544
+ profile_url: profileUrl,
2545
+ posts_analyzed: postAnalysis.length,
2546
+ total_likes: totalLikes,
2547
+ total_comments: totalComments,
2548
+ avg_engagement: Math.round(avgEngagement),
2549
+ content_type_breakdown: byType,
2550
+ top_hashtags: topHashtags,
2551
+ recommendations,
2552
+ post_details: postAnalysis,
2553
+ };
2554
+ }
2555
+ async function mentionTracker(args, ctx) {
2556
+ const url = args.url;
2557
+ const brandTerms = args.brand_terms;
2558
+ const scrollCount = args.scrolls ?? 5;
2559
+ const enrichProfiles = args.enrich_profiles ?? false;
2560
+ const schedule = args.schedule ?? false;
2561
+ const intervalMin = args.check_interval_minutes ?? 60;
2562
+ const { tabId, items } = await extractPosts(ctx, url, scrollCount);
2563
+ const matches = filterByKeywords(items, brandTerms);
2564
+ // Save mentions
2565
+ const mentionsFile = path.join(NEURON_DIR, "mentions.yaml");
2566
+ const store = loadYaml(mentionsFile) ?? { mentions: [] };
2567
+ for (const match of matches) {
2568
+ store.mentions.push({
2569
+ ...match,
2570
+ brand_terms_matched: brandTerms.filter((t) => JSON.stringify(match).toLowerCase().includes(t.toLowerCase())),
2571
+ source_url: url,
2572
+ detected_at: new Date().toISOString(),
2573
+ });
2574
+ }
2575
+ saveYaml(mentionsFile, store);
2576
+ // Enrich if requested
2577
+ let enriched = [];
2578
+ if (enrichProfiles && matches.length > 0) {
2579
+ const authorUrls = new Set();
2580
+ for (const item of matches) {
2581
+ const obj = item;
2582
+ const links = (obj.links ?? obj.url ?? obj.href ?? "");
2583
+ for (const link of (Array.isArray(links) ? links : [links])) {
2584
+ if (typeof link === "string" && link.startsWith("http"))
2585
+ authorUrls.add(link);
2586
+ }
2587
+ }
2588
+ for (const profileUrl of [...authorUrls].slice(0, 10)) {
2589
+ enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
2590
+ await sleep(1500);
2591
+ }
2592
+ }
2593
+ await closeTab(ctx, tabId);
2594
+ // Schedule if requested
2595
+ let monitorId = null;
2596
+ if (schedule) {
2597
+ const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
2598
+ const monitorStore = loadYaml(monitorsFile) ?? { monitors: [] };
2599
+ monitorId = `mt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2600
+ monitorStore.monitors.push({
2601
+ id: monitorId,
2602
+ url,
2603
+ name: `Mention Tracker: ${brandTerms.slice(0, 3).join(", ")}`,
2604
+ check_interval_minutes: intervalMin,
2605
+ condition: { type: "new_items", metadata: { brand_terms: brandTerms, source: "mention_tracker" } },
2606
+ enabled: true,
2607
+ created_at: new Date().toISOString(),
2608
+ alert_count: 0,
2609
+ });
2610
+ saveYaml(monitorsFile, monitorStore);
2611
+ }
2612
+ return {
2613
+ url,
2614
+ brand_terms: brandTerms,
2615
+ total_scanned: items.length,
2616
+ mentions_found: matches.length,
2617
+ mentions: matches.slice(0, 30),
2618
+ enriched_profiles: enriched.length > 0 ? enriched : undefined,
2619
+ monitor: monitorId ? { id: monitorId, interval: intervalMin } : undefined,
2620
+ };
2621
+ }
2622
+ async function postAnalyzer(args, ctx) {
2623
+ const url = args.url;
2624
+ const batch = args.batch ?? false;
2625
+ const postsCount = args.posts_count ?? 20;
2626
+ const scrollCount = args.scrolls ?? 3;
2627
+ const { tabId, items } = await extractPosts(ctx, url, batch ? scrollCount + 2 : scrollCount);
2628
+ const analyzed = items.slice(0, batch ? postsCount : 1).map((item) => {
2629
+ const text = JSON.stringify(item).toLowerCase();
2630
+ const obj = item;
2631
+ const likeMatch = text.match(/(\d[\d,]*)\s*likes?/);
2632
+ const commentMatch = text.match(/(\d[\d,]*)\s*comments?/);
2633
+ const shareMatch = text.match(/(\d[\d,]*)\s*(shares?|reposts?)/);
2634
+ const viewMatch = text.match(/(\d[\d,]*)\s*views?/);
2635
+ const hashMatches = text.match(/#\w+/g) ?? [];
2636
+ const mentionMatches = text.match(/@\w+/g) ?? [];
2637
+ let contentType = "text";
2638
+ if (text.includes("video") || text.includes("reel"))
2639
+ contentType = "video";
2640
+ else if (text.includes("carousel") || text.includes("slides"))
2641
+ contentType = "carousel";
2642
+ else if (text.includes("image") || text.includes("photo"))
2643
+ contentType = "image";
2644
+ return {
2645
+ ...obj,
2646
+ metrics: {
2647
+ likes: likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : null,
2648
+ comments: commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : null,
2649
+ shares: shareMatch ? parseInt(shareMatch[1].replace(/,/g, ""), 10) : null,
2650
+ views: viewMatch ? parseInt(viewMatch[1].replace(/,/g, ""), 10) : null,
2651
+ },
2652
+ content_type: contentType,
2653
+ hashtags: hashMatches,
2654
+ mentions: mentionMatches,
2655
+ };
2656
+ });
2657
+ await closeTab(ctx, tabId);
2658
+ return {
2659
+ url,
2660
+ platform: detectPlatform(url),
2661
+ batch,
2662
+ posts_analyzed: analyzed.length,
2663
+ posts: analyzed,
2664
+ };
2665
+ }
2666
+ async function contentPipeline(args, ctx) {
2667
+ const sourceUrl = args.source_url;
2668
+ const targetPlatform = args.target_platform;
2669
+ const scrollCount = args.scrolls ?? 5;
2670
+ // Extract source content
2671
+ const { tabId, items } = await extractPosts(ctx, sourceUrl, scrollCount);
2672
+ // Also get the page's text content
2673
+ let pageText = "";
2674
+ try {
2675
+ pageText = String(await call(ctx, "evaluateJS", {
2676
+ tabId,
2677
+ expression: `
2678
+ const article = document.querySelector('article') || document.querySelector('[role="main"]') || document.body;
2679
+ article.innerText.substring(0, 5000);
2680
+ `,
2681
+ }));
2682
+ }
2683
+ catch {
2684
+ pageText = JSON.stringify(items).substring(0, 5000);
2685
+ }
2686
+ await closeTab(ctx, tabId);
2687
+ // Platform-specific formatting hints
2688
+ const formatGuide = {
2689
+ linkedin: {
2690
+ max_chars: 3000,
2691
+ format: "line breaks between sentences, emoji sparingly, 3-5 hashtags at end",
2692
+ hook_style: "Bold first line, line break, story or stat",
2693
+ cta: "Comment below, follow for more, share if you agree",
2694
+ },
2695
+ x: {
2696
+ max_chars: 280,
2697
+ format: "Thread if long, numbered tweets, no hashtag spam",
2698
+ hook_style: "Hot take, contrarian, or surprising stat",
2699
+ cta: "RT if you agree, follow for more",
2700
+ },
2701
+ instagram: {
2702
+ max_chars: 2200,
2703
+ format: "Caption with line breaks, 20-30 hashtags in first comment",
2704
+ hook_style: "Stop-scroll first sentence, emoji hooks",
2705
+ cta: "Save this, share to stories, link in bio",
2706
+ },
2707
+ tiktok: {
2708
+ max_chars: 300,
2709
+ format: "Script format: hook (3s), body (20-40s), CTA (3s)",
2710
+ hook_style: "POV:, What if, Story time, Controversial opener",
2711
+ cta: "Follow for part 2, comment your experience",
2712
+ },
2713
+ newsletter: {
2714
+ max_chars: 10000,
2715
+ format: "Subject line, preview text, sections with headers, CTA button",
2716
+ hook_style: "Subject: curiosity gap or direct benefit",
2717
+ cta: "Reply to this email, share with a friend",
2718
+ },
2719
+ blog: {
2720
+ max_chars: 20000,
2721
+ format: "H1, intro, H2 sections, bullet points, conclusion, meta description",
2722
+ hook_style: "SEO-aware title, engaging first paragraph",
2723
+ cta: "Subscribe, read related posts",
2724
+ },
2725
+ };
2726
+ return {
2727
+ source_url: sourceUrl,
2728
+ source_platform: detectPlatform(sourceUrl),
2729
+ target_platform: targetPlatform,
2730
+ source_content: pageText.substring(0, 3000),
2731
+ source_data: items.slice(0, 5),
2732
+ format_guide: formatGuide[targetPlatform] ?? formatGuide.blog,
2733
+ hint: "Use the source_content and format_guide to restructure the content for the target platform. " +
2734
+ "Write the hook, body, and CTA following the platform's conventions.",
2735
+ };
2736
+ }
2737
+ async function groupLinkScanner(args, ctx) {
2738
+ const url = args.url;
2739
+ const scrollCount = args.scrolls ?? 5;
2740
+ const validateLinks = args.validate_links ?? false;
2741
+ const enrichProfiles = args.enrich_profiles ?? false;
2742
+ const { tabId, items } = await extractPosts(ctx, url, scrollCount);
2743
+ // Also scan full page text for group links
2744
+ let fullText = "";
2745
+ try {
2746
+ fullText = String(await call(ctx, "evaluateJS", {
2747
+ tabId,
2748
+ expression: "document.body.innerText.substring(0, 50000)",
2749
+ }));
2750
+ }
2751
+ catch { /* ok */ }
2752
+ await closeTab(ctx, tabId);
2753
+ // Regex patterns for group links
2754
+ const patterns = [
2755
+ /https?:\/\/wa\.me\/[^\s"',<>]+/gi,
2756
+ /https?:\/\/chat\.whatsapp\.com\/[^\s"',<>]+/gi,
2757
+ /https?:\/\/t\.me\/[^\s"',<>]+/gi,
2758
+ /https?:\/\/telegram\.me\/[^\s"',<>]+/gi,
2759
+ ];
2760
+ const allText = fullText + " " + JSON.stringify(items);
2761
+ const groupLinks = [];
2762
+ const seen = new Set();
2763
+ for (const pattern of patterns) {
2764
+ const matches = allText.match(pattern) ?? [];
2765
+ for (const match of matches) {
2766
+ const cleaned = match.replace(/[),.'";]+$/, "");
2767
+ if (seen.has(cleaned))
2768
+ continue;
2769
+ seen.add(cleaned);
2770
+ const type = cleaned.includes("whatsapp") || cleaned.includes("wa.me")
2771
+ ? "whatsapp" : "telegram";
2772
+ // Extract surrounding context
2773
+ const idx = allText.indexOf(cleaned);
2774
+ const context = allText.substring(Math.max(0, idx - 100), idx + cleaned.length + 100).trim();
2775
+ groupLinks.push({ url: cleaned, type, context: context.substring(0, 200) });
2776
+ }
2777
+ }
2778
+ // Save to file
2779
+ const groupLinksFile = path.join(NEURON_DIR, "group-links.yaml");
2780
+ const store = loadYaml(groupLinksFile) ?? { links: [] };
2781
+ for (const link of groupLinks) {
2782
+ store.links.push({
2783
+ ...link,
2784
+ source_url: url,
2785
+ found_at: new Date().toISOString(),
2786
+ });
2787
+ }
2788
+ saveYaml(groupLinksFile, store);
2789
+ return {
2790
+ source_url: url,
2791
+ total_links_found: groupLinks.length,
2792
+ whatsapp: groupLinks.filter((l) => l.type === "whatsapp").length,
2793
+ telegram: groupLinks.filter((l) => l.type === "telegram").length,
2794
+ links: groupLinks,
2795
+ };
2796
+ }
2797
+ async function bioHarvester(args, ctx) {
2798
+ const urls = args.urls.slice(0, 20);
2799
+ const followLinktree = args.follow_linktree ?? true;
2800
+ const delayMs = args.delay_ms ?? 2000;
2801
+ const results = [];
2802
+ for (const profileUrl of urls) {
2803
+ try {
2804
+ const tab = (await call(ctx, "openTab", { url: profileUrl }));
2805
+ const tabId = tab?.tabId;
2806
+ if (!tabId) {
2807
+ results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: [], error: "Failed to open tab" });
2808
+ continue;
2809
+ }
2810
+ await sleep(2500);
2811
+ // Extract all links from the page
2812
+ const linkData = await call(ctx, "evaluateJS", {
2813
+ tabId,
2814
+ expression: `
2815
+ (function() {
2816
+ const links = [];
2817
+ // Bio section links
2818
+ document.querySelectorAll('a[href]').forEach(a => {
2819
+ const href = a.href;
2820
+ if (href && !href.startsWith('javascript') && !href.includes(window.location.hostname + '/')) {
2821
+ links.push(href);
2822
+ }
2823
+ });
2824
+ // Check for email patterns in bio text
2825
+ const bioText = document.body.innerText;
2826
+ const emails = bioText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g) || [];
2827
+ const phones = bioText.match(/(\\+?\\d{1,3}[-.\\s]?)?\\(?\\d{1,4}\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}/g) || [];
2828
+ return JSON.stringify({ links: [...new Set(links)], emails, phones });
2829
+ })()
2830
+ `,
2831
+ });
2832
+ const parsed = JSON.parse(String(linkData ?? '{"links":[],"emails":[],"phones":[]}'));
2833
+ // Categorize links
2834
+ const categorized = [];
2835
+ for (const link of parsed.links) {
2836
+ const lower = link.toLowerCase();
2837
+ let category = "website";
2838
+ if (lower.includes("wa.me") || lower.includes("whatsapp"))
2839
+ category = "whatsapp";
2840
+ else if (lower.includes("t.me") || lower.includes("telegram"))
2841
+ category = "telegram";
2842
+ else if (lower.includes("linktr.ee") || lower.includes("beacons.ai") || lower.includes("stan.store") || lower.includes("linkin.bio"))
2843
+ category = "link_in_bio";
2844
+ else if (lower.includes("calendly") || lower.includes("cal.com"))
2845
+ category = "booking";
2846
+ else if (lower.includes("youtube") || lower.includes("youtu.be"))
2847
+ category = "youtube";
2848
+ else if (lower.includes("tiktok"))
2849
+ category = "tiktok";
2850
+ else if (lower.includes("twitter") || lower.includes("x.com"))
2851
+ category = "x";
2852
+ else if (lower.includes("linkedin"))
2853
+ category = "linkedin";
2854
+ else if (lower.includes("facebook") || lower.includes("fb.com"))
2855
+ category = "facebook";
2856
+ else if (lower.includes("github"))
2857
+ category = "github";
2858
+ categorized.push({ url: link, category });
2859
+ }
2860
+ for (const email of parsed.emails) {
2861
+ categorized.push({ url: `mailto:${email}`, category: "email" });
2862
+ }
2863
+ for (const phone of parsed.phones) {
2864
+ categorized.push({ url: `tel:${phone}`, category: "phone" });
2865
+ }
2866
+ // Follow link-in-bio pages
2867
+ if (followLinktree) {
2868
+ const linktreeLinks = categorized.filter((l) => l.category === "link_in_bio");
2869
+ for (const ltLink of linktreeLinks.slice(0, 1)) {
2870
+ try {
2871
+ const ltTab = (await call(ctx, "openTab", { url: ltLink.url }));
2872
+ if (ltTab?.tabId) {
2873
+ await sleep(2000);
2874
+ const ltData = await call(ctx, "evaluateJS", {
2875
+ tabId: ltTab.tabId,
2876
+ expression: `
2877
+ JSON.stringify([...document.querySelectorAll('a[href]')].map(a => a.href).filter(h =>
2878
+ h && !h.includes(window.location.hostname) && !h.startsWith('javascript')
2879
+ ))
2880
+ `,
2881
+ });
2882
+ const ltLinks = JSON.parse(String(ltData ?? "[]"));
2883
+ for (const ll of ltLinks) {
2884
+ categorized.push({ url: ll, category: "from_linktree" });
2885
+ }
2886
+ await closeTab(ctx, ltTab.tabId);
2887
+ }
2888
+ }
2889
+ catch { /* ok */ }
2890
+ }
2891
+ }
2892
+ await closeTab(ctx, tabId);
2893
+ results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: categorized, error: null });
2894
+ }
2895
+ catch (err) {
2896
+ results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: [], error: err.message });
2897
+ }
2898
+ if (delayMs > 0)
2899
+ await sleep(delayMs);
2900
+ }
2901
+ return {
2902
+ total_profiles: results.length,
2903
+ total_links: results.reduce((sum, r) => sum + r.links.length, 0),
2904
+ profiles: results,
2905
+ };
2906
+ }
2907
+ async function crossPlatformFinder(args, ctx) {
2908
+ const handle = args.handle;
2909
+ const sourcePlatform = args.source_platform;
2910
+ const searchPlatforms = args.search_platforms ??
2911
+ ["ig", "x", "li", "tk", "fb"].filter((p) => p !== sourcePlatform);
2912
+ const platformSearchUrls = {
2913
+ ig: `https://www.instagram.com/${handle}/`,
2914
+ x: `https://x.com/${handle}`,
2915
+ li: `https://www.linkedin.com/in/${handle}/`,
2916
+ tk: `https://www.tiktok.com/@${handle}`,
2917
+ fb: `https://www.facebook.com/${handle}`,
2918
+ };
2919
+ const results = [];
2920
+ for (const platform of searchPlatforms) {
2921
+ const searchUrl = platformSearchUrls[platform];
2922
+ if (!searchUrl)
2923
+ continue;
2924
+ try {
2925
+ const profile = await researchSingleProfile(ctx, searchUrl, 1);
2926
+ const hasContent = profile.data != null && !profile.error;
2927
+ const text = JSON.stringify(profile.data ?? "").toLowerCase();
2928
+ // Check if the page is a valid profile (not a 404/error page)
2929
+ const is404 = text.includes("sorry, this page isn") ||
2930
+ text.includes("this account doesn") ||
2931
+ text.includes("this page isn't available") ||
2932
+ text.includes("user not found") ||
2933
+ text.length < 100;
2934
+ results.push({
2935
+ platform,
2936
+ url: searchUrl,
2937
+ found: hasContent && !is404,
2938
+ confidence: hasContent && !is404 ? "handle_match" : "not_found",
2939
+ data: hasContent && !is404 ? profile.data : null,
2940
+ error: profile.error,
2941
+ });
2942
+ }
2943
+ catch (err) {
2944
+ results.push({
2945
+ platform,
2946
+ url: searchUrl,
2947
+ found: false,
2948
+ confidence: "error",
2949
+ data: null,
2950
+ error: err.message,
2951
+ });
2952
+ }
2953
+ await sleep(2000);
2954
+ }
2955
+ return {
2956
+ handle,
2957
+ source_platform: sourcePlatform,
2958
+ platforms_searched: searchPlatforms.length,
2959
+ found_on: results.filter((r) => r.found).length,
2960
+ results,
2961
+ };
2962
+ }
2963
+ async function pageWeight(args, ctx) {
2964
+ const url = args.url;
2965
+ const budget = (args.budget ?? {});
2966
+ const maxJsKb = budget.max_js_kb ?? 300;
2967
+ const maxCssKb = budget.max_css_kb ?? 100;
2968
+ const maxImagesKb = budget.max_images_kb ?? 500;
2969
+ const maxTotalKb = budget.max_total_kb ?? 1500;
2970
+ const maxSingleKb = budget.max_single_asset_kb ?? 200;
2971
+ // Open page and let it fully load
2972
+ const tab = (await call(ctx, "openTab", { url }));
2973
+ const tabId = tab?.tabId;
2974
+ if (!tabId)
2975
+ throw new Error("Failed to open tab");
2976
+ await sleep(4000);
2977
+ // Scroll to trigger lazy loads
2978
+ for (let i = 0; i < 3; i++) {
2979
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
2980
+ await sleep(500);
2981
+ }
2982
+ // Get waterfall data for resource sizes and timing
2983
+ const [waterfallData, requests] = await Promise.allSettled([
2984
+ call(ctx, "waterfall", { tabId }),
2985
+ call(ctx, "getRecentRequests", { tabId, limit: 300 }),
2986
+ ]);
2987
+ await closeTab(ctx, tabId);
2988
+ // Categorize resources
2989
+ const categories = {
2990
+ javascript: { count: 0, total_kb: 0, assets: [] },
2991
+ css: { count: 0, total_kb: 0, assets: [] },
2992
+ images: { count: 0, total_kb: 0, assets: [] },
2993
+ fonts: { count: 0, total_kb: 0, assets: [] },
2994
+ video: { count: 0, total_kb: 0, assets: [] },
2995
+ other: { count: 0, total_kb: 0, assets: [] },
2996
+ };
2997
+ const requestList = requests.status === "fulfilled"
2998
+ ? (requests.value?.requests ?? [])
2999
+ : [];
3000
+ let totalKb = 0;
3001
+ for (const req of requestList) {
3002
+ const r = req;
3003
+ const reqUrl = String(r.url ?? "");
3004
+ const size = (r.responseSize ?? r.size ?? r.contentLength ?? 0) / 1024;
3005
+ const lower = reqUrl.toLowerCase();
3006
+ let cat = "other";
3007
+ if (lower.endsWith(".js") || lower.includes("javascript") || (r.contentType ?? "").includes("javascript"))
3008
+ cat = "javascript";
3009
+ else if (lower.endsWith(".css") || (r.contentType ?? "").includes("css"))
3010
+ cat = "css";
3011
+ else if (/\.(png|jpg|jpeg|gif|webp|avif|svg|ico)/.test(lower) || (r.contentType ?? "").includes("image"))
3012
+ cat = "images";
3013
+ else if (/\.(woff2?|ttf|otf|eot)/.test(lower) || (r.contentType ?? "").includes("font"))
3014
+ cat = "fonts";
3015
+ else if (/\.(mp4|webm|m3u8)/.test(lower) || (r.contentType ?? "").includes("video"))
3016
+ cat = "video";
3017
+ const sizeKb = Math.round(size * 10) / 10;
3018
+ categories[cat].count++;
3019
+ categories[cat].total_kb += sizeKb;
3020
+ categories[cat].assets.push({ url: reqUrl.substring(0, 120), size_kb: sizeKb });
3021
+ totalKb += sizeKb;
3022
+ }
3023
+ // Build recommendations
3024
+ const recommendations = [];
3025
+ const violations = [];
3026
+ if (categories.javascript.total_kb > maxJsKb) {
3027
+ violations.push({ rule: "max_js_kb", actual: Math.round(categories.javascript.total_kb), limit: maxJsKb });
3028
+ recommendations.push({ priority: "high", category: "javascript", message: `JS total ${Math.round(categories.javascript.total_kb)}KB exceeds budget of ${maxJsKb}KB — tree-shake, code-split, or defer non-critical scripts` });
3029
+ }
3030
+ if (categories.css.total_kb > maxCssKb) {
3031
+ violations.push({ rule: "max_css_kb", actual: Math.round(categories.css.total_kb), limit: maxCssKb });
3032
+ recommendations.push({ priority: "medium", category: "css", message: `CSS total ${Math.round(categories.css.total_kb)}KB exceeds budget — purge unused rules, inline critical CSS` });
3033
+ }
3034
+ if (categories.images.total_kb > maxImagesKb) {
3035
+ violations.push({ rule: "max_images_kb", actual: Math.round(categories.images.total_kb), limit: maxImagesKb });
3036
+ recommendations.push({ priority: "high", category: "images", message: `Images total ${Math.round(categories.images.total_kb)}KB exceeds budget — compress, convert to WebP/AVIF, lazy-load below fold` });
3037
+ }
3038
+ if (totalKb > maxTotalKb) {
3039
+ violations.push({ rule: "max_total_kb", actual: Math.round(totalKb), limit: maxTotalKb });
3040
+ }
3041
+ // Flag oversized individual assets
3042
+ for (const [cat, data] of Object.entries(categories)) {
3043
+ for (const asset of data.assets) {
3044
+ if (asset.size_kb > maxSingleKb) {
3045
+ recommendations.push({ priority: "high", category: cat, message: `${asset.url} is ${asset.size_kb}KB — exceeds ${maxSingleKb}KB single asset limit` });
3046
+ }
3047
+ }
3048
+ }
3049
+ // Sort each category's assets by size descending
3050
+ for (const cat of Object.values(categories)) {
3051
+ cat.assets.sort((a, b) => b.size_kb - a.size_kb);
3052
+ cat.assets = cat.assets.slice(0, 5); // keep top 5 per category
3053
+ cat.total_kb = Math.round(cat.total_kb);
3054
+ }
3055
+ return {
3056
+ url,
3057
+ total_kb: Math.round(totalKb),
3058
+ total_requests: requestList.length,
3059
+ categories,
3060
+ budget_violations: violations,
3061
+ recommendations: recommendations.sort((a, b) => a.priority === "high" ? -1 : 1),
3062
+ waterfall: waterfallData.status === "fulfilled" ? waterfallData.value : undefined,
3063
+ };
3064
+ }
3065
+ //# sourceMappingURL=business-tools.js.map