@melaya/runner 1.1.50 → 1.1.52

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.
@@ -712,6 +712,15 @@ def _build_agent():
712
712
  # secrets are prohibited (takeover instead), act-and-observe, and human
713
713
  # handoff runs through browser_ask_user without ending the turn.
714
714
  browser_rule = (
715
+ "- SEE THE PAGE FIRST: the user is looking at a LIVE tab you are attached "
716
+ "to. When they mention the screen, the page, this tab, 'the email/form/"
717
+ "text here', 'the yandex screen', or ask you to act on on-screen content, "
718
+ "READ it with browser_get_screen_tree (or browser_screenshot for a visual/"
719
+ "canvas page) BEFORE you answer or act. NEVER ask the user to paste, "
720
+ "forward, attach or describe what is already on their screen, and NEVER "
721
+ "treat 'the screen' or an 'attached' page as a chat image attachment - it "
722
+ "is the browser tab. You have eyes; use them before ever saying you cannot "
723
+ "see something.\n"
715
724
  "- If the user asks you to DO something in their attached BROWSER (open a "
716
725
  "page, read, click, type, fill a form), use the browser_* tools. ALWAYS "
717
726
  "call browser_get_screen_tree before you click or type, and re-read it "
@@ -803,11 +812,55 @@ def _build_agent():
803
812
  "question needs live/current web data or a page's contents, use web_search / "
804
813
  "web_fetch — do NOT claim you lack a web tool.\n"
805
814
  )
