robot-resources 1.14.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,78 +1,91 @@
1
- # robot-resources
1
+ # Robot Resources
2
2
 
3
- > One command to install Robot Resources tools for any agent stack.
3
+ > Tools for AI agents and agentic software. Humans have HR. Agents have RR.
4
+
5
+ Robot Resources builds tools for AI agents and any software that makes LLM API calls — chatbots, RAG pipelines, AI-powered apps, internal tools. Two products today: **Router** (smart model selection — picks the right model for each task, 60-90% cost savings as a side effect) and **Scraper** (token compression for web content, median 91% token reduction). Both run locally. Both free.
6
+
7
+ ## Quick Start
4
8
 
5
9
  ```bash
6
- npx robot-resources
10
+ npx -y robot-resources
7
11
  ```
8
12
 
9
- The wizard detects what you're building and walks you through the right setup OpenClaw plugin install, JS library path, Python SDK path, MCP config for Cursor / Claude Code, or just the docs URL.
13
+ Detects your project shape (Node / Python / OpenClaw) and installs the right shim. No login, no API keys to enter, no signup. Router uses your existing provider keys they never leave your machine.
14
+
15
+ ## Router
16
+
17
+ Smart model selection. Classifies each prompt by task type (coding / reasoning / analysis / simple_qa / creative / general), filters by model capability, then within the qualifying set picks the cheapest. Routes the right model for the task — cost savings (60-90% across mixed workloads) follow from that, not the other way around. Hybrid classification: keyword fast-path (~5ms, ~70% of prompts) + LLM slow-path for ambiguous prompts (~200ms). Routes across Anthropic, OpenAI, and Google when the corresponding keys are present.
10
18
 
11
- ## What the wizard does
19
+ Three ways to install on a dev machine:
12
20
 
13
- 1. **Provisions an anonymous API key** via `POST /v1/auth/signup` (saved to `~/.robot-resources/config.json`). Optional routing works without it; the key just lights up the dashboard at robotresources.ai/dashboard.
14
- 2. **Detects your stack**:
15
- - OpenClaw installed (`~/.openclaw/`) installs the router plugin (in-process HTTP server) + scraper OC plugin, patches `openclaw.json`, restarts the gateway. **No Python, no daemon, no system service.**
16
- - Non-OC + cwd has `package.json` with LangChain/LangGraph/Mastra → preselects "JS/TS agent."
17
- - Non-OC + cwd has `requirements.txt` / `pyproject.toml` → preselects "Python agent."
18
- - Non-OC + Cursor or Claude Code installed → preselects "MCP tool."
19
- 3. **Runs the chosen path**:
20
- - **JS/TS agent** → prints `npm install @robot-resources/router` + `import { routePrompt }` example
21
- - **Python agent** → prints `pip install robot-resources` + `from robot_resources.router import route` example, plus an httpx fallback if you'd rather skip the SDK
22
- - **Cursor / Claude Code** → writes the scraper MCP config into `~/.cursor/mcp.json` / `~/.claude/settings.json`
23
- - **Docs** → prints the URL + exits
24
- - **Install OpenClaw first** → redirect message + exits
21
+ - **OpenClaw users** get an in-process plugin inside the OC gateway. Anthropic, OpenAI, and Google calls each route to their native upstream no cross-shape body translation.
22
+ - **Node projects** get an auto-attach shim (`NODE_OPTIONS=--require .../auto.cjs`). Every Anthropic, OpenAI, and Google SDK call from any Node process routes automatically. No code changes.
23
+ - **Python projects** get a `.pth` auto-attach shim in your venv. Every `anthropic` / `openai` / `google.generativeai` SDK call routes automatically. No code changes.
25
24
 
26
- ## Flags
25
+ For runtimes that ignore `NODE_OPTIONS` / `.pth` (Bun, Deno, Vercel Edge, Go, Rust, etc.), call the HTTP API directly: `POST https://api.robotresources.ai/v1/route`. Authed by API key, 100 req/min, CORS open.
27
26
 
27
+ For explicit control inside JS / Python code, use the routing-decision library:
28
+
29
+ ```bash
30
+ npm install @robot-resources/router # JS / TS
31
+ pip install robot-resources # Python (singular package name)
28
32
  ```
29
- --for=<target> langchain | python | cursor | claude-code | docs
30
- Skip the prompt and run that path directly.
31
- Required for non-TTY contexts (CI, piped, etc.)
32
- --non-interactive Treat as CI run regardless of TTY state
33
- --yes / -y Same as --non-interactive
33
+
34
+ ```js
35
+ import { routePrompt } from '@robot-resources/router/routing';
36
+ const decision = routePrompt('write a python function that reverses a string');
37
+ // decision.selected_model → 'claude-haiku-4-5' (or similar — cheapest qualifying)
34
38
  ```
35
39
 
36
- Without flags in a non-TTY context, the wizard prints the `--for=` hint and exits cleanly — never blocks waiting for stdin.
40
+ ```python
41
+ from robot_resources.router import route
42
+ decision = route('write a python function that reverses a string')
43
+ ```
37
44
 
38
- ## Pre-set the API key
45
+ Returns a routing decision; your code makes the actual LLM call with the selected model. Each request goes from your machine straight to the lab's API (`api.anthropic.com` / `api.openai.com` / `generativelanguage.googleapis.com`) using your existing key for that lab. Nothing is relayed through our infrastructure.
39
46
 
40
- For fleets or CI:
47
+ ## Scraper
41
48
 
42
- ```bash
43
- export RR_API_KEY=rr_live_... # skip signup, use this key
44
- npx robot-resources --for=cursor # or whatever path applies
45
- ```
49
+ Token compression for web content. Fetches any URL, strips noise, returns clean markdown with token count. Median 91% token reduction per page (verified across 41 page types). Mozilla Readability extraction (0.97 F1). Content-aware token estimation calibrated per content type, ±15% of actual BPE. 3-tier fetch (fast / stealth via TLS fingerprint / render via headless browser), BFS multi-page crawl, robots.txt compliance.
46
50
 
