ohmyjev 0.7.1 → 0.8.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
@@ -77,6 +77,7 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
77
77
  | **Router** | Asks Jev once per request for a tier, an effort level and a risk score. Then it raises or lowers effort and picks the model for general-purpose subagents. With no key it falls back to Claude Code's built-in classifier, which reports no confidence, so then it only ever routes up. |
78
78
  | **Auto-compact** | When the task changes after a finished step and the context is at least 40% full, it compacts the conversation. The summary keeps the new request in full. |
79
79
  | **`ask_jev`** | A tool the model can use to ask Jev about repo files or text without loading them into its own context. |
80
+ | **`browse`** | A tool that does a web task in a headless browser, with Jev picking each click and field: a Google Flights search in about 6 seconds. Off until you turn on `browse`. |
80
81
  | **`/omj`** | This session's Jev calls, errors, cost, median latency and denies, and where the key comes from. It never prints the key. `/omj config` lists and changes settings. `/omj stats` opens a dashboard of every session in your browser. `/omj off` and `/omj on` switch ohmyjev for the session. `/ohmyjev` is the same command. |
81
82
 
82
83
  ### How a tool call goes through it
@@ -212,7 +213,7 @@ pi has no settings screen for extensions, so put yours under an `ohmyjev` key in
212
213
  | Router effort | ✓ | none: hooks can't set it | ✓ (left alone while you're on `auto`) | ✓ |
213
214
  | Router subagent model | general-purpose subagents | none | subagents on the default `task` role | none: pi has no subagents |
214
215
  | Router without a key | Claude Code's built-in classifier, up only | none | off: omp has no separate classifier call for extensions | the cheapest model you've set up, up only |
215
- | `/omj`, `ask_jev` | ✓ | none: hooks can't add commands or tools | ✓ | ✓ |
216
+ | `/omj`, `ask_jev`, `browse` | ✓ | none: hooks can't add commands or tools | ✓ | ✓ |
216
217
  | Decision lines | in the transcript | the log only | notifications | notifications |
217
218
  | Status | under the prompt | none | in the footer | in the footer |
218
219
 
@@ -275,6 +276,31 @@ The model can call this tool on its own when it needs a judgment about files wit
275
276
  Jev reads the files, not the model. ohmyjev only sends files that are inside the repo, up to 8000 characters each and
276
277
  80000 in total.
277
278
 