815
+ # WHAT MELAYA IS - mirrors MELAYA_PRODUCT in
816
+ # server/src/services/agents/assistantChat.ts. The runner builds its own
817
+ # prompt and never sees that constant, so without this block the
818
+ # runner-hosted path kept the old tool-inventory self-description below and
819
+ # answered "what can you do" with "pipelines, runs, costs, plan usage" -
820
+ # the exact failure the cloud fix was written to remove (a user with 9
821
+ # successful turns still wrote "I'm confused just tell me what do and be
822
+ # brief", then was told Melaya cannot control phones).
823
+ #
824
+ # Keep the two in step: this is a hand-mirror, like the extension's
825
+ # recovery-card table. Short on purpose - it ships on every runner turn.
826
+ melaya_product = (
827
+ "WHAT MELAYA IS (true on every turn, regardless of which tools you "
828
+ "currently hold) - Melaya is a platform for building and running AI "
829
+ "agents across business tools, browsers and Android phones. Six "
830
+ "products on one core: 1) Melaya Agents, a visual builder for workflows "
831
+ "and multi-agent crews with models, tools, memory, schedules and "
832
+ "approval steps in replayable runs. 2) Melaya Assistant, this chat, one "
833
+ "workspace over the user's connected systems. 3) Melaya Device Control, "
834
+ "agents that operate REAL Android apps through the accessibility "
835
+ "service, no root and no per-app API. 4) Melaya Browser Control, agents "
836
+ "that act inside the user's own browser, per-tab and per-site scoped, "
837
+ "with live observation and manual takeover. 5) Melaya MCP Server, which "
838
+ "gives Claude, ChatGPT and Cursor access to Melaya's tools, browser, "
839
+ "phone and workflows over one connection. 6) Melaya Marketing, AI SEO "
840
+ "audits, backlink building, ads and a marketing copilot. Model freedom "
841
+ "across 28 providers with no lock-in, 7,000+ scoped tools, 150+ "
842
+ "validated templates, human approval before consequential actions, a "
843
+ "replayable audit trail, and built-in evals. The runner executes on the "
844
+ "user's OWN machine, so credentials and data stay with them.\n"
845
+ "ANSWERING \"what can you do\" - lead with the products above in ONE "
846
+ "short concrete paragraph tied to what this user likely wants, THEN say "
847
+ "what you can do in this exact chat and what one step unlocks the rest. "
848
+ "Do NOT answer with your tool inventory ('I can list pipelines, runs "
849
+ "and costs') - that describes your session, not Melaya. Never present a "
850
+ "product Melaya HAS as something you cannot do: absent tools are a "
851
+ "SETUP state, so name the capability and the single step that enables "
852
+ "it. Equally, never claim a capability is ready RIGHT NOW unless the "
853
+ "tools for it are actually in this turn's toolset.\n"
854
+ )
806
855
  sys_prompt = (
807
856
  "You are the Melaya Assistant, the in-app copilot of the Melaya "
808
- "agent-orchestration platform. You have READ-ONLY tools over the data "
857
+ "agent-orchestration platform.\n\n"
858
+ + melaya_product
859
+ + "\n"
860
+ "IN THIS CHAT you additionally hold READ-ONLY tools over the data "
809
861
  "this user is allowed to see: pipelines, runs, LLM costs, plan usage, "
810
- "validated workflow templates and eval results"
862
+ "validated workflow templates and eval results. That is your CURRENT "
863
+ "TOOLBOX, not a description of Melaya"
811
864
  + (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
812
865
  + "\nRules:\n"
813
866
  "- Use tools to answer questions about the user's pipelines, spend, usage, "
@@ -214,6 +214,58 @@ async function humanType(page, text) {
214
214
  await page.waitForTimeout(Math.round(d));
215
215
  }
216
216
  }
217
+ // Minimal, safe HTML rendering of plain text for the clipboard `text/html`
218
+ // flavor: preserves paragraph and line breaks so a paste into Google Docs /
219
+ // rich editors keeps structure instead of collapsing to one line.
220
+ function htmlFromPlain(t) {
221
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
222
+ return t.split(/\n{2,}/).map((p) => `<p>${esc(p).replace(/\n/g, "<br>")}</p>`).join("") || "<p></p>";
223
+ }
224
+ // One-shot bulk text insert (the "paste" mode of input_text). Char-by-char
225
+ // humanType is ~100ms/char (700s+ on an article), lands out of order in a live-
226
+ // re-rendering editor, and never reaches a Google Docs <canvas> at all. This
227
+ // writes the whole string to the clipboard (text/plain + text/html) and fires a
228
+ // real Ctrl/Cmd+V, which every surface — <input>, <textarea>, contenteditable,
229
+ // and the Docs canvas — imports in a single operation with formatting intact.
230
+ // Returns "paste" on success, or null when the clipboard is unavailable
231
+ // (permission / non-secure context) so the caller can fall back to CDP
232
+ // Input.insertText. The caller has already focused the field and run the
233
+ // secret-field / publish gates.
234
+ async function pasteBulkText(page, text) {
235
+ try {
236
+ const origin = new URL(page.url()).origin;
237
+ if (origin && origin !== "null") {
238
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin }).catch(() => { });
239
+ }
240
+ }
241
+ catch { /* opaque / non-http origin: grantPermissions would throw */ }
242
+ const wrote = await page.evaluate(async (args) => {
243
+ try {
244
+ const nav = navigator;
245
+ const CI = globalThis.ClipboardItem;
246
+ if (nav.clipboard?.write && CI) {
247
+ await nav.clipboard.write([new CI({
248
+ "text/plain": new Blob([args.t], { type: "text/plain" }),
249
+ "text/html": new Blob([args.h], { type: "text/html" }),
250
+ })]);
251
+ return true;
252
+ }
253
+ if (nav.clipboard?.writeText) {
254
+ await nav.clipboard.writeText(args.t);
255
+ return true;
256
+ }
257
+ return false;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ }, { t: text, h: htmlFromPlain(text) }).catch(() => false);
263
+ if (wrote) {
264
+ await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
265
+ return "paste";
266
+ }
267
+ return null;
268
+ }
217
269
  // Break a wheel scroll into a few smaller, jittered steps with pauses.
218
270
  async function humanScroll(page, dx, dy) {
219
271
  const steps = _randInt(3, 6);
@@ -1558,11 +1610,29 @@ export async function startBrowserBridge(opts) {
1558
1610
  "Hand off to the human with browser_ask_user so they enter the secret; never type credentials.");
1559
1611
  }
1560
1612
  await humanClick(page, pt.x, pt.y);
1561
- // Clear-then-type: select-all + humanized type preserves site key handlers.
1613
+ // Clear-then-type: select-all first so both paths REPLACE the field.
1562
1614
  await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => { });
1563
1615
  await humanPause(page, 40, 120);
1564
- await humanType(page, text);
1565
- return { typed: text.length };
1616
+ // Entry strategy. "human" = per-char cadence (anti-bot, short fields);
1617
+ // "paste" = one-shot bulk insert; omitted/"auto" = paste for long text
1618
+ // (char-by-char would blow the 20s tool timeout and scramble on a live
1619
+ // editor / never reach a Docs canvas). Short text keeps humanType so the
1620
+ // anti-bot cadence and site keystroke handlers still fire.
1621
+ const mode = String(args.mode ?? "auto");
1622
+ const usePaste = mode === "paste" || (mode !== "human" && text.length > 300);
1623
+ if (!usePaste) {
1624
+ await humanType(page, text);
1625
+ return { typed: text.length, via: "human" };
1626
+ }
1627
+ let via = await pasteBulkText(page, text);
1628
+ if (!via) {
1629
+ // Clipboard blocked: CDP whole-string insert (inputs/textarea/
1630
+ // contenteditable; not canvas). getCdp is in scope here.
1631
+ const cdp = await getCdp(rec, page);
1632
+ await cdpRace(cdp.send("Input.insertText", { text }), "Input.insertText");
1633
+ via = "insertText";
1634
+ }
1635
+ return { typed: text.length, via };
1566
1636
  }
