ohmyjev 0.6.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/LICENSE +1 -1
- package/README.md +71 -20
- package/adapters/codex.ts +163 -0
- package/adapters/omp.ts +33 -12
- package/adapters/pi.ts +26 -2
- package/adapters/shared.ts +46 -1
- package/hooks/policy.ts +141 -0
- package/package.json +10 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -13,14 +13,14 @@ d88P 88888888 "Y88P" 888 888 888 888 888 "Y88888 888 "Y8888 Y88P
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
Guardrails for Claude Code, pi and omp, decided by <a href="https://typesafe.ai">Jev</a>.<br>
|
|
16
|
+
Guardrails for Claude Code, Codex, pi and omp, decided by <a href="https://typesafe.ai">Jev</a>.<br>
|
|
17
17
|
One mod that stops irreversible and data-leaking commands, even in bypass mode, and runs them when you say yes. It also flags prompt injection, pushes back on an unverified "done" and routes effort per turn.
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<img alt="Claude Code 2.1.287+" src="https://img.shields.io/badge/Claude_Code-2.1.287%2B-d97757">
|
|
22
22
|
<img alt="Decided by Jev" src="https://img.shields.io/badge/decided_by-Jev-101315">
|
|
23
|
-
<img alt="pi and omp" src="https://img.shields.io/badge/also_for-
|
|
23
|
+
<img alt="Codex, pi and omp" src="https://img.shields.io/badge/also_for-Codex_·_pi_·_omp-86a893">
|
|
24
24
|
<a href="https://www.npmjs.com/package/ohmyjev"><img alt="npm" src="https://img.shields.io/npm/v/ohmyjev?color=de6145"></a>
|
|
25
25
|
<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-798186">
|
|
26
26
|
</p>
|
|
@@ -30,7 +30,7 @@ d88P 88888888 "Y88P" 888 888 888 888 888 "Y88888 888 "Y8888 Y88P
|
|
|
30
30
|
<a href="#quick-start">Quick start</a> ·
|
|
31
31
|
<a href="#what-you-get">What you get</a> ·
|
|
32
32
|
<a href="#install">Install</a> ·
|
|
33
|
-
<a href="#pi-and-omp">pi and omp</a> ·
|
|
33
|
+
<a href="#codex-pi-and-omp">Codex, pi and omp</a> ·
|
|
34
34
|
<a href="#using-it">Using it</a> ·
|
|
35
35
|
<a href="#settings">Settings</a> ·
|
|
36
36
|
<a href="#privacy">Privacy</a> ·
|
|
@@ -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
|
|
@@ -144,11 +145,29 @@ key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0
|
|
|
144
145
|
If it says `key: none`, ohmyjev can't see a key. In that case every gate stays open, the router falls back to Claude
|
|
145
146
|
Code's built-in classifier (up only), and the status under the prompt shows `jev ⚠ no key`.
|
|
146
147
|
|
|
147
|
-
## pi and omp
|
|
148
|
+
## Codex, pi and omp
|
|
148
149
|
|
|
149
|
-
The same checks run in [
|
|
150
|
-
They share the decision logic and the key lookup
|
|
151
|
-
plugin, and write to the same `~/.ohmyjev` logs, so
|
|
150
|
+
The same checks run in [Codex](https://developers.openai.com/codex) as a plugin with hooks, and in [pi](https://pi.dev)
|
|
151
|
+
and [omp](https://github.com/can1357/oh-my-pi) as a native extension. They share the decision logic and the key lookup
|
|
152
|
+
(`TYPESAFE_API_KEY`, then `OPENROUTER_API_KEY`) with the Claude Code plugin, and write to the same `~/.ohmyjev` logs, so
|
|
153
|
+
`/omj stats` in any of them shows every session.
|
|
154
|
+
|
|
155
|
+
### Codex
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
codex plugin marketplace add Novacon/ohmyjev
|
|
159
|
+
codex plugin add ohmyjev@ohmyjev
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then start Codex and run `/hooks` once: Codex skips a plugin's hooks until you trust them. ohmyjev needs Node on your
|
|
163
|
+
`PATH` and the key in the shell that starts Codex. It gates `Bash`, `apply_patch` edits and MCP calls, screens their
|
|
164
|
+
output, and runs the done-check. Saying yes after a block works the same way. Codex hooks can't change the model or add
|
|
165
|
+
a tool, so there is no router, no `ask_jev` and no `/omj` command. Put settings in `~/.codex/ohmyjev.json`, using the
|
|
166
|
+
names in [Settings](#settings):
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{ "requested": 0.9, "policies": "never touch the prod cluster" }
|
|
170
|
+
```
|
|
152
171
|
|
|
153
172
|
### omp
|
|
154
173
|
|
|
@@ -184,18 +203,19 @@ pi has no settings screen for extensions, so put yours under an `ohmyjev` key in
|
|
|
184
203
|
|
|
185
204
|
### What differs
|
|
186
205
|
|
|
187
|
-
| | Claude Code | omp | pi |
|
|
188
|
-
|
|
189
|
-
| Bash gate | Bash | `bash`, `eval`, and `github` pushes and PRs | `bash`, `powershell` |
|
|
190
|
-
| Write gate | Write, Edit, NotebookEdit | `write`, `edit` (patches and renames included), `ast_edit` | `write`, `edit` |
|
|
191
|
-
| Exfil gate | WebFetch, MCP tools | `read` of a URL, MCP tools | MCP tools (pi has no fetch tool; `curl` goes through the bash gate) |
|
|
192
|
-
| Injection screen | Bash, WebFetch, MCP, Reads outside the repo | `bash`, `github`, URL reads, MCP, reads outside the repo | `bash`, `powershell`, MCP, reads outside the repo |
|
|
193
|
-
| Done-check | ✓ | ✓ | ✓ |
|
|
194
|
-
| Router effort | ✓ | ✓ (left alone while you're on `auto`) | ✓ |
|
|
195
|
-
| Router subagent model | general-purpose subagents | subagents on the default `task` role | none: pi has no subagents |
|
|
196
|
-
| Router without a key | Claude Code's built-in classifier, up only | off: omp has no separate classifier call for extensions | the cheapest model you've set up, up only |
|
|
197
|
-
|
|
|
198
|
-
|
|
|
206
|
+
| | Claude Code | Codex | omp | pi |
|
|
207
|
+
|---|---|---|---|---|
|
|
208
|
+
| Bash gate | Bash | Bash | `bash`, `eval`, and `github` pushes and PRs | `bash`, `powershell` |
|
|
209
|
+
| Write gate | Write, Edit, NotebookEdit | `apply_patch` | `write`, `edit` (patches and renames included), `ast_edit` | `write`, `edit` |
|
|
210
|
+
| Exfil gate | WebFetch, MCP tools | MCP tools | `read` of a URL, MCP tools | MCP tools (pi has no fetch tool; `curl` goes through the bash gate) |
|
|
211
|
+
| Injection screen | Bash, WebFetch, MCP, Reads outside the repo | Bash, MCP | `bash`, `github`, URL reads, MCP, reads outside the repo | `bash`, `powershell`, MCP, reads outside the repo |
|
|
212
|
+
| Done-check | ✓ | ✓ | ✓ | ✓ |
|
|
213
|
+
| Router effort | ✓ | none: hooks can't set it | ✓ (left alone while you're on `auto`) | ✓ |
|
|
214
|
+
| Router subagent model | general-purpose subagents | none | subagents on the default `task` role | none: pi has no subagents |
|
|
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 |
|
|
216
|
+
| `/omj`, `ask_jev`, `browse` | ✓ | none: hooks can't add commands or tools | ✓ | ✓ |
|
|
217
|
+
| Decision lines | in the transcript | the log only | notifications | notifications |
|
|
218
|
+
| Status | under the prompt | none | in the footer | in the footer |
|
|
199
219
|
|
|
200
220
|
## Using it
|
|
201
221
|
|
|
@@ -256,6 +276,31 @@ The model can call this tool on its own when it needs a judgment about files wit
|
|
|
256
276
|
Jev reads the files, not the model. ohmyjev only sends files that are inside the repo, up to 8000 characters each and
|
|
257
277
|
80000 in total.
|
|
258
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
|
+
|
|
259
304
|
### In the transcript
|
|
260
305
|
|
|
261
306
|
Each routing decision shows up as a dim line in the transcript. The model never sees these lines. First what Jev
|
|
@@ -334,6 +379,7 @@ one for the session. To keep a change, run `/plugin configure ohmyjev@ohmyjev` i
|
|
|
334
379
|
| `routeWithoutKey` | on | With no key, routes with Claude Code's built-in classifier instead. It has no confidence, so it only routes up. |
|
|
335
380
|
| `autoCompact` | on | Compacts when the task changes. `compactMinPercent` (40) sets how full the context has to be first. |
|
|
336
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. |
|
|
337
383
|
| `policies` | empty | Your own rules, separated by `;`. |
|
|
338
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. |
|
|
339
385
|
| `fastModel`, `balancedModel`, `deepModel` | `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` | The model id for each router tier. |
|
|
@@ -365,6 +411,7 @@ a fixed size:
|
|
|
365
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) |
|
|
366
412
|
| Router | The request (up to 1500 characters) |
|
|
367
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 |
|
|
368
415
|
|
|
369
416
|
The write and exfil gates also send your latest message (1500), for the request check. When you've set `policies`,
|
|
370
417
|
every gate sends those too. Turning a feature off stops its calls.
|
|
@@ -379,6 +426,9 @@ have set up, through pi's own provider. Logs and session files stay in `~/.ohmyj
|
|
|
379
426
|
claude plugin update ohmyjev@ohmyjev # then restart Claude Code
|
|
380
427
|
claude plugin uninstall ohmyjev@ohmyjev
|
|
381
428
|
|
|
429
|
+
codex plugin marketplace upgrade ohmyjev # Codex; then trust the changed hooks in /hooks
|
|
430
|
+
codex plugin remove ohmyjev@ohmyjev
|
|
431
|
+
|
|
382
432
|
omp plugin upgrade ohmyjev # omp; restart the session
|
|
383
433
|
omp plugin uninstall ohmyjev
|
|
384
434
|
|
|
@@ -418,4 +468,5 @@ The design and plans are in [`docs/superpowers`](docs/superpowers).
|
|
|
418
468
|
|
|
419
469
|
## Credits
|
|
420
470
|
|
|
421
|
-
The question rubrics come from [disler/ten-levels-of-jev](https://github.com/disler/ten-levels-of-jev).
|
|
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.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ohmyjev for Codex: one command hook, run once per event (codex/hooks.json). Each run is a fresh process, so what
|
|
3
|
+
* must outlive it (the user's latest request, the call blocked last turn, this turn's tools) sits in
|
|
4
|
+
* ~/.ohmyjev/codex/<session>.json. Same decisions as the other hosts (hooks/policy.ts); every failure prints nothing,
|
|
5
|
+
* and nothing lets the call through. Codex hooks can't change the model or add tools, so there is no router or ask_jev.
|
|
6
|
+
* Built to codex/ohmyjev.mjs with `bun run build:codex`, since Codex runs it with plain node.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
9
|
+
import {
|
|
10
|
+
BASH_Q, EXFIL_Q, SCREEN_Q, STOP_Q, WRITE_Q, applyPatchPaths, callSummary, clip, denyText, gateBash, gateExfil, gateWrite,
|
|
11
|
+
judgeStop, pathDenyText, plainRead, readConfig, sanitizeSid, screen, withPolicies, withPolicyQ, type Config,
|
|
12
|
+
} from '../hooks/policy.ts'
|
|
13
|
+
import { Session, keyOf, pathAllowed, repoRoot, type Env, type Fetch } from './shared.ts'
|
|
14
|
+
|
|
15
|
+
const CLIP = 16000
|
|
16
|
+
type Tool = { tool: string; input: string; outcome: 'ok' | 'failed' }
|
|
17
|
+
type State = { request: string; previous: string[]; count: number; blockedBefore: string; lastBlocked: string; tools: Tool[]; pushedBackAt: number }
|
|
18
|
+
const EMPTY: State = { request: '', previous: [], count: 0, blockedBefore: '', lastBlocked: '', tools: [], pushedBackAt: -1 }
|
|
19
|
+
type Input = Record<string, unknown>
|
|
20
|
+
const str = (o: Input, k: string): string => (typeof o[k] === 'string' ? (o[k] as string) : '')
|
|
21
|
+
const obj = (v: unknown): Input => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Input) : {})
|
|
22
|
+
|
|
23
|
+
/** Settings: ~/.codex/ohmyjev.json, a flat object of the setting names in the README. */
|
|
24
|
+
async function config(env: Env): Promise<Config> {
|
|
25
|
+
const text = await readFile(`${env.HOME ?? ''}/.codex/ohmyjev.json`, 'utf8').catch(() => '{}')
|
|
26
|
+
try {
|
|
27
|
+
return readConfig(obj(JSON.parse(text)))
|
|
28
|
+
} catch {
|
|
29
|
+
return readConfig({})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const statePath = (env: Env, sid: string) => `${env.HOME ?? ''}/.ohmyjev/codex/${sanitizeSid(sid)}.json`
|
|
34
|
+
async function load(env: Env, sid: string): Promise<State> {
|
|
35
|
+
const text = await readFile(statePath(env, sid), 'utf8').catch(() => '')
|
|
36
|
+
try {
|
|
37
|
+
return { ...EMPTY, ...(JSON.parse(text) as Partial<State>) }
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...EMPTY }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ponytail: last write wins; two hooks finishing at once can drop one tool from this turn's list (done-check input only)
|
|
43
|
+
async function save(env: Env, sid: string, st: State): Promise<void> {
|
|
44
|
+
await mkdir(`${env.HOME ?? ''}/.ohmyjev/codex`, { recursive: true, mode: 0o700 }).catch(() => undefined)
|
|
45
|
+
await writeFile(statePath(env, sid), JSON.stringify(st), { mode: 0o600 }).catch(() => undefined)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } })
|
|
49
|
+
|
|
50
|
+
/** One hook event in, Codex's JSON answer out; undefined means no opinion. */
|
|
51
|
+
export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise<object | undefined> {
|
|
52
|
+
const c = await config(env)
|
|
53
|
+
const event = str(input, 'hook_event_name')
|
|
54
|
+
const sid = str(input, 'session_id') || 'codex'
|
|
55
|
+
const cwd = str(input, 'cwd') || process.cwd()
|
|
56
|
+
if (!c.enabled) return undefined
|
|
57
|
+
|
|
58
|
+
if (event === 'SessionStart')
|
|
59
|
+
return keyOf(c, env) ? undefined : { systemMessage: 'ohmyjev: no Jev key, so every call passes through. Set TYPESAFE_API_KEY in the shell that starts Codex.' }
|
|
60
|
+
|
|
61
|
+
const st = await load(env, sid)
|
|
62
|
+
if (event === 'UserPromptSubmit') {
|
|
63
|
+
const prompt = str(input, 'prompt')
|
|
64
|
+
if (!prompt.trim() || /^\/\S+\s*$/.test(prompt.trim())) return undefined
|
|
65
|
+
if (st.request) st.previous = [...st.previous, clip(st.request, 200)].slice(-5)
|
|
66
|
+
Object.assign(st, { request: prompt, count: st.count + 1, blockedBefore: st.lastBlocked, lastBlocked: '', tools: [] })
|
|
67
|
+
await save(env, sid, st)
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const x = new Session(c, env, sid, undefined, fetchImpl)
|
|
72
|
+
const tool = str(input, 'tool_name')
|
|
73
|
+
const toolInput = obj(input.tool_input)
|
|
74
|
+
|
|
75
|
+
if (event === 'PreToolUse') {
|
|
76
|
+
const blocked = async (j: { verdict: unknown; reason: string }) => {
|
|
77
|
+
if (j.verdict !== 'deny') return undefined
|
|
78
|
+
st.lastBlocked = callSummary(tool, toolInput)
|
|
79
|
+
await save(env, sid, st)
|
|
80
|
+
return deny(denyText(j.reason))
|
|
81
|
+
}
|
|
82
|
+
if (tool === 'Bash' && c.bashGate) {
|
|
83
|
+
const command = str(toolInput, 'command')
|
|
84
|
+
if (plainRead(command, c)) return undefined
|
|
85
|
+
const state = withPolicies({ command: clip(command, CLIP), cwd, ...(command.length > CLIP ? { truncated: true } : {}) }, c, st.request, st.blockedBefore)
|
|
86
|
+
const d = await x.decide('tool.call', tool, state, withPolicyQ(BASH_Q, c, st.request))
|
|
87
|
+
if (!d) return undefined
|
|
88
|
+
const j = gateBash(d.answers, c)
|
|
89
|
+
x.record(d, j.verdict, j.reason)
|
|
90
|
+
return blocked(j)
|
|
91
|
+
}
|
|
92
|
+
if (tool === 'apply_patch' && c.writeGate) {
|
|
93
|
+
const patch = str(toolInput, 'command')
|
|
94
|
+
const root = await repoRoot(cwd)
|
|
95
|
+
for (const path of applyPatchPaths(patch))
|
|
96
|
+
if (!(await pathAllowed(path, cwd, root, c, env))) {
|
|
97
|
+
x.denyByCode(tool, `${path} is outside the repo and allowPaths`)
|
|
98
|
+
return deny(pathDenyText(path))
|
|
99
|
+
}
|
|
100
|
+
const state = withPolicies({ path: applyPatchPaths(patch).join('; '), content: clip(patch, CLIP) }, c, st.request, st.blockedBefore)
|
|
101
|
+
const d = await x.decide('tool.call', tool, state, withPolicyQ(WRITE_Q, c, st.request))
|
|
102
|
+
if (!d) return undefined
|
|
103
|
+
const j = gateWrite(d.answers, c)
|
|
104
|
+
x.record(d, j.verdict, j.reason)
|
|
105
|
+
return blocked(j)
|
|
106
|
+
}
|
|
107
|
+
if (tool.startsWith('mcp__') && c.exfilGate) {
|
|
108
|
+
const state = withPolicies({ tool, input: clip(JSON.stringify(toolInput), 4000) }, c, st.request, st.blockedBefore)
|
|
109
|
+
const d = await x.decide('tool.call', tool, state, withPolicyQ(EXFIL_Q, c, st.request))
|
|
110
|
+
if (!d) return undefined
|
|
111
|
+
const j = gateExfil(d.answers, c)
|
|
112
|
+
x.record(d, j.verdict, j.reason)
|
|
113
|
+
return blocked(j)
|
|
114
|
+
}
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (event === 'PostToolUse') {
|
|
119
|
+
const response = input.tool_response
|
|
120
|
+
const code = obj(response).exit_code ?? obj(response).exitCode
|
|
121
|
+
st.tools = [...st.tools, { tool, input: clip(JSON.stringify(toolInput), 200), outcome: typeof code === 'number' && code !== 0 ? 'failed' : 'ok' } as Tool].slice(-20)
|
|
122
|
+
await save(env, sid, st)
|
|
123
|
+
if (!c.injectionScreen || !(tool === 'Bash' || tool.startsWith('mcp__'))) return undefined
|
|
124
|
+
const text = typeof response === 'string' ? response : JSON.stringify(response ?? '')
|
|
125
|
+
if (!text.trim()) return undefined
|
|
126
|
+
const d = await x.decide('tool.result', tool, { tool, content: clip(text, 6000) }, SCREEN_Q)
|
|
127
|
+
if (!d) return undefined
|
|
128
|
+
const s = screen(d.answers, c)
|
|
129
|
+
x.record(d, s.flagged ? 'flag' : null, s.reason)
|
|
130
|
+
return s.flagged ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: s.note } } : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (event === 'Stop') {
|
|
134
|
+
if (!c.doneCheck || input.stop_hook_active === true || st.pushedBackAt === st.count) return undefined
|
|
135
|
+
const state = {
|
|
136
|
+
current_request: clip(st.request, 600),
|
|
137
|
+
previous_requests: st.previous,
|
|
138
|
+
tools_this_turn: st.tools,
|
|
139
|
+
last_assistant_message: clip(str(input, 'last_assistant_message'), 1500),
|
|
140
|
+
}
|
|
141
|
+
const d = await x.decide('Stop', '', state, STOP_Q)
|
|
142
|
+
if (!d) return undefined
|
|
143
|
+
const j = judgeStop(d.answers, c, st.tools.length > 0)
|
|
144
|
+
x.record(d, j.block ? 'block' : null, j.block ?? 'stop ok')
|
|
145
|
+
if (!j.block) return undefined
|
|
146
|
+
st.pushedBackAt = st.count
|
|
147
|
+
await save(env, sid, st)
|
|
148
|
+
return { decision: 'block', reason: j.block }
|
|
149
|
+
}
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The hook command: one JSON event on stdin, the answer on stdout. Any failure prints nothing, so Codex goes on. */
|
|
154
|
+
export async function main(): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
let text = ''
|
|
157
|
+
for await (const chunk of process.stdin) text += chunk
|
|
158
|
+
const out = await handle(obj(JSON.parse(text)), process.env)
|
|
159
|
+
if (out) process.stdout.write(JSON.stringify(out))
|
|
160
|
+
} catch {
|
|
161
|
+
// ohmyjev's own bug: no opinion
|
|
162
|
+
}
|
|
163
|
+
}
|
package/adapters/omp.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
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
|
-
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead, configCommand,
|
|
8
|
-
OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
|
|
7
|
+
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead, configCommand, applyPatchPaths,
|
|
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
|
|
|
@@ -150,15 +150,6 @@ const exfilTool = (tool: string, input: RecordOfUnknown): boolean => urlRead(too
|
|
|
150
150
|
const githubWrite = (tool: string, input: RecordOfUnknown): boolean =>
|
|
151
151
|
tool === 'github' && GITHUB_READONLY_OPS[string(input, 'op')] !== true
|
|
152
152
|
|
|
153
|
-
function applyPatchPaths(patch: string): string[] {
|
|
154
|
-
const paths: string[] = []
|
|
155
|
-
for (const line of patch.split('\n')) {
|
|
156
|
-
const match = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/.exec(line.trim())
|
|
157
|
-
if (match?.[1]) paths.push(match[1])
|
|
158
|
-
}
|
|
159
|
-
return paths
|
|
160
|
-
}
|
|
161
|
-
|
|
162
153
|
function writePaths(tool: string, input: RecordOfUnknown): string[] {
|
|
163
154
|
const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === 'string' && p !== '') : []
|
|
164
155
|
for (const key of ['path', 'file_path', 'notebook_path']) {
|
|
@@ -260,6 +251,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
260
251
|
let compactPending = false
|
|
261
252
|
let classifiedPrompt: string | undefined
|
|
262
253
|
let askRegistered = false
|
|
254
|
+
let browseRegistered = false
|
|
263
255
|
let sessionGeneration = 0
|
|
264
256
|
let userEffort: string | undefined
|
|
265
257
|
const routedThinkingEntries = new Set<string>()
|
|
@@ -432,6 +424,35 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
432
424
|
})
|
|
433
425
|
askRegistered = true
|
|
434
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
|
+
}
|
|
435
456
|
}
|
|
436
457
|
|
|
437
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,
|
package/adapters/shared.ts
CHANGED
|
@@ -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,
|
|
@@ -480,6 +481,16 @@ export function hostPath(p: string): string | null {
|
|
|
480
481
|
export const parseWorktrees = (porcelain: string): string[] =>
|
|
481
482
|
porcelain.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice('worktree '.length).trim()).filter(Boolean)
|
|
482
483
|
|
|
484
|
+
/** Every file an apply_patch patch adds, updates, deletes or moves to (omp and Codex edits). */
|
|
485
|
+
export function applyPatchPaths(patch: string): string[] {
|
|
486
|
+
const paths: string[] = []
|
|
487
|
+
for (const line of patch.split('\n')) {
|
|
488
|
+
const match = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/.exec(line.trim())
|
|
489
|
+
if (match?.[1]) paths.push(match[1])
|
|
490
|
+
}
|
|
491
|
+
return paths
|
|
492
|
+
}
|
|
493
|
+
|
|
483
494
|
export const isUnder = (target: string, root: string): boolean =>
|
|
484
495
|
target === root || target.startsWith(root.replace(/\/+$/, '') + '/')
|
|
485
496
|
|
|
@@ -582,3 +593,133 @@ export function summarize(log: string): string {
|
|
|
582
593
|
...denies.slice(-5).map(r => ` ${r.tool || r.event}: ${r.reason ?? ''}`),
|
|
583
594
|
].join('\n')
|
|
584
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.
|
|
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",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"pi",
|
|
16
16
|
"claude-code",
|
|
17
17
|
"claude-code-plugin",
|
|
18
|
+
"codex",
|
|
19
|
+
"codex-plugin",
|
|
18
20
|
"jev",
|
|
19
21
|
"guardrails",
|
|
20
22
|
"ai-agent",
|
|
@@ -144,6 +146,11 @@
|
|
|
144
146
|
"description": "Expose the ask_jev tool for quick judgments about repo files or text",
|
|
145
147
|
"default": true
|
|
146
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
|
+
},
|
|
147
154
|
"autoCompact": {
|
|
148
155
|
"type": "boolean",
|
|
149
156
|
"description": "Compact after Jev sees a task change at a natural boundary",
|
|
@@ -243,7 +250,8 @@
|
|
|
243
250
|
},
|
|
244
251
|
"scripts": {
|
|
245
252
|
"test": "bun test adapters && claude plugin test .",
|
|
246
|
-
"typecheck": "tsc -p . && tsc -p tsconfig.adapters.json"
|
|
253
|
+
"typecheck": "tsc -p . && tsc -p tsconfig.adapters.json",
|
|
254
|
+
"build:codex": "bun build codex/main.ts --target=node --format=esm --outfile=codex/ohmyjev.mjs"
|
|
247
255
|
},
|
|
248
256
|
"devDependencies": {
|
|
249
257
|
"@types/bun": "^1.4.2",
|