279
+ ### `browse`
280
+
281
+ Turn it on with `/omj config browse on`, or `browse` in the settings. The model then has a `browse` tool for tasks on a
282
+ website: give it a starting URL, one narrow goal, and the text it may type.
283
+
284
+ ```json
285
+ {
286
+ "url": "https://www.google.com/travel/flights?hl=en",
287
+ "goal": "Find one-way flights from Zurich to London on November 20, 2026. Stop when flight options show.",
288
+ "values": ["Zurich", "London", "Nov 20, 2026"]
289
+ }
290
+ ```
291
+
292
+ Each step, Jev reads the page's controls and picks one action and its target in a single request, and when a field
293
+ needs text it picks the right entry from `values`. That run took 11 steps and 5.5 seconds: one-way, both cities, the
294
+ date, search. The tool returns where it ended, the steps and the page's visible text, and that text goes through the
295
+ injection screen like any tool output. The exfil gate checks the goal first, so a goal that would carry your files to a
296
+ website is denied before the browser opens.
297
+
298
+ It is [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast) (MIT), pinned to one commit, run through
299
+ [uv](https://docs.astral.sh/uv), so install uv once. It starts its own headless Chrome, Chromium, Brave or Edge with
300
+ a profile in `~/.ohmyjev/browser`, signed in to nothing, and closes it after the task. Set `CHROME_PATH` to choose the
301
+ browser. With `OPENROUTER_API_KEY` or `TEXT_MODEL_API_KEY` set, a small text model writes field text instead of picking
302
+ from `values`. Use WebFetch for a page the model only needs to read.
303
+
278
304
  ### In the transcript
279
305
 
280
306
  Each routing decision shows up as a dim line in the transcript. The model never sees these lines. First what Jev
@@ -353,6 +379,7 @@ one for the session. To keep a change, run `/plugin configure ohmyjev@ohmyjev` i
353
379
  | `routeWithoutKey` | on | With no key, routes with Claude Code's built-in classifier instead. It has no confidence, so it only routes up. |
354
380
  | `autoCompact` | on | Compacts when the task changes. `compactMinPercent` (40) sets how full the context has to be first. |
355
381
  | `askJev` | on | Gives the model the `ask_jev` tool. |
382
+ | `browse` | off | Gives the model the `browse` tool. Needs uv and a Chromium-based browser. |
356
383
  | `policies` | empty | Your own rules, separated by `;`. |
357
384
  | `allowPaths` | `~/.claude;$TMPDIR;/tmp` | Places outside the repo where writes are allowed. ohmyjev ignores any entry with `..` in it. You don't need it for a sibling worktree or another git repo the agent `cd`s into: those count as the repo. |
358
385
  | `fastModel`, `balancedModel`, `deepModel` | `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` | The model id for each router tier. |
@@ -384,6 +411,7 @@ a fixed size:
384
411
  | Done-check | The current request (600), up to five earlier requests (200 each), up to 20 of this turn's tool calls with their input (200 each) and outcome, and Claude's last message (1500) |
385
412
  | Router | The request (up to 1500 characters) |
386
413
  | `ask_jev` | The model's question, any text it passes (up to 20000 characters) and the repo files it names (8000 each, 80000 in total). It never sends files outside the repo |
414
+ | `browse` | The goal and URL to the exfil gate; then, each step, the page's controls and visible text (6000) and the goal to Jev, and the page's text to the injection screen. The websites see the browser like any visitor |
387
415
 
388
416
  The write and exfil gates also send your latest message (1500), for the request check. When you've set `policies`,
389
417
  every gate sends those too. Turning a feature off stops its calls.
@@ -440,4 +468,5 @@ The design and plans are in [`docs/superpowers`](docs/superpowers).
440
468
 
441
469
  ## Credits
442
470
 
443
- The question rubrics come from [disler/ten-levels-of-jev](https://github.com/disler/ten-levels-of-jev). MIT license.
471
+ The question rubrics come from [disler/ten-levels-of-jev](https://github.com/disler/ten-levels-of-jev). `browse` runs
472
+ [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast). Both MIT.
package/adapters/omp.ts CHANGED
@@ -5,12 +5,12 @@ import { readFile } from 'node:fs/promises'
5
5
  import {
6
6
  BASH_Q, DEFAULTS, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
7
7
  clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead, configCommand, applyPatchPaths,
8
- OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
8
+ BROWSE_DESCRIPTION, OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
9
9
  keepInstructions, modelOf, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
10
10
  type Config, type Route,
11
11
  } from '../hooks/policy.ts'
12
12
  import {
13
- ASK_DESCRIPTION, Session, answerAsk, keyOf, pathAllowed, repoRoot,
13
+ ASK_DESCRIPTION, Session, answerAsk, runBrowse, keyOf, pathAllowed, repoRoot,
14
14
  type Env, type Fetch,
15
15
  } from './shared.ts'
16
16
 
@@ -251,6 +251,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
251
251
  let compactPending = false
252
252
  let classifiedPrompt: string | undefined
253
253
  let askRegistered = false
254
+ let browseRegistered = false
254
255
  let sessionGeneration = 0
255
256
  let userEffort: string | undefined
256
257
  const routedThinkingEntries = new Set<string>()
@@ -423,6 +424,35 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
423
424
  })
424
425
  askRegistered = true
425
426
  }
427
+ if (c.browse && !browseRegistered) {
428
+ const t = pi.typebox.Type
429
+ pi.registerTool({
430
+ name: 'browse',
431
+ label: 'Browse',
432
+ description: BROWSE_DESCRIPTION,
433
+ parameters: t.Object({
434
+ url: t.String({ description: 'Where to start, an http(s) URL' }),
435
+ goal: t.String({ description: 'One narrow goal, and when to stop' }),
436
+ values: t.Optional(t.Array(t.String(), { description: 'Exact text the task may type; Jev puts each into the field that needs it' })),
437
+ }),
438
+ approval: 'exec',
439
+ async execute(_id, params, _signal, _update, toolCtx) {
440
+ try {
441
+ touch(toolCtx)
442
+ if (!session) return { content: [{ type: 'text', text: JSON.stringify({ error: 'jev session unavailable' }) }] }
443
+ const r = await runBrowse(session, params, userRequest, blockedBefore)
444
+ if ('deny' in r) {
445
+ lastBlocked = callSummary('browse', params)
446
+ return { content: [{ type: 'text', text: r.deny }], details: {}, isError: true }
447
+ }
448
+ return { content: [{ type: 'text', text: r.text }], details: {} }
449
+ } catch {
450
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'browse failed' }) }], details: {} }
451
+ }
452
+ },
453
+ })
454
+ browseRegistered = true
455
+ }
426
456
  }