1567
1637
  case "press_key": {
1568
1638
  const key = String(args.key || "");
@@ -148,11 +148,11 @@ export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
148
148
  * longer life than the issuer can mint is forged or corrupt, so it is
149
149
  * rejected regardless of signature.
150
150
  *
151
- * Raised 900s -> 4h alongside the server. Because this half ships in the
152
- * runner, a server deployed ahead of a runner republish would mint grants
153
- * this rejects with claims_invalid — publish the runner before, or at the
154
- * same time as, the server change. */
155
- const MAX_LIFETIME_SECONDS = 4 * 60 * 60;
151
+ * Raised 900s -> 4h -> 7d alongside the server (persistent week-long browser
152
+ * sessions). Because this half ships in the runner, a server deployed ahead of
153
+ * a runner republish would mint grants this rejects with claims_invalid —
154
+ * publish the runner before, or at the same time as, the server change. */
155
+ const MAX_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
156
156
  function b64urlJson(part, what) {
157
157
  let buf;
158
158
  try {
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Command } from "commander";
17
17
  import chalk from "chalk";
18
+ import { reportInstallFailure } from "./installReport.js";
18
19
  import { connect } from "./connection.js";
19
20
  import { detectModels } from "./detect.js";
20
21
  // Logo colors: cyan → blue → indigo gradient (matching melaya.org)
@@ -63,6 +64,7 @@ async function main() {
63
64
  console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
64
65
  console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
65
66
  console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
67
+ await reportInstallFailure("single_instance_locked", { token: opts.token, server: opts.server });
66
68
  process.exit(1);
67
69
  }
68
70
  // Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
@@ -87,6 +89,10 @@ async function main() {
87
89
  console.log(chalk.gray(" macOS: brew install python@3.12"));
88
90
  console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
89
91
  console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
92
+ await reportInstallFailure("python_missing", {
93
+ token: opts.token, server: opts.server,
94
+ detail: py.foundVersion ? `host=${py.foundVersion}` : "none",
95
+ });
90
96
  process.exit(1);
91
97
  }
92
98
  python = provisioned;
@@ -185,7 +191,16 @@ async function bootstrap() {
185
191
  }
186
192
  await main();
187
193
  }
188
- bootstrap().catch((e) => {
194
+ bootstrap().catch(async (e) => {
189
195
  console.error(chalk.red(`\n Fatal: ${e.message}\n`));
196
+ // Token is parsed inside main(); recover it from argv so a crash BEFORE the
197
+ // socket still reports. Only the STAGE is sent — never the error text, which
198
+ // routinely carries file paths and usernames.
199
+ const tokArg = process.argv.find((a) => a.startsWith("--token"));
200
+ const srvArg = process.argv.find((a) => a.startsWith("--server"));
201
+ await reportInstallFailure("bootstrap_fatal", {
202
+ token: tokArg ? tokArg.replace(/^--token[= ]?/, "") : undefined,
203
+ server: srvArg ? srvArg.replace(/^--server[= ]?/, "") : undefined,
204
+ });
190
205
  process.exit(1);
191
206
  });
@@ -303,11 +303,20 @@ export async function connect(opts) {
303
303
  void _prewarmAssistantEnv();
304
304
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
305
305
  });
306
+ // A runner that never connects is invisible to the funnel: last_seen stays
307
+ // NULL and the reason dies on the user's machine. Report the stage before
308
+ // exiting — auth-rejected and unreachable are very different fixes.
309
+ let reportedConnectFailure = false;
306
310
  socket.on("connect_error", (err) => {
307
311
  spinner.fail(chalk.red(`Connection failed: ${err.message}`));
308
- if (err.message.includes("Authentication")) {
312
+ const authRejected = err.message.includes("Authentication");
313
+ if (!reportedConnectFailure) {
314
+ reportedConnectFailure = true;
315
+ void import("./installReport.js").then((m) => m.reportInstallFailure(authRejected ? "socket_auth_rejected" : "socket_unreachable", { token: opts.token, server: opts.serverUrl })).catch(() => { });
316
+ }
317
+ if (authRejected) {
309
318
  console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
310
- process.exit(1);
319
+ setTimeout(() => process.exit(1), 300); // let the beacon leave the box
311
320
  }
312
321
  });
313
322
  socket.on("disconnect", (reason) => {
@@ -0,0 +1,12 @@
1
+ /** The point at which the runner gave up. Keep in lockstep with the server's
2
+ * META_ALLOW entry for `runner_install_failed`. */
3
+ export type InstallFailStage = "single_instance_locked" | "python_missing" | "socket_auth_rejected" | "socket_unreachable" | "bootstrap_fatal";
4
+ /**
5
+ * Best-effort telemetry for a runner that is about to exit without connecting.
6
+ * Awaited only so the process does not die mid-flight; it resolves either way.
7
+ */
8
+ export declare function reportInstallFailure(stage: InstallFailStage, opts?: {
9
+ token?: string;
10
+ server?: string;
11
+ detail?: string;
12
+ }): Promise<void>;
@@ -0,0 +1,76 @@
1
+ // packages/runner/src/installReport.ts
2
+ //
3
+ // Report WHY the runner never came up.
4
+ //
5
+ // The activation funnel could see that runner tokens were being issued and that
6
+ // nobody ever connected — measured 2026-09-14 over 48h: **9 tokens issued, 0
7
+ // connections, 0 local runs** — but not one reason, because every failure path
8
+ // here ends in `process.exit(1)` on the user's own machine and the server never
9
+ // hears about it. `runner_tokens.last_seen` stays NULL and that is the entire
10
+ // signal. 9 → 0 is not actionable; "6 of 9 had no usable Python" is.
11
+ //
12
+ // This closes that gap with a single fire-and-forget POST before the exit.
13
+ //
14
+ // Rules it must obey, because it runs while something is already going wrong:
15
+ // - NEVER throws, never blocks shutdown, hard 3s timeout.
16
+ // - Sends a STAGE CODE, not a message. No file paths, no usernames, no
17
+ // hostnames, no error strings — those carry the user's directory layout and
18
+ // sometimes their name. The stage plus a coarse platform is what makes the
19
+ // number actionable; the rest is someone's home directory.
20
+ // - Honours MELAYA_RUNNER_NO_TELEMETRY=1.
21
+ // - Authenticated by the runner token the user already passed, so the server
22
+ // can attribute it to the account without any new credential.
23
+ let alreadySent = false;
24
+ /**
25
+ * Best-effort telemetry for a runner that is about to exit without connecting.
26
+ * Awaited only so the process does not die mid-flight; it resolves either way.
27
+ */
28
+ export async function reportInstallFailure(stage, opts = {}) {
29
+ try {
30
+ if (alreadySent)
31
+ return; // one report per launch
32
+ alreadySent = true;
33
+ if (process.env.MELAYA_RUNNER_NO_TELEMETRY === "1")
34
+ return;
35
+ const token = String(opts.token || "").trim();
36
+ if (!token)
37
+ return; // nothing to attribute it to
38
+ const base = String(opts.server || "https://api.melaya.org").replace(/\/+$/, "");
39
+ // Version is read the same way connection.ts does; a dev tree without a
40
+ // package.json simply reports "0.0.0".
41
+ let version = "0.0.0";
42
+ try {
43
+ const { readFileSync } = await import("node:fs");
44
+ const { join, dirname } = await import("node:path");
45
+ const { fileURLToPath } = await import("node:url");
46
+ const here = dirname(fileURLToPath(import.meta.url));
47
+ version = String(JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version || "0.0.0");
48
+ }
49
+ catch { /* non-fatal */ }
50
+ const ac = new AbortController();
51
+ const timer = setTimeout(() => ac.abort(), 3000);
52
+ try {
53
+ await fetch(`${base}/api/v1/runner/install-report`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
56
+ // `detail` is a SHORT, bounded, non-identifying hint (e.g. the
57
+ // Python version actually found) — never a path or an error dump.
58
+ body: JSON.stringify({
59
+ stage,
60
+ version,
61
+ platform: process.platform, // darwin | win32 | linux
62
+ arch: process.arch,
63
+ node: process.versions.node,
64
+ ...(opts.detail ? { detail: String(opts.detail).slice(0, 60) } : {}),
65
+ }),
66
+ signal: ac.signal,
67
+ });
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+ catch {
74
+ /* The runner is already failing. Telemetry must never make that worse. */
75
+ }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.50",
3
+ "version": "1.1.52",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,