47
- ## Five paths, one wizard
51
+ Three ways to consume:
48
52
 
49
- | Path | What you get | Where |
50
- |---|---|---|
51
- | OpenClaw plugin | In-process router inside the OC gateway. Auto-routes Anthropic calls to the cheapest capable model. | `~/.openclaw/extensions/robot-resources-router/` |
52
- | JS/TS agent | `@robot-resources/router/routing` — pure ESM, zero deps, offline keyword classifier. | npm |
53
- | Python agent | `robot-resources` (singular) — thin httpx client over `/v1/route`. | PyPI |
54
- | HTTP API | Any language with curl/fetch. Authed by API key. | `POST https://api.robotresources.ai/v1/route` |
55
- | Cursor / Claude Code MCP | Scraper MCP wired into your tool's config (web fetches → 91% smaller markdown). | `~/.cursor/mcp.json` or `~/.claude/settings.json` |
53
+ - **JS library** `npm install @robot-resources/scraper` `import { scrape } from '@robot-resources/scraper'`
54
+ - **MCP server** — `npx -y @robot-resources/scraper scraper-mcp` exposes `scraper_compress_url(url)` and crawl tools to any MCP-compatible client. Auto-wired into OpenClaw by the wizard; for other clients (Cursor, Claude Code, Windsurf), add manually to your client's MCP config.
55
+ - **OpenClaw plugin** installed automatically via `npx robot-resources`. Hooks `before_tool_call` to redirect `web_fetch` through scraper compression.
56
56
 
57
- Full integration docs: https://robotresources.ai/docs
57
+ No API keys, no config.
58
58
 
59
- ## Architecture (post-PR-2.5)
59
+ ## Deploying to production
60
60
 
61
- The router used to be a Python daemon on `localhost:3838`. **Not anymore.** It now runs in-process inside whichever surface consumes it:
61
+ The wizard's shell-config install reaches dev machines only production processes don't read `.bashrc`, and env vars come from your deploy config. Copy-paste recipes for setting `NODE_OPTIONS` (Node) or installing the `.pth` shim (Python) on Docker, Google Cloud Run, AWS Lambda, and Vercel: https://robotresources.ai/docs/deploy/.
62
62
 
63
- - **OpenClaw** — the plugin's `register()` starts an HTTP server on `127.0.0.1:18790` inside OC's node process. Lifetime tied to OC. Zero daemon to keep alive.
64
- - **JS agents** — call `routePrompt()` directly. No HTTP at all. Pure function.
65
- - **Python / curl** — call `POST /v1/route` on `api.robotresources.ai`. Server-side classifier on Cloudflare Workers.
63
+ ## Advanced
66
64
 
67
- User provider keys never leave the user's machine. The platform never receives, stores, or transmits them.
65
+ ```
66
+ npx robot-resources [flags]
68
67
 
69
- ## Telemetry
68
+ --for=<target> langchain | python | cursor | claude-code | docs
69
+ Skip the prompt and run that path directly.
70
+ Required for non-TTY contexts (CI, piped, etc.)
71
+ --non-interactive Treat as CI run regardless of TTY state
72
+ --yes / -y Same as --non-interactive
73
+ ```
70
74
 
71
- Anonymous, fire-and-forget, opt-in via the wizard's API-key provisioning. Events: `wizard_started`, `wizard_path_chosen`, `install_complete`, `route_completed`, `route_via_api`, `route_via_lib`. No personal data, no request content, no provider keys.
75
+ Pre-set the API key for fleets or CI (skips signup, uses the key you provide):
76
+
77
+ ```bash
78
+ export RR_API_KEY=rr_live_...
79
+ npx robot-resources --for=langchain
80
+ ```
72
81
 
73
82
  ## Pricing
74
83
 
75
- Free. Unlimited. Your API keys never leave your machine.
84
+ Free. Unlimited. No tiers. You pay your AI providers directly — no markup, no rate limits, no quotas.
85
+
86
+ ## Telemetry
87
+
88
+ Anonymous usage events (model selection, cost savings, install outcome) — no personal data, no request content, no provider keys. Opt out with `RR_TELEMETRY=off`.
76
89
 
77
90
  ## Links
78
91
 
@@ -80,11 +93,11 @@ Free. Unlimited. Your API keys never leave your machine.
80
93
  - Docs: https://robotresources.ai/docs
81
94
  - Dashboard: https://robotresources.ai/dashboard
82
95
  - HTTP API: `POST https://api.robotresources.ai/v1/route`
83
- - npm: https://www.npmjs.com/package/robot-resources
84
96
  - GitHub: https://github.com/robot-resources/packages
85
97
  - Discord: https://robotresources.ai/discord
86
98
  - Contact: agent@robotresources.ai
87
- - Agent docs: https://robotresources.ai/llms.txt
99
+
100
+ > AI agents installing this on a human's behalf: see https://robotresources.ai/llms.txt for the agent install protocol.
88
101
 
89
102
  ## License
90
103
 