427
457
 
428
458
  const reinitialize = async (ctx: HostContext): Promise<void> => {
package/adapters/pi.ts CHANGED
@@ -2,12 +2,12 @@ import { readFile } from 'node:fs/promises'
2
2
  import {
3
3
  BASH_Q, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
4
4
  TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead, configCommand,
5
- OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
5
+ BROWSE_DESCRIPTION, BROWSE_SCHEMA, OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
6
6
  keepInstructions, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
7
7
  type Config, type Route,
8
8
  } from '../hooks/policy.ts'
9
9
  import {
10
- ASK_DESCRIPTION, ASK_SCHEMA, Session, answerAsk, keyOf, pathAllowed, repoRoot,
10
+ ASK_DESCRIPTION, ASK_SCHEMA, Session, answerAsk, runBrowse, keyOf, pathAllowed, repoRoot,
11
11
  type Env, type Fetch,
12
12
  } from './shared.ts'
13
13
 
@@ -354,6 +354,29 @@ export function createExtension(options: PiAdapterOptions = {}) {
354
354
  })
355
355
  askRegistered = true
356
356
  }
357
+ let browseRegistered = false
358
+ const registerBrowse = (): void => {
359
+ if (browseRegistered || !c.browse) return
360
+ pi.registerTool({
361
+ name: 'browse',
362
+ label: 'Browse',
363
+ description: BROWSE_DESCRIPTION,
364
+ parameters: BROWSE_SCHEMA,
365
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
366
+ try {
367
+ const r = await runBrowse(await ensureSession(ctx), params, userRequest, blockedBefore)
368
+ if ('deny' in r) {
369
+ lastBlocked = callSummary('browse', params)
370
+ return { content: [{ type: 'text', text: r.deny }], details: undefined, isError: true }
371
+ }
372
+ return { content: [{ type: 'text', text: r.text }], details: undefined }
373
+ } catch {
374
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'browse failed' }) }], details: undefined }
375
+ }
376
+ },
377
+ })
378
+ browseRegistered = true
379
+ }
357
380
 
358
381
  /** /omj and /ohmyjev: the report, `settings`, or `on`/`off` for this session (the enabled setting is the default). */
359
382
  const command = async (args: string, ctx: PiContext): Promise<void> => {
@@ -389,6 +412,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
389
412
  userThinking = pi.getThinkingLevel()
390
413
  const x = await ensureSession(ctx)
391
414
  registerAsk()
415
+ registerBrowse()
392
416
  const key = keyOf(c, env)
393
417
  say(
394
418
  ctx,
@@ -8,7 +8,7 @@ import { appendFile, chmod, lstat, mkdir, readFile, readdir, realpath, writeFile
8
8
  import { dashboardHtml } from '../hooks/dashboard.ts'
9
9
  import { ENDPOINTS, JevError, parseReply, pickKey, type Key } from '../hooks/jev.ts'
10
10
  import {
11
- DASHBOARD, EMPTY_SESSION, absolute, buildQuestion, clip, expandRoot, hostPath, isUnder, normalize, parseLog, parseWorktrees, rawAbsolute, sanitizeSid,
11
+ BROWSE_TIMEOUT_MS, DASHBOARD, EMPTY_SESSION, EXFIL_Q, SCREEN_Q, browseCommand, browseReply, denyText, gateExfil, screen, withPolicies, withPolicyQ, absolute, buildQuestion, clip, expandRoot, hostPath, isUnder, normalize, parseLog, parseWorktrees, rawAbsolute, sanitizeSid,
12
12
  splitList, statusText, summarize, type Answers, type Config, type LogEntry, type Questions, type SessionState, type Verdict,
13
13
  } from '../hooks/policy.ts'
14
14
 
@@ -288,3 +288,48 @@ export async function answerAsk(x: Session, input: Record<string, unknown>, cwd:
288
288
  x.record(d, null, 'asked')
289
289
  return JSON.stringify(d.answers.answer)
290
290
  }
291
+
292
+ // --- browse: jev-ultrafast in the user's Chrome ---
293
+
294
+ /** Runs a process to completion: its stdout and stderr, whatever the exit. Tests pass a fake. */
295
+ export type Runner = (argv: string[], env: Record<string, string>, timeoutMs: number) => Promise<{ stdout: string; stderr: string }>
296
+ const execRunner: Runner = (argv, env, timeoutMs) => {
297
+ const { promise, resolve } = Promise.withResolvers<{ stdout: string; stderr: string }>()
298
+ execFile(argv[0]!, argv.slice(1), { env: { ...process.env, ...env }, timeout: timeoutMs, maxBuffer: 4 << 20 }, (err, stdout, stderr) =>
299
+ resolve({ stdout: String(stdout ?? ''), stderr: String(stderr ?? '') || (err ? String(err) : '') }))
300
+ return promise
301
+ }
302
+
303
+ /**
304
+ * browse for pi and omp: the exfil gate on the call (the user's request can lift it), jev-ultrafast, then the
305
+ * injection screen on what came back. The text for the model, or a deny.
306
+ */
307
+ export async function runBrowse(
308
+ x: Session,
309
+ input: Record<string, unknown>,
310
+ request = '',
311
+ blockedBefore = '',
312
+ run: Runner = execRunner,
313
+ ): Promise<{ text: string } | { deny: string }> {
314
+ const c = x.c
315
+ if (!c.browse) return { text: JSON.stringify({ error: 'browse is off: /omj config browse on' }) }
316
+ if (c.enabled && c.exfilGate) {
317
+ const state = withPolicies({ tool: 'browse', input: clip(JSON.stringify(input), 4000) }, c, request, blockedBefore)
318
+ const d = await x.decide('tool.call', 'browse', state, withPolicyQ(EXFIL_Q, c, request))
319
+ if (d) {
320
+ const j = gateExfil(d.answers, c)
321
+ x.record(d, j.verdict, j.reason)
322
+ if (j.verdict === 'deny') return { deny: denyText(j.reason) }
323
+ }
324
+ }
325
+ const cmd = browseCommand(input, c, x.env)
326
+ if (typeof cmd === 'string') return { text: JSON.stringify({ error: cmd }) }
327
+ const r = await run(cmd.argv, cmd.env, BROWSE_TIMEOUT_MS)
328
+ const text = browseReply(r.stdout, r.stderr)
329
+ if (!c.enabled || !c.injectionScreen) return { text }
330
+ const d = await x.decide('tool.result', 'browse', { tool: 'browse', content: clip(text, 6000) }, SCREEN_Q)
331
+ if (!d) return { text }
332
+ const s = screen(d.answers, c)
333
+ x.record(d, s.flagged ? 'flag' : null, s.reason)
334
+ return { text: s.flagged ? `${text}\n${s.note}` : text }
335
+ }
package/hooks/policy.ts CHANGED
@@ -26,6 +26,7 @@ export const DEFAULTS = {
26
26
  exfil: 0.7,
27
27
  requested: 0.8,
28
28
  askJev: true,
29
+ browse: false,
29
30
  autoCompact: true,
30
31
  compactSwitched: 0.8,
31
32
  compactBoundary: 0.6,
@@ -592,3 +593,133 @@ export function summarize(log: string): string {
592
593
  ...denies.slice(-5).map(r => ` ${r.tool || r.event}: ${r.reason ?? ''}`),
593
594
  ].join('\n')
594
595
  }
596
+
597
+ // --- browse: jev-ultrafast (github.com/browser-use/jev-ultrafast, MIT) drives Chrome, Jev picks each step ---
598
+
599
+ export const JEV_ULTRAFAST = 'git+https://github.com/browser-use/jev-ultrafast@1231850a0bf1a0c0341fe408ef1668dbbfdfac46'
600
+ export const BROWSE_TIMEOUT_MS = 180_000
601
+ export const BROWSE_DESCRIPTION =
602
+ 'Do a task in a real browser tab, fast: Jev picks each click, field and option from the page\'s controls (about one ' +
603
+ 'second per step). Give a starting URL and one narrow goal, like "find one-way flights Zurich to London on Sept 20, ' +
604
+ 'stop when results show" or "open the install section of these docs", with the text to type in `values`. Returns ' +
605
+ 'where it ended, the steps taken and the visible text. Runs in its own headless browser, signed in to nothing. Use WebFetch for a page you only need to read.'
606
+ export const BROWSE_SCHEMA = {
607
+ type: 'object',
608
+ properties: {
609
+ url: { type: 'string', description: 'Where to start, an http(s) URL' },
610
+ goal: { type: 'string', description: 'One narrow goal, and when to stop' },
611
+ values: {
612
+ type: 'array',
613
+ items: { type: 'string' },
614
+ description: 'Exact text the task may type: names, dates, search terms ("Zurich", "London", "Sep 20, 2026"). Jev puts each into the field that needs it',
615
+ },
616
+ },
617
+ required: ['url', 'goal'],
618
+ } as const
619
+
620
+ /** Runs one jev-ultrafast Agent and prints one JSON line: where it ended, its steps, the visible text; or an error. */
621
+ const BROWSE_PY = `
622
+ import glob, json, os, shutil, subprocess, sys, tempfile, time
623
+
624
+ def chromium():
625
+ if os.environ.get("CHROME_PATH"): return os.environ["CHROME_PATH"]
626
+ for app in ["Google Chrome", "Chromium", "Brave Browser", "Microsoft Edge"]:
627
+ p = f"/Applications/{app}.app/Contents/MacOS/{app}"
628
+ if os.path.exists(p): return p
629
+ for name in ["google-chrome", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]:
630
+ if shutil.which(name): return shutil.which(name)
631
+ home = os.path.expanduser("~")
632
+ found = sorted(glob.glob(home + "/Library/Caches/ms-playwright/chromium-*/chrome-mac*/*.app/Contents/MacOS/*")
633
+ + glob.glob(home + "/.cache/ms-playwright/chromium-*/chrome-linux*/chrome")
634
+ + glob.glob(home + "/.omp/puppeteer/chrome/*/*/*.app/Contents/MacOS/*"))
635
+ return found[-1] if found else None
636
+
637
+ def headless():
638
+ exe = chromium()
639
+ if not exe: raise RuntimeError("no Chrome, Chromium, Brave or Edge found; install one or set CHROME_PATH")
640
+ profile = os.path.expanduser("~/.ohmyjev/browser")
641
+ os.makedirs(profile, exist_ok=True)
642
+ port_file = os.path.join(profile, "DevToolsActivePort")
643
+ if os.path.exists(port_file): os.remove(port_file)
644
+ proc = subprocess.Popen([exe, "--headless=new", "--remote-debugging-port=0", "--user-data-dir=" + profile,
645
+ "--no-first-run", "--no-default-browser-check", "--window-size=1120,780", "about:blank"],
646
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
647
+ for _ in range(150):
648
+ if os.path.exists(port_file) and open(port_file).read().strip(): break
649
+ time.sleep(0.05)
650
+ else:
651
+ proc.kill(); raise RuntimeError("the headless browser did not start: " + exe)
652
+ os.environ["BU_CDP_URL"] = "http://127.0.0.1:" + open(port_file).read().split()[0]
653
+ return proc
654
+
655
+ def pick_value(context):
656
+ # No text model: Jev picks, from the values the coding agent gave, the one this field needs.
657
+ from jev_ultrafast.model import post_json
658
+ values = json.loads(os.environ.get("OMJ_BROWSE_VALUES") or "[]")
659
+ criteria = {f"v{i}": v for i, v in enumerate(values)}
660
+ criteria["none"] = "None of these is what this field needs"
661
+ started = time.time()
662
+ body = {"model": os.environ.get("TYPESAFE_MODEL", "jev-latest"), "state": context, "questions": {"value": {
663
+ "type": "choice", "instructions": "Which value should be typed into \`field\` to make progress on \`goal\`?", "criteria": criteria}}}
664
+ answer = post_json("https://api.typesafe.ai/v1/systemone", os.environ["TYPESAFE_API_KEY"], body)["answers"]["value"]["choice"]
665
+ if answer not in criteria or answer == "none":
666
+ raise ValueError("no given value fits the field " + json.dumps(context["field"].get("label")) + "; pass the text it needs in values")
667
+ return criteria[answer], {"model": "jev (picked from values)", "latency_ms": round((time.time() - started) * 1000), "usage": {}}
668
+
669
+ proc = None
670
+ try:
671
+ if not (os.environ.get("BU_CDP_URL") or os.environ.get("BU_CDP_WS")): proc = headless()
672
+ from jev_ultrafast import Agent
673
+ import jev_ultrafast.agent
674
+ if not os.environ.get("TEXT_MODEL_API_KEY"): jev_ultrafast.agent.field_text = pick_value
675
+ with Agent(sys.argv[1], sys.argv[2]) as agent:
676
+ for _ in agent.run():
677
+ pass
678
+ s = agent.snapshot()
679
+ except Exception as e:
680
+ print(json.dumps({"error": str(e)[:500]})); sys.exit(0)
681
+ finally:
682
+ if proc: proc.terminate()
683
+ p = s["page"]
684
+ steps = [h["kind"] + " " + h["action"] + (" = " + h["text"] if h["text"] else "") for h in s["history"]]
685
+ print(json.dumps({"status": s["status"], "url": p.get("url"), "title": p.get("title"), "steps": steps, "ms": s["elapsed_ms"], "text": (p.get("text") or "")[:6000]}))
686
+ `
687
+
688
+ /**
689
+ * The process that runs a browse call, or the error to hand back. jev-ultrafast speaks only TypeSafe's endpoint, and
690
+ * types into fields with a small text model: TEXT_MODEL_API_KEY, else OPENROUTER_API_KEY with mercury-2.5.
691
+ */
692
+ export function browseCommand(
693
+ input: { url?: unknown; goal?: unknown; values?: unknown },
694
+ c: Config,
695
+ env: Readonly<Record<string, string | undefined>>,
696
+ ): { argv: string[]; env: Record<string, string> } | string {
697
+ const url = typeof input.url === 'string' ? input.url.trim() : ''
698
+ const goal = typeof input.goal === 'string' ? input.goal.trim() : ''
699
+ if (!/^https?:\/\/\S+$/i.test(url)) return 'url must be an http(s) URL'
700
+ if (!goal) return 'goal is required'
701
+ const typesafe = c.apiKey || env.TYPESAFE_API_KEY
702
+ if (!typesafe) return 'browse needs a TypeSafe key (TYPESAFE_API_KEY or the apiKey setting); OpenRouter does not serve its requests'
703
+ const text: Record<string, string> = env.TEXT_MODEL_API_KEY
704
+ ? { TEXT_MODEL_API_KEY: env.TEXT_MODEL_API_KEY }
705
+ : env.OPENROUTER_API_KEY
706
+ ? { TEXT_MODEL_API_KEY: env.OPENROUTER_API_KEY, TEXT_MODEL_BASE_URL: 'https://openrouter.ai/api/v1', TEXT_MODEL: 'inception/mercury-2.5', TEXT_MODEL_REASONING: 'none' }
707
+ : {}
708
+ return {
709
+ argv: ['uv', 'run', '--no-project', '--quiet', '--with', JEV_ULTRAFAST, 'python', '-c', BROWSE_PY, url, goal],
710
+ env: { TYPESAFE_API_KEY: typesafe, ...text, ...(Array.isArray(input.values) ? { OMJ_BROWSE_VALUES: JSON.stringify(input.values.filter(v => typeof v === 'string').slice(0, 20)) } : {}) },
711
+ }
712
+ }
713
+
714
+ /** The runner's stdout as the tool's text: its JSON line, or an error that says what to fix. */
715
+ export function browseReply(stdout: string, stderr = ''): string {
716
+ const line = stdout.trim().split('\n').at(-1) ?? ''
717
+ try {
718
+ const out = JSON.parse(line) as { error?: string }
719
+ if (out.error && /TEXT_MODEL_API_KEY/.test(out.error)) out.error += '. Pass the text to type in values, or set OPENROUTER_API_KEY'
720
+ return JSON.stringify(out)
721
+ } catch {
722
+ const why = /No such file|ENOENT|not found/i.test(stderr) ? 'uv is not installed (https://docs.astral.sh/uv)' : clip(stderr.trim() || 'no output', 500)
723
+ return JSON.stringify({ error: `browse could not run: ${why}. Chrome needs remote debugging allowed the first time.` })
724
+ }
725
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohmyjev",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Guardrails for coding agents, decided by TypeSafe's Jev: blocks destructive commands, catches prompt injection, pushes back on an unverified done and routes effort per turn. For Claude Code, pi and omp.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -146,6 +146,11 @@
146
146
  "description": "Expose the ask_jev tool for quick judgments about repo files or text",
147
147
  "default": true
148
148
  },
149
+ "browse": {
150
+ "type": "boolean",
151
+ "description": "Expose a browse tool: jev-ultrafast does a task in your Chrome, Jev picking each step. Needs uv, Chrome remote debugging, and OPENROUTER_API_KEY to type into fields",
152
+ "default": false
153
+ },
149
154
  "autoCompact": {
150
155
  "type": "boolean",
151
156
  "description": "Compact after Jev sees a task change at a natural boundary",