package/bin/setup.js CHANGED
@@ -25,13 +25,18 @@ if (args.includes('--uninstall')) {
25
25
  const scopeArg = args.find((a) => a.startsWith('--scope='));
26
26
  const scope = scopeArg ? scopeArg.slice('--scope='.length) : 'full';
27
27
 
28
+ // Phase 11: --auto-attach-source opts into source-edit injection in non-
29
+ // interactive contexts (CI). Default off — auto-rewriting source files
30
+ // without consent is too aggressive. Interactive runs always show Y/N.
31
+ const autoAttachSource = args.includes('--auto-attach-source');
32
+
28
33
  // Treat piped/CI runs (no TTY on stdin OR stdout) as non-interactive so the
29
34
  // wizard never blocks on a prompt that can't be answered. The interactive
30
35
  // menu is only opened when both stdin and stdout are real terminals.
31
36
  const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
32
37
  const nonInteractive = explicitNonInteractive || !hasTty;
33
38
 
34
- runWizard({ nonInteractive, target, scope }).catch((err) => {
39
+ runWizard({ nonInteractive, target, scope, autoAttachSource }).catch((err) => {
35
40
  console.error(`\n ✗ Setup failed: ${err.message}\n`);
36
41
  process.exit(1);
37
42
  });
@@ -3,10 +3,17 @@ import { join } from 'node:path';
3
3
  import { select } from '@inquirer/prompts';
4
4
  import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
5
5
  import { configureClaudeCode, configureCursor } from './tool-config.js';
6
- import { header, info, success, warn, blank } from './ui.js';
6
+ import { header, info, success, warn, blank, confirm } from './ui.js';
7
7
  import { readConfig } from './config.mjs';
8
8
  import { installNodeShim } from './install-node-shim.js';
9
9
  import { installPythonShim } from './install-python-shim.js';
10
+ import {
11
+ detectEntryFile,
12
+ hasSourceMarker,
13
+ writeSourceMarker,
14
+ previewInjection,
15
+ pathForTelemetry,
16
+ } from './source-edit-attach.js';
10
17
 
11
18
  const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
12
19
 
@@ -89,7 +96,143 @@ async function emitPathChosen(path) {
89
96
  }
90
97
  }
91
98
 
92
- async function showJsPath() {
99
+ async function emitNodeEntryPatched(payload) {
100
+ const config = readConfig();
101
+ if (!config.api_key) return;
102
+ try {
103
+ await fetch(`${PLATFORM_URL}/v1/telemetry`, {
104
+ method: 'POST',
105
+ headers: {
106
+ Authorization: `Bearer ${config.api_key}`,
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify({
110
+ product: 'cli',
111
+ event_type: 'node_entry_patched',
112
+ payload: { ...payload, platform: process.platform },
113
+ }),
114
+ signal: AbortSignal.timeout(5_000),
115
+ });
116
+ } catch {
117
+ // Best-effort — never let telemetry break the install path.
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Phase 11 — offer to inject `require('@robot-resources/router/auto')`
123
+ * (or ESM equivalent) at the top of the user's entry file.
124
+ *
125
+ * Why this is here: NODE_OPTIONS in shell config never reaches cron /
126
+ * Docker / systemd / Lambda agents because those launchers don't read
127
+ * shell rc files. A line in the source survives any launcher.
128
+ *
129
+ * Gates:
130
+ * - No package.json or no detectable entry → skip with a hint
131
+ * - Marker already present → skip silently
132
+ * - Non-interactive without `autoAttachSource` → skip with a hint;
133
+ * auto-rewriting source files in CI without consent is too aggressive
134
+ * - Y/N declined → skip silently, user can always re-run
135
+ */
136
+ async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = process.cwd() } = {}) {
137
+ const detection = detectEntryFile(cwd);
138
+ if (!detection.path) {
139
+ info('No agent entry file detected — skipping source-attach step.');
140
+ info('Re-run from your project root, or add this line yourself at the top of your entry file:');
141
+ info(" require('@robot-resources/router/auto'); // CJS");
142
+ info(" // or: import '@robot-resources/router/auto'; // ESM/TS");
143
+ await emitNodeEntryPatched({ outcome: 'no_entry_detected' });
144
+ return;
145
+ }
146
+
147
+ if (hasSourceMarker(detection.path)) {
148
+ info(`${pathForTelemetry(detection.path, cwd)} already has the auto-attach line — leaving it as-is.`);
149
+ await emitNodeEntryPatched({
150
+ outcome: 'already_present',
151
+ entry_path: pathForTelemetry(detection.path, cwd),
152
+ });
153
+ return;
154
+ }
155
+
156
+ // Non-interactive: skip unless explicit opt-in. Source files are sacred.
157
+ if (nonInteractive && !autoAttachSource) {
158
+ blank();
159
+ info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
160
+ info(`To enable it in CI, re-run with: npx robot-resources --for=langchain --auto-attach-source`);
161
+ info(`Or add this line manually at the top of ${pathForTelemetry(detection.path, cwd)}:`);
162
+ info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
163
+ await emitNodeEntryPatched({
164
+ outcome: 'skipped_non_interactive',
165
+ entry_path: pathForTelemetry(detection.path, cwd),
166
+ });
167
+ return;
168
+ }
169
+
170
+ // Multi-candidate ambiguity → ask which entry the user actually runs.
171
+ let target = detection.path;
172
+ if (!nonInteractive && detection.candidates.length > 1) {
173
+ const choices = detection.candidates.map((c) => ({
174
+ name: pathForTelemetry(c, cwd),
175
+ value: c,
176
+ }));
177
+ choices.push({ name: 'Skip — I\'ll add the line manually', value: '__skip__' });
178
+ target = await select({
179
+ message: 'Multiple entry candidates found. Which file runs your agent?',
180
+ default: detection.path,
181
+ choices,
182
+ });
183
+ if (target === '__skip__') {
184
+ await emitNodeEntryPatched({ outcome: 'declined_ambiguous_pick' });
185
+ return;
186
+ }
187
+ }
188
+
189
+ // Show the diff and ask Y/N (unless auto-attach is explicitly opt-in).
190
+ const preview = previewInjection(target);
191
+ blank();
192
+ success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
193
+ blank();
194
+ info(`File: ${pathForTelemetry(target, cwd)}`);
195
+ blank();
196
+ for (const line of preview.split('\n')) info(line);
197
+ blank();
198
+
199
+ let proceed = autoAttachSource;
200
+ if (!nonInteractive && !autoAttachSource) {
201
+ proceed = await confirm('Add the line?', { defaultYes: true });
202
+ }
203
+
204
+ if (!proceed) {
205
+ info('Skipped — you can re-run anytime.');
206
+ await emitNodeEntryPatched({
207
+ outcome: 'declined',
208
+ entry_path: pathForTelemetry(target, cwd),
209
+ });
210
+ return;
211
+ }
212
+
213
+ const result = writeSourceMarker(target);
214
+ if (result.ok) {
215
+ success(`Added the auto-attach line to ${pathForTelemetry(target, cwd)} (${result.syntax}).`);
216
+ if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
217
+ info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
218
+ await emitNodeEntryPatched({
219
+ outcome: 'patched',
220
+ entry_path: pathForTelemetry(target, cwd),
221
+ import_syntax: result.syntax,
222
+ backup_written: !!result.backupWritten,
223
+ });
224
+ } else {
225
+ warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
226
+ info('You can add the line yourself — see the snippet above.');
227
+ await emitNodeEntryPatched({
228
+ outcome: 'failed',
229
+ entry_path: pathForTelemetry(target, cwd),
230
+ error: result.error,
231
+ });
232
+ }
233
+ }
234
+
235
+ async function showJsPath({ nonInteractive = false, autoAttachSource = false } = {}) {
93
236
  blank();
94
237
  success('JS/TS integration');
95
238
  blank();
@@ -105,14 +248,10 @@ async function showJsPath() {
105
248
  }
106
249
  }
107
250
  blank();
108
- info('Once your shell picks up the new NODE_OPTIONS, every Node agent on');
109
- info('this machine routes Anthropic, OpenAI, and Google SDK calls through');
110
- info('Robot Resources.');
111
- if (process.platform === 'win32') {
112
- info('Open a new cmd / PowerShell window — current terminals will not see the change.');
113
- } else {
114
- info('Open a new terminal — or run: source ~/.zshrc (or your shell rc)');
115
- }
251
+ info('Phase 3+ NODE_OPTIONS shim installed. Works for desktop dev sessions');
252
+ info('after a terminal restart, but does NOT reach cron / Docker / systemd /');
253
+ info('Lambda. The next step adds a one-line require to your agent source so');
254
+ info('routing works regardless of how the process is launched.');
116
255
  } else {
117
256
  warn(result.message);
118
257
  blank();
@@ -124,6 +263,11 @@ async function showJsPath() {
124
263
  info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require ~/.robot-resources/router/auto.cjs"');
125
264
  }
126
265
  }
266
+
267
+ // Phase 11 — source-edit injection. Reaches cron / Docker / systemd / Lambda
268
+ // since the line lives in the user's source, not in shell config.
269
+ await maybeInjectSourceEdit({ nonInteractive, autoAttachSource });
270
+
127
271
  blank();
128
272
  info('Docs: https://robotresources.ai/docs/langchain');
129
273
  blank();
@@ -204,9 +348,9 @@ function showInstallOcPath() {
204
348
  blank();
205
349
  }
206
350
 
207
- async function runPath(path) {
351
+ async function runPath(path, opts = {}) {
208
352
  switch (path) {
209
- case 'js': await showJsPath(); break;
353
+ case 'js': await showJsPath(opts); break;
210
354
  case 'python': await showPythonPath(); break;
211
355
  case 'mcp': showMcpPath(); break;
212
356
  case 'docs': showDocsPath(); break;
@@ -221,11 +365,11 @@ async function runPath(path) {
221
365
  * - non-interactive AND no target: print hint with --for= options and exit
222
366
  * - interactive: 5-option menu via @inquirer/prompts.select
223
367
  */
224
- export async function runNonOcWizard({ nonInteractive = false, target = null } = {}) {
368
+ export async function runNonOcWizard({ nonInteractive = false, target = null, autoAttachSource = false } = {}) {
225
369
  const normalized = normalizeTarget(target);
226
370
 
227
371
  if (normalized) {
228
- await runPath(normalized);
372
+ await runPath(normalized, { nonInteractive, autoAttachSource });
229
373
  await emitPathChosen(normalized);
230
374
  return;
231
375
  }
@@ -249,7 +393,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
249
393
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
250
394
  info(' Pass --for=<other> to override, or --uninstall to remove later.');
251
395
  blank();
252
- await runPath(autoTarget);
396
+ await runPath(autoTarget, { nonInteractive, autoAttachSource });
253
397
  await emitPathChosen(autoTarget);
254
398
  return;
255
399
  }
@@ -334,7 +478,9 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
334
478
  if (autoTarget) {
335
479
  info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
336
480
  blank();
337
- await runPath(autoTarget);
481
+ // Treat the timed-out interactive session as non-interactive for the
482
+ // source-edit step too — we don't have a TTY to ask the Y/N on.
483
+ await runPath(autoTarget, { nonInteractive: true, autoAttachSource });
338
484
  await emitPathChosen(autoTarget);
339
485
  return;
340
486
  }
@@ -347,6 +493,6 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
347
493
  return;
348
494
  }
349
495
 
350
- await runPath(chosen);
496
+ await runPath(chosen, { nonInteractive, autoAttachSource });
351
497
  await emitPathChosen(chosen);
352
498
  }
@@ -0,0 +1,335 @@
1
+ import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from 'node:fs';
2
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+
4
+ /**
5
+ * Source-edit injection for non-OC Node agents (Phase 11).
6
+ *
7
+ * The Phase-3 NODE_OPTIONS approach only reaches processes that inherit
8
+ * from a shell that loaded ~/.bashrc or ~/.zshrc. Cron / Docker / systemd /
9
+ * Lambda / Cloud Run / serverless functions do NOT inherit shell rc files,
10
+ * so the shim never loads in the agents that matter most. Funnel data
11
+ * showed a 45-point cliff: 53% reach `node_shim_installed`, only 8.6% ever
12
+ * emit `adapter_attached`.
13
+ *
14
+ * This module injects ONE LINE at the top of the user's entry source file:
15
+ *
16
+ * // >>> robot-resources: auto-attach >>>
17
+ * require('@robot-resources/router/auto');
18
+ * // <<< robot-resources <<<
19
+ *
20
+ * (or `import '@robot-resources/router/auto';` for ESM/TS files.)
21
+ *
22
+ * The line runs whenever the agent process starts, regardless of how it
23
+ * was launched. Cron, Docker, systemd, Lambda — all work the same.
24
+ *
25
+ * Marker-block convention mirrors shell-config.js exactly so an `--uninstall`
26
+ * remove pass is text-based and never destructive on partial state.
27
+ *
28
+ * Backup: a `.rr-backup` is written once on first inject (never overwritten
29
+ * on re-inject) so `--uninstall --purge` can restore the original source
30
+ * even if the marker block was tampered with.
31
+ */
32
+
33
+ export const MARK_BEGIN = '// >>> robot-resources: auto-attach >>>';
34
+ export const MARK_END = '// <<< robot-resources <<<';
35
+
36
+ const REQUIRE_LINE = "require('@robot-resources/router/auto');";
37
+ const IMPORT_LINE = "import '@robot-resources/router/auto';";
38
+
39
+ // File extensions we'll treat as Node source — anything else, refuse to
40
+ // edit. Keeps us out of build artifacts (.min.js), declaration files (.d.ts),
41
+ // JSON, etc.
42
+ const VALID_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']);
43
+
44
+ /**
45
+ * Decide which import syntax to inject for a given source file.
46
+ * Implements Node's resolution rules verbatim:
47
+ * .cjs → cjs
48
+ * .mjs → esm
49
+ * .ts / .tsx → emit `import` (works under both ts-node CJS and tsx/ESM)
50
+ * .js / .jsx → walk up to nearest package.json, check "type" field.
51
+ * "module" → esm; anything else (or absent) → cjs.
52
+ */
53
+ export function detectImportSyntax(entryPath) {
54
+ const ext = extname(entryPath).toLowerCase();
55
+ if (ext === '.cjs') return 'cjs';
56
+ if (ext === '.mjs') return 'esm';
57
+ if (ext === '.ts' || ext === '.tsx') return 'esm';
58
+
59
+ // Walk up from the file's directory until we hit a package.json or
60
+ // exhaust the path. This is what Node itself does for `.js` files.
61
+ let dir = dirname(resolve(entryPath));
62
+ while (true) {
63
+ const pkgPath = join(dir, 'package.json');
64
+ if (existsSync(pkgPath)) {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
67
+ return pkg.type === 'module' ? 'esm' : 'cjs';
68
+ } catch {
69
+ return 'cjs';
70
+ }
71
+ }
72
+ const parent = dirname(dir);
73
+ if (parent === dir) return 'cjs';
74
+ dir = parent;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Locate the user's agent entry file under cwd. Staged fallback through
80
+ * standard package.json fields, then a convention scan.
81
+ *
82
+ * Returns { path, candidates }:
83
+ * - path: absolute path to the chosen entry, or null if nothing matched
84
+ * - candidates: every viable candidate found (used by the wizard to
85
+ * show a select() prompt when ambiguous)
86
+ *
87
+ * Priority (first match wins for `path`, all viable hits go in `candidates`):
88
+ * 1. package.json `bin` — single-string or single-key object → entry
89
+ * 2. package.json `main`
90
+ * 3. package.json `exports["."]` (or `exports["."].default`)
91
+ * 4. parsed package.json `scripts.start` — regex against
92
+ * `(node|tsx|ts-node|bun) <file>`
93
+ * 5. convention scan: src/index.{ts,js,mjs,cjs}, index.{ts,js,mjs,cjs},
94
+ * src/agent.{ts,js}, agent.{ts,js}, src/main.{ts,js}, main.{ts,js},
95
+ * src/app.{ts,js}, app.{ts,js}, src/bot.{ts,js}, bot.{ts,js}
96
+ */
97
+ export function detectEntryFile(cwd = process.cwd()) {
98
+ const candidates = [];
99
+ const pkgPath = join(cwd, 'package.json');
100
+ let pkg = null;
101
+ if (existsSync(pkgPath)) {
102
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { /* malformed */ }
103
+ }
104
+
105
+ if (pkg) {
106
+ // Tier 1: bin
107
+ if (typeof pkg.bin === 'string') {
108
+ addCandidate(candidates, cwd, pkg.bin);
109
+ } else if (pkg.bin && typeof pkg.bin === 'object') {
110
+ for (const v of Object.values(pkg.bin)) {
111
+ if (typeof v === 'string') addCandidate(candidates, cwd, v);
112
+ }
113
+ }
114
+
115
+ // Tier 2: main
116
+ if (typeof pkg.main === 'string') addCandidate(candidates, cwd, pkg.main);
117
+
118
+ // Tier 3: exports["."]
119
+ const root = pkg.exports?.['.'] ?? pkg.exports;
120
+ if (typeof root === 'string') addCandidate(candidates, cwd, root);
121
+ else if (root && typeof root === 'object') {
122
+ const dflt = root.default ?? root.import ?? root.require;
123
+ if (typeof dflt === 'string') addCandidate(candidates, cwd, dflt);
124
+ }
125
+
126
+ // Tier 4: scripts.start parse
127
+ if (typeof pkg.scripts?.start === 'string') {
128
+ // Match `node ./foo.js`, `tsx src/agent.ts`, `bun run agent.ts` etc.
129
+ const m = pkg.scripts.start.match(/(?:node|tsx|ts-node|bun(?:\s+run)?)\s+(\S+)/);
130
+ if (m) addCandidate(candidates, cwd, m[1]);
131
+ }
132
+ }
133
+
134
+ // Tier 5: convention scan — only adds if the candidate file actually exists.
135
+ const conventions = [
136
+ 'src/index.ts', 'src/index.js', 'src/index.mjs', 'src/index.cjs',
137
+ 'index.ts', 'index.js', 'index.mjs', 'index.cjs',
138
+ 'src/agent.ts', 'src/agent.js', 'agent.ts', 'agent.js',
139
+ 'src/main.ts', 'src/main.js', 'main.ts', 'main.js',
140
+ 'src/app.ts', 'src/app.js', 'app.ts', 'app.js',
141
+ 'src/bot.ts', 'src/bot.js', 'bot.ts', 'bot.js',
142
+ ];
143
+ for (const rel of conventions) {
144
+ const abs = join(cwd, rel);
145
+ if (existsSync(abs) && !candidates.includes(abs)) candidates.push(abs);
146
+ }
147
+
148
+ // Filter to files that exist and have a valid extension. The package.json
149
+ // tiers might point at files that don't exist yet (e.g. a `main` of
150
+ // `dist/index.js` before the user has built). Don't try to inject into
151
+ // missing files.
152
+ const viable = candidates.filter((c) => existsSync(c) && VALID_EXTS.has(extname(c).toLowerCase()));
153
+
154
+ return { path: viable[0] ?? null, candidates: viable };
155
+ }
156
+
157
+ function addCandidate(arr, cwd, rel) {
158
+ // Strip leading ./, normalize, resolve relative to cwd.
159
+ const cleaned = rel.replace(/^\.\//, '');
160
+ const abs = join(cwd, cleaned);
161
+ if (!arr.includes(abs)) arr.push(abs);
162
+ }
163
+
164
+ /**
165
+ * Returns true if the given source file already contains our marker.
166
+ * Used to skip-if-already-installed and to gate uninstall.
167
+ */
168
+ export function hasSourceMarker(filePath) {
169
+ try {
170
+ return readFileSync(filePath, 'utf-8').includes(MARK_BEGIN);
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Idempotently inject the auto-attach marker block at the top of the file
178
+ * (after shebang, if present). Writes a one-time backup at `${filePath}.rr-backup`.
179
+ *
180
+ * Returns { ok, alreadyInstalled, path, syntax, backupPath, error }.
181
+ */
182
+ export function writeSourceMarker(filePath, opts = {}) {
183
+ const syntax = opts.syntax ?? detectImportSyntax(filePath);
184
+
185
+ let original;
186
+ try { original = readFileSync(filePath, 'utf-8'); }
187
+ catch (err) {
188
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `read_failed: ${err.message}` };
189
+ }
190
+
191
+ if (original.includes(MARK_BEGIN)) {
192
+ return { ok: true, alreadyInstalled: true, path: filePath, syntax, backupPath: backupPathFor(filePath) };
193
+ }
194
+
195
+ // Backup once. Never overwrite — preserves the user's pristine original
196
+ // even if they manually edit before our second pass.
197
+ const backupPath = backupPathFor(filePath);
198
+ let backupWritten = false;
199
+ if (!existsSync(backupPath)) {
200
+ try {
201
+ writeFileSync(backupPath, original, { mode: getMode(filePath) });
202
+ backupWritten = true;
203
+ } catch (err) {
204
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `backup_failed: ${err.message}` };
205
+ }
206
+ }
207
+
208
+ // Insert position: after shebang line if present, otherwise at top.
209
+ // We do NOT try to skip past `"use strict";` — modern Node treats it as
210
+ // a normal directive; placing our require/import before it is harmless.
211
+ const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
212
+ const block = `${MARK_BEGIN}\n${line}\n${MARK_END}\n`;
213
+
214
+ let next;
215
+ if (original.startsWith('#!')) {
216
+ const nl = original.indexOf('\n');
217
+ if (nl === -1) {
218
+ // single-line file, only a shebang — append our block on a new line
219
+ next = original + '\n' + block;
220
+ } else {
221
+ next = original.slice(0, nl + 1) + block + original.slice(nl + 1);
222
+ }
223
+ } else {
224
+ next = block + original;
225
+ }
226
+
227
+ try {
228
+ writeFileSync(filePath, next, { mode: getMode(filePath) });
229
+ } catch (err) {
230
+ return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `write_failed: ${err.message}`, backupPath, backupWritten };
231
+ }
232
+
233
+ return { ok: true, alreadyInstalled: false, path: filePath, syntax, backupPath, backupWritten };
234
+ }
235
+
236
+ /**
237
+ * Idempotently remove the marker block from the source file. Mirror of
238
+ * writeSourceMarker. Returns { ok, removed, restored, path, error }.
239
+ *
240
+ * If `restoreFromBackup` is true and a `.rr-backup` exists, restores from
241
+ * the backup instead of splicing. Used for `--purge`. The backup is
242
+ * deleted after a successful restore.
243
+ */
244
+ export function removeSourceMarker(filePath, { restoreFromBackup = false } = {}) {
245
+ let original;
246
+ try { original = readFileSync(filePath, 'utf-8'); }
247
+ catch (err) {
248
+ return { ok: false, removed: false, restored: false, path: filePath, error: `read_failed: ${err.message}` };
249
+ }
250
+
251
+ if (restoreFromBackup) {
252
+ const backupPath = backupPathFor(filePath);
253
+ if (existsSync(backupPath)) {
254
+ try {
255
+ const pristine = readFileSync(backupPath, 'utf-8');
256
+ writeFileSync(filePath, pristine, { mode: getMode(filePath) });
257
+ unlinkSync(backupPath);
258
+ return { ok: true, removed: true, restored: true, path: filePath };
259
+ } catch (err) {
260
+ return { ok: false, removed: false, restored: false, path: filePath, error: `restore_failed: ${err.message}` };
261
+ }
262
+ }
263
+ // No backup → fall through to marker splice.
264
+ }
265
+
266
+ const startIdx = original.indexOf(MARK_BEGIN);
267
+ if (startIdx === -1) {
268
+ return { ok: true, removed: false, restored: false, path: filePath };
269
+ }
270
+ const endIdx = original.indexOf(MARK_END, startIdx);
271
+ if (endIdx === -1) {
272
+ return { ok: false, removed: false, restored: false, path: filePath, error: 'marker_end_missing' };
273
+ }
274
+
275
+ // Splice from MARK_BEGIN through end of MARK_END line + the trailing
276
+ // newline our writer added. Walk backward over leading newlines so
277
+ // repeated install/uninstall cycles don't accumulate blanks.
278
+ const afterEnd = original.indexOf('\n', endIdx);
279
+ const sliceEnd = afterEnd === -1 ? original.length : afterEnd + 1;
280
+
281
+ let sliceStart = startIdx;
282
+ while (sliceStart > 0 && original[sliceStart - 1] === '\n') sliceStart--;
283
+
284
+ const next = original.slice(0, sliceStart) +
285
+ (sliceStart > 0 ? '\n' : '') +
286
+ original.slice(sliceEnd);
287
+
288
+ try {
289
+ writeFileSync(filePath, next, { mode: getMode(filePath) });
290
+ } catch (err) {
291
+ return { ok: false, removed: false, restored: false, path: filePath, error: `write_failed: ${err.message}` };
292
+ }
293
+
294
+ return { ok: true, removed: true, restored: false, path: filePath };
295
+ }
296
+
297
+ /**
298
+ * Build the proposed diff a wizard can show before asking Y/N. Returns a
299
+ * UI-friendly string with the marker block + a few lines of context.
300
+ */
301
+ export function previewInjection(filePath, opts = {}) {
302
+ const syntax = opts.syntax ?? detectImportSyntax(filePath);
303
+ const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
304
+ let original = '';
305
+ try { original = readFileSync(filePath, 'utf-8'); } catch { /* ignore */ }
306
+ const firstLines = original.split('\n').slice(0, 3).join('\n');
307
+ return [
308
+ `+ ${MARK_BEGIN}`,
309
+ `+ ${line}`,
310
+ `+ ${MARK_END}`,
311
+ firstLines.split('\n').map((l) => ` ${l}`).join('\n'),
312
+ ].join('\n');
313
+ }
314
+
315
+ function backupPathFor(filePath) {
316
+ return `${filePath}.rr-backup`;
317
+ }
318
+
319
+ function getMode(filePath) {
320
+ try { return statSync(filePath).mode & 0o777; } catch { return 0o644; }
321
+ }
322
+
323
+ /**
324
+ * Best-effort cwd-relative path for telemetry, falling back to basename
325
+ * if the file is outside cwd. Used to avoid leaking absolute paths (which
326
+ * can contain usernames) into Supabase.
327
+ */
328
+ export function pathForTelemetry(filePath, cwd = process.cwd()) {
329
+ const rel = relative(cwd, filePath);
330
+ if (rel.startsWith('..') || rel.includes(sep + '..' + sep)) {
331
+ // Outside cwd — basename only.
332
+ return filePath.split(sep).pop();
333
+ }
334
+ return rel;
335
+ }
package/lib/uninstall.js CHANGED
@@ -6,6 +6,7 @@ import { removeShellLine } from './shell-config.js';
6
6
  import { detectVenv } from './venv-detect.js';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { removePersistedNodeOptions } from './windows-env.js';
9
+ import { detectEntryFile, hasSourceMarker, removeSourceMarker } from './source-edit-attach.js';
9
10
 
10
11
  /**
11
12
  * Single source of truth for `npx robot-resources --uninstall`.
@@ -115,6 +116,36 @@ export function runUninstall({ purge = false } = {}) {
115
116
  }
116
117
  }
117
118
 
119
+ // 3a. Phase 11 — source-edit auto-attach line. If the wizard injected
120
+ // `require('@robot-resources/router/auto')` at the top of the user's
121
+ // entry file, peel the marker block out (or restore from .rr-backup
122
+ // when --purge). Idempotent: no-op when no entry detected or marker
123
+ // not present. Runs against cwd, so users who --uninstall from a
124
+ // different repo won't accidentally wipe the wrong file.
125
+ try {
126
+ const detection = detectEntryFile();
127
+ if (detection.path && hasSourceMarker(detection.path)) {
128
+ const r = removeSourceMarker(detection.path, { restoreFromBackup: !!purge });
129
+ if (r.ok && (r.removed || r.restored)) {
130
+ components_removed.push(r.restored ? 'node_entry_restored_from_backup' : 'node_entry_marker_removed');
131
+ } else if (!r.ok) {
132
+ errors.push({ component: 'node_entry_source_edit', message: r.error || 'unknown' });
133
+ }
134
+ }
135
+ // --purge also wipes any leftover .rr-backup (covers the case where the
136
+ // user manually deleted the marker but kept the backup file around).
137
+ if (purge && detection.path && existsSync(`${detection.path}.rr-backup`)) {
138
+ try {
139
+ rmSync(`${detection.path}.rr-backup`, { force: true });
140
+ components_removed.push('node_entry_backup_purged');
141
+ } catch (err) {
142
+ errors.push({ component: 'node_entry_backup_purged', message: err.message });
143
+ }
144
+ }
145
+ } catch (err) {
146
+ errors.push({ component: 'node_entry_source_edit', message: err.message });
147
+ }
148
+
118
149
  // 3b. Copied router dir at ~/.robot-resources/router/ (Phase 8). The shell
119
150
  // line points at this absolute path — once the line is gone, the
120
151
  // copied files are dead weight. Remove them.
package/lib/wizard.js CHANGED
@@ -26,6 +26,43 @@ const CLI_VERSION = (() => {
26
26
  }
27
27
  })();
28
28
 
29
+ /**
30
+ * Fire `install_complete` once after the wizard finishes — for BOTH OC
31
+ * and non-OC paths. Phase 11 fix: previously this only fired on the OC
32
+ * path because the non-OC branch returned early. The 7-day funnel showed
33
+ * 0/58 non-OC users hitting this event for a week. Now both paths fire
34
+ * with a `path: 'oc' | 'non-oc'` discriminator so funnel queries can
35
+ * segment without a second event type.
36
+ *
37
+ * Best-effort with one retry. Total budget: ~20s. Telemetry never blocks
38
+ * the wizard exit beyond that.
39
+ */
40
+ async function emitInstallComplete({ apiKey, payload }) {
41
+ if (!apiKey) return;
42
+ const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
43
+ const body = JSON.stringify({
44
+ product: 'cli',
45
+ event_type: 'install_complete',
46
+ payload,
47
+ });
48
+ for (let attempt = 0; attempt < 2; attempt++) {
49
+ try {
50
+ const res = await fetch(`${platformUrl}/v1/telemetry`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Authorization': `Bearer ${apiKey}`,
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body,
57
+ signal: AbortSignal.timeout(10_000),
58
+ });
59
+ if (res.ok) break;
60
+ } catch {
61
+ // Try again on next iteration.
62
+ }
63
+ }
64
+ }
65
+
29
66
  /**
30
67
  * Main setup wizard. In Option 4 (post-PR-2.5) the wizard does NOT install
31
68
  * a Python daemon, register a system service, or run a localhost health
@@ -43,7 +80,7 @@ const CLI_VERSION = (() => {
43
80
  *
44
81
  * No Python, no venv, no systemd, no port probe.
45
82
  */
46
- export async function runWizard({ nonInteractive = false, target = null, scope = 'full' } = {}) {
83
+ export async function runWizard({ nonInteractive = false, target = null, scope = 'full', autoAttachSource = false } = {}) {
47
84
  header();
48
85
 
49
86
  // Detect OC once up front. Used both to branch into the non-OC wizard and
@@ -165,7 +202,31 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
165
202
  // MCP config / docs / install-OC). The non-OC wizard's wizard_path_chosen
166
203
  // telemetry now fires too, since Step 0 above provisioned an api_key.
167
204
  if (!openclawDetected) {
168
- await runNonOcWizard({ nonInteractive, target });
205
+ await runNonOcWizard({ nonInteractive, target, autoAttachSource });
206
+
207
+ // Phase 11: install_complete now fires for non-OC too. Closes the
208
+ // funnel signal that was 0/58 for a week.
209
+ if (results.auth) {
210
+ try {
211
+ const config = readConfig();
212
+ await emitInstallComplete({
213
+ apiKey: config.api_key,
214
+ payload: {
215
+ source: 'wizard',
216
+ path: 'non-oc',
217
+ cli_version: CLI_VERSION,
218
+ target: target ?? null,
219
+ scope,
220
+ auto_attach_source: !!autoAttachSource,
221
+ platform: process.platform,
222
+ os_release: osRelease(),
223
+ node_version: process.version,
224
+ install_duration_ms: Date.now() - wizardStartMs,
225
+ non_interactive: nonInteractive,
226
+ },
227
+ });
228
+ } catch { /* non-fatal */ }
229
+ }
169
230
  return;
170
231
  }
171
232
 
@@ -250,54 +311,32 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
250
311
 
251
312
  // ── Install Complete Telemetry ───────────────────────────────────────────
252
313
  //
253
- // Fire once after install, using the API key directly (not from config read-back).
254
- // This immediately populates last_used_at and proves the key works end-to-end.
255
- //
256
- // Retry once with longer timeout — Cloudflare analytics showed client-side
257
- // aborts on the original 5s single-attempt, leaving stranded signups with
258
- // no telemetry. Two 10s attempts catch the long tail. Still fire-and-forget.
314
+ // Fire once after the OC install path completes. Non-OC fires its own
315
+ // install_complete higher up (right after runNonOcWizard returns), so
316
+ // both paths now produce this funnel signal — see emitInstallComplete().
259
317
 
260
318
  if (results.auth) {
261
319
  try {
262
320
  const config = readConfig();
263
- const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
264
- const installPayload = {
265
- source: 'wizard',
266
- cli_version: CLI_VERSION,
267
- plugin_installed: results.pluginInstalled,
268
- scraper: results.scraper || false,
269
- platform: process.platform,
270
- os_release: osRelease(),
271
- node_version: process.version,
272
- install_duration_ms: Date.now() - wizardStartMs,
273
- openclaw_detected: results.openclawDetected,
274
- openclaw_config_patched: results.openclawConfigPatched,
275
- scraper_mcp_registered: results.scraperMcpRegistered,
276
- };
277
- const body = JSON.stringify({
278
- product: 'cli',
279
- event_type: 'install_complete',
280
- payload: installPayload,
321
+ await emitInstallComplete({
322
+ apiKey: config.api_key,
323
+ payload: {
324
+ source: 'wizard',
325
+ path: 'oc',
326
+ cli_version: CLI_VERSION,
327
+ plugin_installed: results.pluginInstalled,
328
+ scraper: results.scraper || false,
329
+ platform: process.platform,
330
+ os_release: osRelease(),
331
+ node_version: process.version,
332
+ install_duration_ms: Date.now() - wizardStartMs,
333
+ openclaw_detected: results.openclawDetected,
334
+ openclaw_config_patched: results.openclawConfigPatched,
335
+ scraper_mcp_registered: results.scraperMcpRegistered,
336
+ },
281
337
  });
282
-
283
- for (let attempt = 0; attempt < 2; attempt++) {
284
- try {
285
- const res = await fetch(`${platformUrl}/v1/telemetry`, {
286
- method: 'POST',
287
- headers: {
288
- 'Authorization': `Bearer ${config.api_key}`,
289
- 'Content-Type': 'application/json',
290
- },
291
- body,
292
- signal: AbortSignal.timeout(10_000),
293
- });
294
- if (res.ok) break;
295
- } catch {
296
- // Try again on next iteration; outer catch handles total failure
297
- }
298
- }
299
338
  } catch {
300
- // Non-fatal — install_complete is best-effort
339
+ // Non-fatal — install_complete is best-effort.
301
340
  }
302
341
  }
303
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {