ohmyjev 0.7.1 → 0.9.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 +80 -19
- package/adapters/codex.ts +42 -17
- package/adapters/omp.ts +53 -11
- package/adapters/pi.ts +57 -17
- package/adapters/shared.ts +81 -2
- package/hooks/policy.ts +268 -1
- package/package.json +61 -1
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ d88P 88888888 "Y88P" 888 888 888 888 888 "Y88888 888 "Y8888 Y88P
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
|
|
17
|
-
One
|
|
16
|
+
<a href="https://typesafe.ai">Jev</a>, batteries included, for Claude Code, Codex, pi and omp.<br>
|
|
17
|
+
One install that makes your coding agent faster and safer: no permission prompts for safe commands, long output cut to what matters, the right skill loaded, and nothing irreversible until you say yes.
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
@@ -40,13 +40,14 @@ d88P 88888888 "Y88P" 888 888 888 888 888 "Y88888 888 "Y8888 Y88P
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
42
|
Jev is TypeSafe's decision model. You send it some state and a few typed questions, and it sends back probabilities
|
|
43
|
-
in about
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
in about 200 ms for a fraction of a cent. That's cheap enough to ask about every step your agent takes, so ohmyjev asks
|
|
44
|
+
it everywhere a coding agent wastes your time or puts your work at risk: which command needs your OK, which part of a
|
|
45
|
+
long log matters, which skill a prompt needs, whether the agent is going in circles, and whether a command can be
|
|
46
|
+
undone. It runs as in-process hooks, so there's no extra process and no server to keep up.
|
|
46
47
|
|
|
47
|
-
The bash gate
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
Most of it makes the agent faster. The bash gate keeps it safe: an agent running unattended in bypass mode can't wipe a
|
|
49
|
+
directory, force push over `main` or upload your keys. When it blocks something, the agent tells you why and asks.
|
|
50
|
+
Answer yes and that exact command runs.
|
|
50
51
|
|
|
51
52
|
## Quick start
|
|
52
53
|
|
|
@@ -65,6 +66,19 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
|
|
|
65
66
|
|
|
66
67
|
## What you get
|
|
67
68
|
|
|
69
|
+
### Faster
|
|
70
|
+
|
|
71
|
+
| Feature | What it does |
|
|
72
|
+
|---|---|
|
|
73
|
+
| **Auto-approve** | Where Claude Code or Codex would stop to ask you, a command Jev is sure only reads or can be undone, or an edit inside the repo with no credential in it, runs without the prompt. Plain reads like `ls`, `git status` and `grep` are cleared by code, with no Jev call at all. Off whenever you've set `policies`. |
|
|
74
|
+
| **Trim long output** | A shell output over 12000 characters is split into parts, and one Jev call rates which parts your request needs: the error, the failing test, the value you asked for. The rest becomes one line saying how much was left out. A 20000-character test log came back as 4600 with the one failure in it. |
|
|
75
|
+
| **Skill picker** | Claude Code sends the model a line for every skill you have on every prompt. ohmyjev leaves that list out and asks Jev whether a prompt needs a skill and which one, then names it beside your prompt. A second check makes sure the pick does the job, so a prompt no skill fits gets none. |
|
|
76
|
+
| **Stuck check** | After the third failed tool call in a request, Jev reads the failures. If they repeat one approach and fail the same way, the agent is told to stop, read the error and try something else, or ask you. |
|
|
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
|
+
| **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
|
+
|
|
80
|
+
### Safer
|
|
81
|
+
|
|
68
82
|
| Feature | What it does |
|
|
69
83
|
|---|---|
|
|
70
84
|
| **Bash gate** | Denies a command when Jev rates it irreversible at 0.6 or more, or when it sends local files or secrets to an outside host (0.7), such as `curl` uploading `~/.ssh`. A command Jev calls irreversible with less confidence is denied too when it also aims to wipe something (0.7). Reversible work always passes, so deleting lines, dropping a co-author trailer or uninstalling a plugin goes through. Plain reads like `ls`, `git status` or `grep` skip Jev entirely: plain code checks them, so they cost nothing and wait for nothing. |
|
|
@@ -74,9 +88,13 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
|
|
|
74
88
|
| **Your request** | Every gate also asks Jev whether your latest message asked for exactly this call. If it did (0.8), a gate's Jev deny lets the call through. So "delete the build folder" or "force push it" works, while the same command picked by the agent on its own is still denied. After a block, a plain "yes" works too: the gate shows Jev the call it just blocked next to your reply. Policy and path denies always stand. |
|
|
75
89
|
| **Injection screen** | When output from Bash, WebFetch, an MCP tool, or a Read outside the repo has instructions aimed at the model, it adds a note telling the model to treat that output as data. |
|
|
76
90
|
| **Done-check** | If the agent says it's done but nothing shows it ran a check, it blocks the stop once and tells the agent to verify. A turn that ran no tools is never blocked, since there was nothing to verify. |
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
|
|
92
|
+
### Tools and commands
|
|
93
|
+
|
|
94
|
+
| Feature | What it does |
|
|
95
|
+
|---|---|
|
|
79
96
|
| **`ask_jev`** | A tool the model can use to ask Jev about repo files or text without loading them into its own context. |
|
|
97
|
+
| **`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
98
|
| **`/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
99
|
|
|
82
100
|
### How a tool call goes through it
|
|
@@ -85,18 +103,21 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
|
|
|
85
103
|
flowchart LR
|
|
86
104
|
A[Claude calls a tool] --> B{ohmyjev asks Jev}
|
|
87
105
|
B -- clearly risky --> C[Denied, with the reason]
|
|
106
|
+
B -- clearly safe --> E
|
|
88
107
|
B -- fine or unsure --> D[Claude Code's normal permission flow]
|
|
89
108
|
B -- Jev down, slow or no key --> D
|
|
90
109
|
D --> E[Tool runs]
|
|
91
|
-
E -->
|
|
110
|
+
E --> T[Long output trimmed<br>to what the request needs]
|
|
111
|
+
T --> F{Output has instructions<br>aimed at the model?}
|
|
92
112
|
F -- yes --> G[Model gets a<br>'treat as data' note]
|
|
93
113
|
F -- no --> H[Output as is]
|
|
94
114
|
```
|
|
95
115
|
|
|
96
|
-
Anything ohmyjev
|
|
97
|
-
being down or slower than `timeoutMs` (1.5 s), a missing key, and any error inside ohmyjev. It never
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
Anything ohmyjev neither denies nor clears goes on to Claude Code's normal permission flow. That covers middling
|
|
117
|
+
answers, Jev being down or slower than `timeoutMs` (1.5 s), a missing key, and any error inside ohmyjev. It never adds a
|
|
118
|
+
prompt of its own, so it works fine with agents running in bypass mode. It only removes prompts: auto-approve answers
|
|
119
|
+
"allow" only where the engine was about to ask and your settings left the call open. Jev can be wrong though, so keep
|
|
120
|
+
your deny rules in `settings.json`.
|
|
100
121
|
|
|
101
122
|
## Install
|
|
102
123
|
|
|
@@ -159,8 +180,9 @@ codex plugin add ohmyjev@ohmyjev
|
|
|
159
180
|
```
|
|
160
181
|
|
|
161
182
|
Then start Codex and run `/hooks` once: Codex skips a plugin's hooks until you trust them. ohmyjev needs Node on your
|
|
162
|
-
`PATH` and the key in the shell that starts Codex. It gates `Bash`, `apply_patch` edits and MCP calls,
|
|
163
|
-
|
|
183
|
+
`PATH` and the key in the shell that starts Codex. It gates `Bash`, `apply_patch` edits and MCP calls, answers
|
|
184
|
+
Codex's permission prompts for calls Jev cleared, trims long output, screens it, catches repeated failures, and runs the
|
|
185
|
+
done-check. Saying yes after a block works the same way. Codex hooks can't change the model or add
|
|
164
186
|
a tool, so there is no router, no `ask_jev` and no `/omj` command. Put settings in `~/.codex/ohmyjev.json`, using the
|
|
165
187
|
names in [Settings](#settings):
|
|
166
188
|
|
|
@@ -209,10 +231,14 @@ pi has no settings screen for extensions, so put yours under an `ohmyjev` key in
|
|
|
209
231
|
| 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) |
|
|
210
232
|
| 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 |
|
|
211
233
|
| Done-check | ✓ | ✓ | ✓ | ✓ |
|
|
234
|
+
| Auto-approve | ✓ | ✓ (PermissionRequest) | none: omp doesn't ask | none: pi doesn't ask |
|
|
235
|
+
| Trim long output | Bash | Bash (the trimmed text replaces the result) | `bash` | `bash`, `powershell` |
|
|
236
|
+
| Stuck check | ✓ | ✓ | ✓ | ✓ |
|
|
237
|
+
| Skill picker | ✓ | none: hooks can't remove the skill list | none | none |
|
|
212
238
|
| Router effort | ✓ | none: hooks can't set it | ✓ (left alone while you're on `auto`) | ✓ |
|
|
213
239
|
| Router subagent model | general-purpose subagents | none | subagents on the default `task` role | none: pi has no subagents |
|
|
214
240
|
| 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 | ✓ | ✓ |
|
|
241
|
+
| `/omj`, `ask_jev`, `browse` | ✓ | none: hooks can't add commands or tools | ✓ | ✓ |
|
|
216
242
|
| Decision lines | in the transcript | the log only | notifications | notifications |
|
|
217
243
|
| Status | under the prompt | none | in the footer | in the footer |
|
|
218
244
|
|
|
@@ -275,6 +301,31 @@ The model can call this tool on its own when it needs a judgment about files wit
|
|
|
275
301
|
Jev reads the files, not the model. ohmyjev only sends files that are inside the repo, up to 8000 characters each and
|
|
276
302
|
80000 in total.
|
|
277
303
|
|
|
304
|
+
### `browse`
|
|
305
|
+
|
|
306
|
+
Turn it on with `/omj config browse on`, or `browse` in the settings. The model then has a `browse` tool for tasks on a
|
|
307
|
+
website: give it a starting URL, one narrow goal, and the text it may type.
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"url": "https://www.google.com/travel/flights?hl=en",
|
|
312
|
+
"goal": "Find one-way flights from Zurich to London on November 20, 2026. Stop when flight options show.",
|
|
313
|
+
"values": ["Zurich", "London", "Nov 20, 2026"]
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Each step, Jev reads the page's controls and picks one action and its target in a single request, and when a field
|
|
318
|
+
needs text it picks the right entry from `values`. That run took 11 steps and 5.5 seconds: one-way, both cities, the
|
|
319
|
+
date, search. The tool returns where it ended, the steps and the page's visible text, and that text goes through the
|
|
320
|
+
injection screen like any tool output. The exfil gate checks the goal first, so a goal that would carry your files to a
|
|
321
|
+
website is denied before the browser opens.
|
|
322
|
+
|
|
323
|
+
It is [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast) (MIT), pinned to one commit, run through
|
|
324
|
+
[uv](https://docs.astral.sh/uv), so install uv once. It starts its own headless Chrome, Chromium, Brave or Edge with
|
|
325
|
+
a profile in `~/.ohmyjev/browser`, signed in to nothing, and closes it after the task. Set `CHROME_PATH` to choose the
|
|
326
|
+
browser. With `OPENROUTER_API_KEY` or `TEXT_MODEL_API_KEY` set, a small text model writes field text instead of picking
|
|
327
|
+
from `values`. Use WebFetch for a page the model only needs to read.
|
|
328
|
+
|
|
278
329
|
### In the transcript
|
|
279
330
|
|
|
280
331
|
Each routing decision shows up as a dim line in the transcript. The model never sees these lines. First what Jev
|
|
@@ -353,6 +404,11 @@ one for the session. To keep a change, run `/plugin configure ohmyjev@ohmyjev` i
|
|
|
353
404
|
| `routeWithoutKey` | on | With no key, routes with Claude Code's built-in classifier instead. It has no confidence, so it only routes up. |
|
|
354
405
|
| `autoCompact` | on | Compacts when the task changes. `compactMinPercent` (40) sets how full the context has to be first. |
|
|
355
406
|
| `askJev` | on | Gives the model the `ask_jev` tool. |
|
|
407
|
+
| `browse` | off | Gives the model the `browse` tool. Needs uv and a Chromium-based browser. |
|
|
408
|
+
| `autoApprove` | on | Skips the permission prompt for a call Jev is sure is safe. `approveConfidence` (0.9) and `approveRiskMax` (0.2) set how sure. |
|
|
409
|
+
| `trimOutput` | on | Cuts long shell output to what the request needs. `trimOver` (12000 characters) and `trimKeep` (0.3) tune it. |
|
|
410
|
+
| `stuckCheck` | on | Tells the agent to change course when its failures repeat. `stuck` (0.7) sets how sure Jev must be. |
|
|
411
|
+
| `skillPick` | on | Claude Code: leaves the skill list out and names the one skill a prompt needs. `skillGate` (0.3) and `skillMin` (0.4) tune it. |
|
|
356
412
|
| `policies` | empty | Your own rules, separated by `;`. |
|
|
357
413
|
| `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
414
|
| `fastModel`, `balancedModel`, `deepModel` | `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` | The model id for each router tier. |
|
|
@@ -383,7 +439,11 @@ a fixed size:
|
|
|
383
439
|
| Injection screen | The tool's output (up to 6000 characters), from Bash, WebFetch, MCP tools and Reads outside the repo |
|
|
384
440
|
| 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
441
|
| Router | The request (up to 1500 characters) |
|
|
442
|
+
| Trim long output | Your latest message (1500) and the long output, in parts of about 1500 characters, up to 24 parts |
|
|
443
|
+
| Stuck check | Your latest message (600) and the last six failed calls: each one's input and the end of its output (300 each) |
|
|
444
|
+
| Skill picker | Each prompt (1500) with every skill's name and one-line description (300 each); then the picked skill's description (600) |
|
|
386
445
|
| `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 |
|
|
446
|
+
| `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
447
|
|
|
388
448
|
The write and exfil gates also send your latest message (1500), for the request check. When you've set `policies`,
|
|
389
449
|
every gate sends those too. Turning a feature off stops its calls.
|
|
@@ -440,4 +500,5 @@ The design and plans are in [`docs/superpowers`](docs/superpowers).
|
|
|
440
500
|
|
|
441
501
|
## Credits
|
|
442
502
|
|
|
443
|
-
The question rubrics come from [disler/ten-levels-of-jev](https://github.com/disler/ten-levels-of-jev).
|
|
503
|
+
The question rubrics come from [disler/ten-levels-of-jev](https://github.com/disler/ten-levels-of-jev). `browse` runs
|
|
504
|
+
[browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast). Both MIT.
|
package/adapters/codex.ts
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
9
9
|
import {
|
|
10
|
-
BASH_Q, EXFIL_Q, SCREEN_Q, STOP_Q, WRITE_Q, applyPatchPaths, callSummary, clip, denyText, gateBash, gateExfil, gateWrite,
|
|
10
|
+
BASH_Q, EXFIL_Q, SCREEN_Q, STOP_Q, WRITE_Q, applyPatchPaths, approveBash, approveWrite, callSummary, clip, clipTail, denyText, gateBash, gateExfil, gateWrite,
|
|
11
11
|
judgeStop, pathDenyText, plainRead, readConfig, sanitizeSid, screen, withPolicies, withPolicyQ, type Config,
|
|
12
12
|
} from '../hooks/policy.ts'
|
|
13
|
-
import { Session, keyOf, pathAllowed, repoRoot, type Env, type Fetch } from './shared.ts'
|
|
13
|
+
import { Session, keyOf, newStuck, pathAllowed, repoRoot, stuckNote, trimOutput, type Env, type Fetch, type Stuck } from './shared.ts'
|
|
14
14
|
|
|
15
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 }
|
|
16
|
+
type Tool = { tool: string; input: string; outcome: 'ok' | 'failed'; output: string }
|
|
17
|
+
type State = { request: string; previous: string[]; count: number; blockedBefore: string; lastBlocked: string; tools: Tool[]; pushedBackAt: number; approved: string[]; stuck: Stuck }
|
|
18
|
+
const EMPTY: State = { request: '', previous: [], count: 0, blockedBefore: '', lastBlocked: '', tools: [], pushedBackAt: -1, approved: [], stuck: { failures: [], askedAt: 0 } }
|
|
19
|
+
/** A call Jev cleared, as PermissionRequest will see it again. */
|
|
20
|
+
const approvalKey = (tool: string, input: Input): string => `${tool}\u0000${str(input, 'command')}`
|
|
19
21
|
type Input = Record<string, unknown>
|
|
20
22
|
const str = (o: Input, k: string): string => (typeof o[k] === 'string' ? (o[k] as string) : '')
|
|
21
23
|
const obj = (v: unknown): Input => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Input) : {})
|
|
@@ -63,7 +65,7 @@ export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise
|
|
|
63
65
|
const prompt = str(input, 'prompt')
|
|
64
66
|
if (!prompt.trim() || /^\/\S+\s*$/.test(prompt.trim())) return undefined
|
|
65
67
|
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: [] })
|
|
68
|
+
Object.assign(st, { request: prompt, count: st.count + 1, blockedBefore: st.lastBlocked, lastBlocked: '', tools: [], stuck: newStuck() })
|
|
67
69
|
await save(env, sid, st)
|
|
68
70
|
return undefined
|
|
69
71
|
}
|
|
@@ -73,7 +75,14 @@ export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise
|
|
|
73
75
|
const toolInput = obj(input.tool_input)
|
|
74
76
|
|
|
75
77
|
if (event === 'PreToolUse') {
|
|
76
|
-
const
|
|
78
|
+
const approve = async () => {
|
|
79
|
+
if (!c.autoApprove) return undefined
|
|
80
|
+
st.approved = [...st.approved, approvalKey(tool, toolInput)].slice(-20)
|
|
81
|
+
await save(env, sid, st)
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
const blocked = async (j: { verdict: unknown; reason: string }, safe = false) => {
|
|
85
|
+
if (j.verdict === null && safe) return approve()
|
|
77
86
|
if (j.verdict !== 'deny') return undefined
|
|
78
87
|
st.lastBlocked = callSummary(tool, toolInput)
|
|
79
88
|
await save(env, sid, st)
|
|
@@ -81,13 +90,13 @@ export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise
|
|
|
81
90
|
}
|
|
82
91
|
if (tool === 'Bash' && c.bashGate) {
|
|
83
92
|
const command = str(toolInput, 'command')
|
|
84
|
-
if (plainRead(command, c)) return
|
|
93
|
+
if (plainRead(command, c)) return approve()
|
|
85
94
|
const state = withPolicies({ command: clip(command, CLIP), cwd, ...(command.length > CLIP ? { truncated: true } : {}) }, c, st.request, st.blockedBefore)
|
|
86
95
|
const d = await x.decide('tool.call', tool, state, withPolicyQ(BASH_Q, c, st.request))
|
|
87
96
|
if (!d) return undefined
|
|
88
97
|
const j = gateBash(d.answers, c)
|
|
89
98
|
x.record(d, j.verdict, j.reason)
|
|
90
|
-
return blocked(j)
|
|
99
|
+
return blocked(j, approveBash(d.answers, c))
|
|
91
100
|
}
|
|
92
101
|
if (tool === 'apply_patch' && c.writeGate) {
|
|
93
102
|
const patch = str(toolInput, 'command')
|
|
@@ -102,7 +111,7 @@ export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise
|
|
|
102
111
|
if (!d) return undefined
|
|
103
112
|
const j = gateWrite(d.answers, c)
|
|
104
113
|
x.record(d, j.verdict, j.reason)
|
|
105
|
-
return blocked(j)
|
|
114
|
+
return blocked(j, approveWrite(d.answers, c))
|
|
106
115
|
}
|
|
107
116
|
if (tool.startsWith('mcp__') && c.exfilGate) {
|
|
108
117
|
const state = withPolicies({ tool, input: clip(JSON.stringify(toolInput), 4000) }, c, st.request, st.blockedBefore)
|
|
@@ -115,19 +124,35 @@ export async function handle(input: Input, env: Env, fetchImpl?: Fetch): Promise
|
|
|
115
124
|
return undefined
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
if (event === 'PermissionRequest') {
|
|
128
|
+
const key = approvalKey(tool, toolInput)
|
|
129
|
+
if (!c.autoApprove || !st.approved.includes(key)) return undefined
|
|
130
|
+
st.approved = st.approved.filter(k => k !== key)
|
|
131
|
+
await save(env, sid, st)
|
|
132
|
+
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' } } }
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
if (event === 'PostToolUse') {
|
|
119
136
|
const response = input.tool_response
|
|
120
137
|
const code = obj(response).exit_code ?? obj(response).exitCode
|
|
121
|
-
|
|
138
|
+
const r = obj(response)
|
|
139
|
+
const shown = typeof response === 'string' ? response : typeof r.output === 'string' ? r.output : typeof r.stdout === 'string' ? r.stdout : JSON.stringify(response ?? '')
|
|
140
|
+
st.tools = [...st.tools, { tool, input: clip(JSON.stringify(toolInput), 200), outcome: typeof code === 'number' && code !== 0 ? 'failed' : 'ok', output: clipTail(shown, 300) } as Tool].slice(-20)
|
|
141
|
+
const failed = typeof code === 'number' && code !== 0
|
|
142
|
+
const stuck = failed ? await stuckNote(x, st.stuck, st.request, tool, toolInput, shown) : null
|
|
122
143
|
await save(env, sid, st)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
const notes = stuck ? [stuck] : []
|
|
145
|
+
// a cut output replaces the result: Codex hooks can't edit it, but a PostToolUse block's reason stands in for it
|
|
146
|
+
const cut = tool === 'Bash' ? await trimOutput(x, st.request, shown) : null
|
|
147
|
+
if (cut) return { decision: 'block', reason: [cut, ...notes].join('\n') }
|
|
148
|
+
const context = () => (notes.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: notes.join('\n') } } : undefined)
|
|
149
|
+
if (!c.injectionScreen || !(tool === 'Bash' || tool.startsWith('mcp__')) || !shown.trim()) return context()
|
|
150
|
+
const d = await x.decide('tool.result', tool, { tool, content: clip(shown, 6000) }, SCREEN_Q)
|
|
151
|
+
if (!d) return context()
|
|
128
152
|
const s = screen(d.answers, c)
|
|
129
153
|
x.record(d, s.flagged ? 'flag' : null, s.reason)
|
|
130
|
-
|
|
154
|
+
if (s.flagged) notes.push(s.note)
|
|
155
|
+
return context()
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
if (event === 'Stop') {
|
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, applyPatchPaths,
|
|
8
|
-
OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
|
|
7
|
+
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, clipTail, 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, newStuck, runBrowse, stuckNote, trimOutput, keyOf, pathAllowed, repoRoot,
|
|
14
14
|
type Env, type Fetch,
|
|
15
15
|
} from './shared.ts'
|
|
16
16
|
|
|
@@ -193,16 +193,18 @@ function sameModel(left: HostModel | undefined, right: HostModel | undefined): b
|
|
|
193
193
|
)
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function toolState(messages: unknown[], after: number): Array<{ tool: string; input: string; outcome: 'ok' | 'failed' | 'pending' }> {
|
|
196
|
+
function toolState(messages: unknown[], after: number): Array<{ tool: string; input: string; outcome: 'ok' | 'failed' | 'pending'; output: string }> {
|
|
197
197
|
const tail = messages.slice(after)
|
|
198
198
|
const outcomes = new Map<string, 'ok' | 'failed'>()
|
|
199
|
+
const outputs = new Map<string, string>()
|
|
199
200
|
for (const message of tail) {
|
|
200
201
|
const m = object(message)
|
|
201
202
|
if (m.role !== 'toolResult') continue
|
|
202
203
|
const id = typeof m.toolCallId === 'string' ? m.toolCallId : ''
|
|
203
204
|
if (id) outcomes.set(id, m.isError === true ? 'failed' : 'ok')
|
|
205
|
+
if (id) outputs.set(id, clipTail(text(m.content), 300))
|
|
204
206
|
}
|
|
205
|
-
const calls: Array<{ tool: string; input: string; outcome: 'ok' | 'failed' | 'pending' }> = []
|
|
207
|
+
const calls: Array<{ tool: string; input: string; outcome: 'ok' | 'failed' | 'pending'; output: string }> = []
|
|
206
208
|
for (const message of tail) {
|
|
207
209
|
const m = object(message)
|
|
208
210
|
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
|
|
@@ -214,6 +216,7 @@ function toolState(messages: unknown[], after: number): Array<{ tool: string; in
|
|
|
214
216
|
tool: typeof p.name === 'string' ? p.name : '',
|
|
215
217
|
input: clip(safeJson(p.arguments), 200),
|
|
216
218
|
outcome: outcomes.get(id) ?? 'pending',
|
|
219
|
+
output: outputs.get(id) ?? '',
|
|
217
220
|
})
|
|
218
221
|
}
|
|
219
222
|
}
|
|
@@ -247,10 +250,12 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
247
250
|
let liveRequest = ''
|
|
248
251
|
let lastBlocked = '' // the call a gate denied this turn
|
|
249
252
|
let blockedBefore = '' // what was denied in the turn before this request: a "yes" confirms it
|
|
253
|
+
let stuck = newStuck()
|
|
250
254
|
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
251
255
|
let compactPending = false
|
|
252
256
|
let classifiedPrompt: string | undefined
|
|
253
257
|
let askRegistered = false
|
|
258
|
+
let browseRegistered = false
|
|
254
259
|
let sessionGeneration = 0
|
|
255
260
|
let userEffort: string | undefined
|
|
256
261
|
const routedThinkingEntries = new Set<string>()
|
|
@@ -423,6 +428,35 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
423
428
|
})
|
|
424
429
|
askRegistered = true
|
|
425
430
|
}
|
|
431
|
+
if (c.browse && !browseRegistered) {
|
|
432
|
+
const t = pi.typebox.Type
|
|
433
|
+
pi.registerTool({
|
|
434
|
+
name: 'browse',
|
|
435
|
+
label: 'Browse',
|
|
436
|
+
description: BROWSE_DESCRIPTION,
|
|
437
|
+
parameters: t.Object({
|
|
438
|
+
url: t.String({ description: 'Where to start, an http(s) URL' }),
|
|
439
|
+
goal: t.String({ description: 'One narrow goal, and when to stop' }),
|
|
440
|
+
values: t.Optional(t.Array(t.String(), { description: 'Exact text the task may type; Jev puts each into the field that needs it' })),
|
|
441
|
+
}),
|
|
442
|
+
approval: 'exec',
|
|
443
|
+
async execute(_id, params, _signal, _update, toolCtx) {
|
|
444
|
+
try {
|
|
445
|
+
touch(toolCtx)
|
|
446
|
+
if (!session) return { content: [{ type: 'text', text: JSON.stringify({ error: 'jev session unavailable' }) }] }
|
|
447
|
+
const r = await runBrowse(session, params, userRequest, blockedBefore)
|
|
448
|
+
if ('deny' in r) {
|
|
449
|
+
lastBlocked = callSummary('browse', params)
|
|
450
|
+
return { content: [{ type: 'text', text: r.deny }], details: {}, isError: true }
|
|
451
|
+
}
|
|
452
|
+
return { content: [{ type: 'text', text: r.text }], details: {} }
|
|
453
|
+
} catch {
|
|
454
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'browse failed' }) }], details: {} }
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
browseRegistered = true
|
|
459
|
+
}
|
|
426
460
|
}
|
|
427
461
|
|
|
428
462
|
const reinitialize = async (ctx: HostContext): Promise<void> => {
|
|
@@ -543,23 +577,30 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
543
577
|
pi.on('tool_result', async (rawEvent, ctx) => {
|
|
544
578
|
try {
|
|
545
579
|
touch(ctx)
|
|
546
|
-
if (!session || !c.enabled
|
|
580
|
+
if (!session || !c.enabled) return
|
|
547
581
|
const event = rawEvent as ToolResult
|
|
548
582
|
const tool = event.toolName
|
|
549
583
|
const input = object(event.input)
|
|
584
|
+
let resultText = event.content.map(part => part.type === 'text' && typeof part.text === 'string' ? part.text : '').filter(Boolean).join('\n')
|
|
585
|
+
const cut = tool === 'bash' ? await trimOutput(session, userRequest, resultText) : null
|
|
586
|
+
if (cut) resultText = cut
|
|
587
|
+
const base = cut ? [...event.content.filter(part => part.type !== 'text'), { type: 'text', text: cut }] : event.content
|
|
588
|
+
const stuckText = (event as { isError?: boolean }).isError ? await stuckNote(session, stuck, userRequest, tool, input, resultText) : null
|
|
589
|
+
const content = stuckText ? [...base, { type: 'text', text: stuckText }] : base
|
|
590
|
+
const changed = stuckText || cut
|
|
591
|
+
if (!c.injectionScreen) return changed ? { content } : undefined
|
|
550
592
|
let shouldScreen = tool === 'bash' || tool === 'github' || exfilTool(tool, input)
|
|
551
593
|
if (tool === 'read' && !urlRead(tool, input) && c.screenReads) {
|
|
552
594
|
const path = string(input, 'path')
|
|
553
595
|
shouldScreen = !sessionLocalWrite(path) && !(await pathAllowed(path, ctx.cwd, root, c, env))
|
|
554
596
|
}
|
|
555
|
-
if (!shouldScreen) return
|
|
556
|
-
const resultText = event.content.map(part => part.type === 'text' && typeof part.text === 'string' ? part.text : '').filter(Boolean).join('\n')
|
|
557
|
-
if (!resultText.trim()) return
|
|
597
|
+
if (!shouldScreen || !resultText.trim()) return changed ? { content } : undefined
|
|
558
598
|
const d = await session.decide('tool_result', tool, { tool, content: clip(resultText, 6000) }, SCREEN_Q)
|
|
559
|
-
if (!d) return
|
|
599
|
+
if (!d) return changed ? { content } : undefined
|
|
560
600
|
const screened = screen(d.answers, c)
|
|
561
601
|
session.record(d, screened.flagged ? 'flag' : null, screened.reason)
|
|
562
|
-
if (screened.flagged) return { content: [...
|
|
602
|
+
if (screened.flagged) return { content: [...content, { type: 'text', text: screened.note }] }
|
|
603
|
+
return changed ? { content } : undefined
|
|
563
604
|
} catch {
|
|
564
605
|
return
|
|
565
606
|
}
|
|
@@ -620,6 +661,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
620
661
|
userEffort = userThinking(ctx)
|
|
621
662
|
const prompt = typeof rawEvent.prompt === 'string' ? rawEvent.prompt : ''
|
|
622
663
|
if (prompt.trim() && !/^\/\S+\s*$/.test(prompt.trim())) {
|
|
664
|
+
stuck = newStuck()
|
|
623
665
|
userRequest = prompt
|
|
624
666
|
blockedBefore = lastBlocked
|
|
625
667
|
lastBlocked = ''
|
package/adapters/pi.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
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
|
-
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead, configCommand,
|
|
5
|
-
OMJ_HELP, OMJ_OFF, OMJ_ON, toggleArg,
|
|
4
|
+
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, clipTail, plainRead, configCommand,
|
|
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, newStuck, runBrowse, stuckNote, trimOutput, keyOf, pathAllowed, repoRoot,
|
|
11
11
|
type Env, type Fetch,
|
|
12
12
|
} from './shared.ts'
|
|
13
13
|
|
|
@@ -173,20 +173,23 @@ function messagesForStop(messages: HostMessage[]) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
const tools: Array<{ id: string; tool: string; input: string; outcome: 'ok' | 'failed' | 'pending' }> = []
|
|
176
|
+
const tools: Array<{ id: string; tool: string; input: string; outcome: 'ok' | 'failed' | 'pending'; output: string }> = []
|
|
177
177
|
const byId = new Map<string, (typeof tools)[number]>()
|
|
178
178
|
for (const message of messages.slice(lastRequest + 1)) {
|
|
179
179
|
if (message.role === 'assistant' && Array.isArray(message.content)) {
|
|
180
180
|
for (const part of message.content) {
|
|
181
181
|
const block = entry(part)
|
|
182
182
|
if (block.type !== 'toolCall' || typeof block.id !== 'string' || typeof block.name !== 'string') continue
|
|
183
|
-
const call = { id: block.id, tool: block.name, input: clip(json(block.arguments), 200), outcome: 'pending' as
|
|
183
|
+
const call = { id: block.id, tool: block.name, input: clip(json(block.arguments), 200), outcome: 'pending' as 'ok' | 'failed' | 'pending', output: '' }
|
|
184
184
|
tools.push(call)
|
|
185
185
|
byId.set(call.id, call)
|
|
186
186
|
}
|
|
187
187
|
} else if (message.role === 'toolResult' && typeof message.toolCallId === 'string') {
|
|
188
188
|
const call = byId.get(message.toolCallId)
|
|
189
|
-
if (call)
|
|
189
|
+
if (call) {
|
|
190
|
+
call.outcome = message.isError ? 'failed' : 'ok'
|
|
191
|
+
call.output = clipTail(textOf(message.content), 300)
|
|
192
|
+
}
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
|
|
@@ -196,7 +199,7 @@ function messagesForStop(messages: HostMessage[]) {
|
|
|
196
199
|
state: {
|
|
197
200
|
current_request: clip(textOf(requests.at(-1)?.content), 600),
|
|
198
201
|
previous_requests: requests.slice(-6, -1).map(message => clip(textOf(message.content), 200)),
|
|
199
|
-
tools_this_turn: tools.slice(-20).map(({ tool, input, outcome }) => ({ tool, input, outcome })),
|
|
202
|
+
tools_this_turn: tools.slice(-20).map(({ tool, input, outcome, output }) => ({ tool, input, outcome, output })),
|
|
200
203
|
last_assistant_message: clip(textOf(lastAssistant?.content), 1500),
|
|
201
204
|
},
|
|
202
205
|
}
|
|
@@ -259,6 +262,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
259
262
|
let liveRequest = ''
|
|
260
263
|
let lastBlocked = '' // the call a gate denied this turn
|
|
261
264
|
let blockedBefore = '' // what was denied in the turn before this request: a "yes" confirms it
|
|
265
|
+
let stuck = newStuck()
|
|
262
266
|
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
263
267
|
let compactPending = false
|
|
264
268
|
let userThinking: ThinkingLevel = 'medium'
|
|
@@ -354,6 +358,29 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
354
358
|
})
|
|
355
359
|
askRegistered = true
|
|
356
360
|
}
|
|
361
|
+
let browseRegistered = false
|
|
362
|
+
const registerBrowse = (): void => {
|
|
363
|
+
if (browseRegistered || !c.browse) return
|
|
364
|
+
pi.registerTool({
|
|
365
|
+
name: 'browse',
|
|
366
|
+
label: 'Browse',
|
|
367
|
+
description: BROWSE_DESCRIPTION,
|
|
368
|
+
parameters: BROWSE_SCHEMA,
|
|
369
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
370
|
+
try {
|
|
371
|
+
const r = await runBrowse(await ensureSession(ctx), params, userRequest, blockedBefore)
|
|
372
|
+
if ('deny' in r) {
|
|
373
|
+
lastBlocked = callSummary('browse', params)
|
|
374
|
+
return { content: [{ type: 'text', text: r.deny }], details: undefined, isError: true }
|
|
375
|
+
}
|
|
376
|
+
return { content: [{ type: 'text', text: r.text }], details: undefined }
|
|
377
|
+
} catch {
|
|
378
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'browse failed' }) }], details: undefined }
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
browseRegistered = true
|
|
383
|
+
}
|
|
357
384
|
|
|
358
385
|
/** /omj and /ohmyjev: the report, `settings`, or `on`/`off` for this session (the enabled setting is the default). */
|
|
359
386
|
const command = async (args: string, ctx: PiContext): Promise<void> => {
|
|
@@ -389,6 +416,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
389
416
|
userThinking = pi.getThinkingLevel()
|
|
390
417
|
const x = await ensureSession(ctx)
|
|
391
418
|
registerAsk()
|
|
419
|
+
registerBrowse()
|
|
392
420
|
const key = keyOf(c, env)
|
|
393
421
|
say(
|
|
394
422
|
ctx,
|
|
@@ -415,6 +443,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
415
443
|
pi.on('before_agent_start', guarded<BeforeAgentStartEvent, void>(async (event, ctx) => {
|
|
416
444
|
latestCtx = ctx
|
|
417
445
|
if (event.prompt.trim() && !/^\/\S+\s*$/.test(event.prompt.trim())) {
|
|
446
|
+
stuck = newStuck()
|
|
418
447
|
userRequest = event.prompt
|
|
419
448
|
blockedBefore = lastBlocked
|
|
420
449
|
lastBlocked = ''
|
|
@@ -495,27 +524,38 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
495
524
|
}
|
|
496
525
|
}))
|
|
497
526
|
|
|
498
|
-
pi.on('tool_result', guarded<ToolResultEvent, { content: Content[]; structuredContent?: unknown }>(async (
|
|
527
|
+
pi.on('tool_result', guarded<ToolResultEvent, { content: Content[]; structuredContent?: unknown }>(async (evt, ctx) => {
|
|
528
|
+
let event = evt
|
|
499
529
|
latestCtx = ctx
|
|
500
530
|
if (!c.enabled) return
|
|
501
531
|
const tool = event.toolName
|
|
502
532
|
const shell = tool === 'bash' || tool === 'powershell'
|
|
503
|
-
|
|
533
|
+
let text = event.content.filter((part): part is TextContent => part.type === 'text').map(part => part.text).join('\n')
|
|
534
|
+
const cut = shell ? await trimOutput(await ensureSession(ctx), userRequest, text) : null
|
|
535
|
+
if (cut) {
|
|
536
|
+
text = cut
|
|
537
|
+
event = { ...event, content: [...event.content.filter(part => part.type !== 'text'), { type: 'text', text: cut }] }
|
|
538
|
+
}
|
|
539
|
+
const stuckText = event.isError ? await stuckNote(await ensureSession(ctx), stuck, userRequest, tool, event.input, text) : null
|
|
540
|
+
const withStuck = stuckText
|
|
541
|
+
? { content: [...event.content, { type: 'text' as const, text: stuckText }], ...(Object.hasOwn(event, 'structuredContent') ? { structuredContent: event.structuredContent } : {}) }
|
|
542
|
+
: undefined
|
|
543
|
+
const changed = withStuck ?? (cut ? { content: event.content, ...(Object.hasOwn(event, 'structuredContent') ? { structuredContent: event.structuredContent } : {}) } : undefined)
|
|
544
|
+
if (!c.injectionScreen || (event.isError && !shell)) return changed
|
|
504
545
|
const candidate = shell || tool.startsWith('mcp__') || tool === 'read'
|
|
505
|
-
if (!candidate) return
|
|
546
|
+
if (!candidate) return changed
|
|
506
547
|
if (tool === 'read') {
|
|
507
|
-
if (!c.screenReads || await pathAllowed(arg(event.input, 'path'), ctx.cwd, root, c, env)) return
|
|
548
|
+
if (!c.screenReads || await pathAllowed(arg(event.input, 'path'), ctx.cwd, root, c, env)) return changed
|
|
508
549
|
}
|
|
509
|
-
|
|
510
|
-
if (!content.trim()) return
|
|
550
|
+
if (!text.trim()) return changed
|
|
511
551
|
const x = await ensureSession(ctx)
|
|
512
|
-
const d = await x.decide('tool.result', tool, { tool, content: clip(
|
|
513
|
-
if (!d) return
|
|
552
|
+
const d = await x.decide('tool.result', tool, { tool, content: clip(text, 6000) }, SCREEN_Q)
|
|
553
|
+
if (!d) return changed
|
|
514
554
|
const judged = screen(d.answers, c)
|
|
515
555
|
x.record(d, judged.flagged ? 'flag' : null, judged.reason)
|
|
516
|
-
if (!judged.flagged) return
|
|
556
|
+
if (!judged.flagged) return changed
|
|
517
557
|
return {
|
|
518
|
-
content: [...event.content, { type: 'text', text: judged.note }],
|
|
558
|
+
content: [...(changed?.content ?? event.content), { type: 'text', text: judged.note }],
|
|
519
559
|
...(Object.hasOwn(event, 'structuredContent') ? { structuredContent: event.structuredContent } : {}),
|
|
520
560
|
}
|
|
521
561
|
}))
|
package/adapters/shared.ts
CHANGED
|
@@ -8,8 +8,8 @@ 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,
|
|
12
|
-
splitList, statusText, summarize, type Answers, type Config, type LogEntry, type Questions, type SessionState, type Verdict,
|
|
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
|
+
STUCK_Q, trimChunks, trimRequest, trimmed, failureOf, judgeStuck, stuckDue, stuckState, splitList, statusText, summarize, type Failure, type Answers, type Config, type LogEntry, type Questions, type SessionState, type Verdict,
|
|
13
13
|
} from '../hooks/policy.ts'
|
|
14
14
|
|
|
15
15
|
export type Env = Readonly<Record<string, string | undefined>>
|
|
@@ -288,3 +288,82 @@ 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
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- stuck check: the same approach failing over and over ---
|
|
338
|
+
|
|
339
|
+
/** One request's failed calls; reset it when the user sends a new request. */
|
|
340
|
+
export type Stuck = { failures: Failure[]; askedAt: number }
|
|
341
|
+
export const newStuck = (): Stuck => ({ failures: [], askedAt: 0 })
|
|
342
|
+
|
|
343
|
+
/** Records a failed call and, when due, asks Jev whether the attempts repeat: the note for the model, or null. */
|
|
344
|
+
export async function stuckNote(x: Session, st: Stuck, request: string, tool: string, input: unknown, output: unknown): Promise<string | null> {
|
|
345
|
+
if (!x.c.enabled || !x.c.stuckCheck) return null
|
|
346
|
+
st.failures.push(failureOf(tool, input, output))
|
|
347
|
+
if (!stuckDue(st.failures.length, st.askedAt)) return null
|
|
348
|
+
st.askedAt = st.failures.length
|
|
349
|
+
const d = await x.decide('tool.result', 'stuck', stuckState(request, st.failures), STUCK_Q)
|
|
350
|
+
if (!d) return null
|
|
351
|
+
const note = judgeStuck(d.answers, x.c, st.failures.length)
|
|
352
|
+
x.record(d, note ? 'flag' : null, note ? 'stuck' : 'not stuck')
|
|
353
|
+
return note
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- output trim ---
|
|
357
|
+
|
|
358
|
+
/** A long shell output cut to the parts the request needs; null to keep it whole. */
|
|
359
|
+
export async function trimOutput(x: Session, request: string, output: string): Promise<string | null> {
|
|
360
|
+
if (!x.c.enabled) return null
|
|
361
|
+
const chunks = trimChunks(output, x.c)
|
|
362
|
+
if (!chunks.length) return null
|
|
363
|
+
const { state, questions } = trimRequest(request, chunks)
|
|
364
|
+
const d = await x.decide('tool.result', 'trim', state, questions)
|
|
365
|
+
if (!d) return null
|
|
366
|
+
const out = trimmed(chunks, d.answers, x.c)
|
|
367
|
+
x.record(d, null, out ? `trimmed ${output.length} to ${out.length} characters` : 'kept whole')
|
|
368
|
+
return out
|
|
369
|
+
}
|
package/hooks/policy.ts
CHANGED
|
@@ -25,7 +25,19 @@ export const DEFAULTS = {
|
|
|
25
25
|
doneAsksUserMax: 0.5,
|
|
26
26
|
exfil: 0.7,
|
|
27
27
|
requested: 0.8,
|
|
28
|
+
autoApprove: true,
|
|
29
|
+
stuckCheck: true,
|
|
30
|
+
skillPick: true,
|
|
31
|
+
skillGate: 0.3,
|
|
32
|
+
skillMin: 0.4,
|
|
33
|
+
trimOutput: true,
|
|
34
|
+
trimOver: 12000,
|
|
35
|
+
trimKeep: 0.3,
|
|
36
|
+
stuck: 0.7,
|
|
37
|
+
approveConfidence: 0.9,
|
|
38
|
+
approveRiskMax: 0.2,
|
|
28
39
|
askJev: true,
|
|
40
|
+
browse: false,
|
|
29
41
|
autoCompact: true,
|
|
30
42
|
compactSwitched: 0.8,
|
|
31
43
|
compactBoundary: 0.6,
|
|
@@ -184,12 +196,33 @@ export const STOP_Q: Questions = {
|
|
|
184
196
|
claimed_done: noul('Does `last_assistant_message` say the task in `current_request` is complete?'),
|
|
185
197
|
verified: noul(
|
|
186
198
|
'Is there evidence the work was checked?',
|
|
187
|
-
'Tests, a build or the program ran and did not fail, output was quoted or inspected; `tools_this_turn` shows such a check with outcome ok',
|
|
199
|
+
'Tests, a build or the program ran and did not fail, output was quoted or inspected; `tools_this_turn` shows such a check with outcome ok and an `output` that confirms it',
|
|
188
200
|
'Only claims success, edited without running or inspecting anything, or every check is pending, backgrounded or failed',
|
|
189
201
|
),
|
|
190
202
|
asks_user: noul('Does `last_assistant_message` end by asking the user a question or reporting a blocker it cannot resolve?'),
|
|
191
203
|
at_boundary: noul('Did the last turn finish a unit of work rather than stop mid-step?'),
|
|
192
204
|
}
|
|
205
|
+
export const STUCK_Q: Questions = {
|
|
206
|
+
stuck: noul(
|
|
207
|
+
'Do the attempts in `failures` repeat one approach and fail the same way, so trying it again will not help?',
|
|
208
|
+
'The same command, edit or fix, re-run with small changes, and the same error or the same kind of failure each time',
|
|
209
|
+
'Different approaches, different errors, or each attempt gets further than the last',
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
/** A failed tool call, as the stuck check reads it. */
|
|
213
|
+
export type Failure = { tool: string; input: string; output: string }
|
|
214
|
+
export const failureOf = (tool: string, input: unknown, output: unknown): Failure =>
|
|
215
|
+
({ tool, input: clip(typeof input === 'string' ? input : JSON.stringify(input), 300), output: clipTail(output, 300) })
|
|
216
|
+
/** Ask after the third failure in a request, then after every third more: rarely, and only while it keeps failing. */
|
|
217
|
+
export const stuckDue = (failures: number, askedAt: number): boolean => failures >= 3 && failures - askedAt >= 3
|
|
218
|
+
export const stuckState = (request: string, failures: Failure[]) => ({ current_request: clip(request, 600), failures: failures.slice(-6) })
|
|
219
|
+
export function judgeStuck(a: Answers, c: Config, n: number): string | null {
|
|
220
|
+
const p = nv(a, 'stuck')
|
|
221
|
+
return p >= c.stuck
|
|
222
|
+
? `[ohmyjev] The last ${n} failed attempts repeat one approach and fail the same way (${f2(p)}). Stop repeating it: read the error, try a different approach, or ask the user.`
|
|
223
|
+
: null
|
|
224
|
+
}
|
|
225
|
+
|
|
193
226
|
export const SWITCHED_Q: Questions = {
|
|
194
227
|
switched_gears: noul('Is `current_request` a different task from `previous_requests`, so the earlier work is no longer needed?'),
|
|
195
228
|
}
|
|
@@ -203,6 +236,11 @@ export const clip = (text: unknown, n: number): string => {
|
|
|
203
236
|
const s = typeof text === 'string' ? text : ''
|
|
204
237
|
return s.length <= n ? s : s.slice(0, n - 1) + '…'
|
|
205
238
|
}
|
|
239
|
+
/** The end of a tool's output, where a test run's verdict and an error's last line are. */
|
|
240
|
+
export const clipTail = (text: unknown, n: number): string => {
|
|
241
|
+
const s = typeof text === 'string' ? text.trim() : ''
|
|
242
|
+
return s.length <= n ? s : '…' + s.slice(-(n - 1))
|
|
243
|
+
}
|
|
206
244
|
export const sanitizeSid = (sid: string): string => sid.replace(/[^\w-]/g, '') || 'unknown'
|
|
207
245
|
export const denyText = (reason: string): string => `ohmyjev blocked this: ${reason}. ${BLOCK_NOTICE}`
|
|
208
246
|
/** A path deny is a setting, not a hazard: the model must not route around it, but it should tell the user how to widen it. */
|
|
@@ -287,6 +325,21 @@ export function gateBash(a: Answers, c: Config): Judged {
|
|
|
287
325
|
return { verdict: null, reason: `${effect.choice} (${f2(effect.confidence)}), destructive ${f2(destructive)}, exfil ${f2(out)}` }
|
|
288
326
|
}
|
|
289
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Whether the host may skip asking the person: Jev is sure the command only reads or makes a change git or a reinstall
|
|
330
|
+
* undoes, and sees no wipe and no data leaving. Policies make every call worth a look, so nothing is approved then.
|
|
331
|
+
*/
|
|
332
|
+
export function approveBash(a: Answers, c: Config): boolean {
|
|
333
|
+
const effect = ch(a, 'effect')
|
|
334
|
+
return c.autoApprove && !splitList(c.policies).length && effect.choice !== 'irreversible' && effect.confidence >= c.approveConfidence &&
|
|
335
|
+
nv(a, 'destructive_intent') < c.approveRiskMax && nv(a, 'exfiltrates') < c.approveRiskMax
|
|
336
|
+
}
|
|
337
|
+
/** The same for an edit inside the repo: no credential in it, not a secrets file. */
|
|
338
|
+
export function approveWrite(a: Answers, c: Config): boolean {
|
|
339
|
+
const kind = ch(a, 'kind')
|
|
340
|
+
return c.autoApprove && !splitList(c.policies).length && nv(a, 'contains_secret') < c.approveRiskMax && kind.choice !== 'secrets'
|
|
341
|
+
}
|
|
342
|
+
|
|
290
343
|
export function gateWrite(a: Answers, c: Config): Judged {
|
|
291
344
|
const kind = ch(a, 'kind')
|
|
292
345
|
const secret = nv(a, 'contains_secret')
|
|
@@ -592,3 +645,217 @@ export function summarize(log: string): string {
|
|
|
592
645
|
...denies.slice(-5).map(r => ` ${r.tool || r.event}: ${r.reason ?? ''}`),
|
|
593
646
|
].join('\n')
|
|
594
647
|
}
|
|
648
|
+
|
|
649
|
+
// --- browse: jev-ultrafast (github.com/browser-use/jev-ultrafast, MIT) drives Chrome, Jev picks each step ---
|
|
650
|
+
|
|
651
|
+
export const JEV_ULTRAFAST = 'git+https://github.com/browser-use/jev-ultrafast@1231850a0bf1a0c0341fe408ef1668dbbfdfac46'
|
|
652
|
+
export const BROWSE_TIMEOUT_MS = 180_000
|
|
653
|
+
export const BROWSE_DESCRIPTION =
|
|
654
|
+
'Do a task in a real browser tab, fast: Jev picks each click, field and option from the page\'s controls (about one ' +
|
|
655
|
+
'second per step). Give a starting URL and one narrow goal, like "find one-way flights Zurich to London on Sept 20, ' +
|
|
656
|
+
'stop when results show" or "open the install section of these docs", with the text to type in `values`. Returns ' +
|
|
657
|
+
'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.'
|
|
658
|
+
export const BROWSE_SCHEMA = {
|
|
659
|
+
type: 'object',
|
|
660
|
+
properties: {
|
|
661
|
+
url: { type: 'string', description: 'Where to start, an http(s) URL' },
|
|
662
|
+
goal: { type: 'string', description: 'One narrow goal, and when to stop' },
|
|
663
|
+
values: {
|
|
664
|
+
type: 'array',
|
|
665
|
+
items: { type: 'string' },
|
|
666
|
+
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',
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
required: ['url', 'goal'],
|
|
670
|
+
} as const
|
|
671
|
+
|
|
672
|
+
/** Runs one jev-ultrafast Agent and prints one JSON line: where it ended, its steps, the visible text; or an error. */
|
|
673
|
+
const BROWSE_PY = `
|
|
674
|
+
import glob, json, os, shutil, subprocess, sys, tempfile, time
|
|
675
|
+
|
|
676
|
+
def chromium():
|
|
677
|
+
if os.environ.get("CHROME_PATH"): return os.environ["CHROME_PATH"]
|
|
678
|
+
for app in ["Google Chrome", "Chromium", "Brave Browser", "Microsoft Edge"]:
|
|
679
|
+
p = f"/Applications/{app}.app/Contents/MacOS/{app}"
|
|
680
|
+
if os.path.exists(p): return p
|
|
681
|
+
for name in ["google-chrome", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]:
|
|
682
|
+
if shutil.which(name): return shutil.which(name)
|
|
683
|
+
home = os.path.expanduser("~")
|
|
684
|
+
found = sorted(glob.glob(home + "/Library/Caches/ms-playwright/chromium-*/chrome-mac*/*.app/Contents/MacOS/*")
|
|
685
|
+
+ glob.glob(home + "/.cache/ms-playwright/chromium-*/chrome-linux*/chrome")
|
|
686
|
+
+ glob.glob(home + "/.omp/puppeteer/chrome/*/*/*.app/Contents/MacOS/*"))
|
|
687
|
+
return found[-1] if found else None
|
|
688
|
+
|
|
689
|
+
def headless():
|
|
690
|
+
exe = chromium()
|
|
691
|
+
if not exe: raise RuntimeError("no Chrome, Chromium, Brave or Edge found; install one or set CHROME_PATH")
|
|
692
|
+
profile = os.path.expanduser("~/.ohmyjev/browser")
|
|
693
|
+
os.makedirs(profile, exist_ok=True)
|
|
694
|
+
port_file = os.path.join(profile, "DevToolsActivePort")
|
|
695
|
+
if os.path.exists(port_file): os.remove(port_file)
|
|
696
|
+
proc = subprocess.Popen([exe, "--headless=new", "--remote-debugging-port=0", "--user-data-dir=" + profile,
|
|
697
|
+
"--no-first-run", "--no-default-browser-check", "--window-size=1120,780", "about:blank"],
|
|
698
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
699
|
+
for _ in range(150):
|
|
700
|
+
if os.path.exists(port_file) and open(port_file).read().strip(): break
|
|
701
|
+
time.sleep(0.05)
|
|
702
|
+
else:
|
|
703
|
+
proc.kill(); raise RuntimeError("the headless browser did not start: " + exe)
|
|
704
|
+
os.environ["BU_CDP_URL"] = "http://127.0.0.1:" + open(port_file).read().split()[0]
|
|
705
|
+
return proc
|
|
706
|
+
|
|
707
|
+
def pick_value(context):
|
|
708
|
+
# No text model: Jev picks, from the values the coding agent gave, the one this field needs.
|
|
709
|
+
from jev_ultrafast.model import post_json
|
|
710
|
+
values = json.loads(os.environ.get("OMJ_BROWSE_VALUES") or "[]")
|
|
711
|
+
criteria = {f"v{i}": v for i, v in enumerate(values)}
|
|
712
|
+
criteria["none"] = "None of these is what this field needs"
|
|
713
|
+
started = time.time()
|
|
714
|
+
body = {"model": os.environ.get("TYPESAFE_MODEL", "jev-latest"), "state": context, "questions": {"value": {
|
|
715
|
+
"type": "choice", "instructions": "Which value should be typed into \`field\` to make progress on \`goal\`?", "criteria": criteria}}}
|
|
716
|
+
answer = post_json("https://api.typesafe.ai/v1/systemone", os.environ["TYPESAFE_API_KEY"], body)["answers"]["value"]["choice"]
|
|
717
|
+
if answer not in criteria or answer == "none":
|
|
718
|
+
raise ValueError("no given value fits the field " + json.dumps(context["field"].get("label")) + "; pass the text it needs in values")
|
|
719
|
+
return criteria[answer], {"model": "jev (picked from values)", "latency_ms": round((time.time() - started) * 1000), "usage": {}}
|
|
720
|
+
|
|
721
|
+
proc = None
|
|
722
|
+
try:
|
|
723
|
+
if not (os.environ.get("BU_CDP_URL") or os.environ.get("BU_CDP_WS")): proc = headless()
|
|
724
|
+
from jev_ultrafast import Agent
|
|
725
|
+
import jev_ultrafast.agent
|
|
726
|
+
if not os.environ.get("TEXT_MODEL_API_KEY"): jev_ultrafast.agent.field_text = pick_value
|
|
727
|
+
with Agent(sys.argv[1], sys.argv[2]) as agent:
|
|
728
|
+
for _ in agent.run():
|
|
729
|
+
pass
|
|
730
|
+
s = agent.snapshot()
|
|
731
|
+
except Exception as e:
|
|
732
|
+
print(json.dumps({"error": str(e)[:500]})); sys.exit(0)
|
|
733
|
+
finally:
|
|
734
|
+
if proc: proc.terminate()
|
|
735
|
+
p = s["page"]
|
|
736
|
+
steps = [h["kind"] + " " + h["action"] + (" = " + h["text"] if h["text"] else "") for h in s["history"]]
|
|
737
|
+
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]}))
|
|
738
|
+
`
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The process that runs a browse call, or the error to hand back. jev-ultrafast speaks only TypeSafe's endpoint, and
|
|
742
|
+
* types into fields with a small text model: TEXT_MODEL_API_KEY, else OPENROUTER_API_KEY with mercury-2.5.
|
|
743
|
+
*/
|
|
744
|
+
export function browseCommand(
|
|
745
|
+
input: { url?: unknown; goal?: unknown; values?: unknown },
|
|
746
|
+
c: Config,
|
|
747
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
748
|
+
): { argv: string[]; env: Record<string, string> } | string {
|
|
749
|
+
const url = typeof input.url === 'string' ? input.url.trim() : ''
|
|
750
|
+
const goal = typeof input.goal === 'string' ? input.goal.trim() : ''
|
|
751
|
+
if (!/^https?:\/\/\S+$/i.test(url)) return 'url must be an http(s) URL'
|
|
752
|
+
if (!goal) return 'goal is required'
|
|
753
|
+
const typesafe = c.apiKey || env.TYPESAFE_API_KEY
|
|
754
|
+
if (!typesafe) return 'browse needs a TypeSafe key (TYPESAFE_API_KEY or the apiKey setting); OpenRouter does not serve its requests'
|
|
755
|
+
const text: Record<string, string> = env.TEXT_MODEL_API_KEY
|
|
756
|
+
? { TEXT_MODEL_API_KEY: env.TEXT_MODEL_API_KEY }
|
|
757
|
+
: env.OPENROUTER_API_KEY
|
|
758
|
+
? { 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' }
|
|
759
|
+
: {}
|
|
760
|
+
return {
|
|
761
|
+
argv: ['uv', 'run', '--no-project', '--quiet', '--with', JEV_ULTRAFAST, 'python', '-c', BROWSE_PY, url, goal],
|
|
762
|
+
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)) } : {}) },
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The runner's stdout as the tool's text: its JSON line, or an error that says what to fix. */
|
|
767
|
+
export function browseReply(stdout: string, stderr = ''): string {
|
|
768
|
+
const line = stdout.trim().split('\n').at(-1) ?? ''
|
|
769
|
+
try {
|
|
770
|
+
const out = JSON.parse(line) as { error?: string }
|
|
771
|
+
if (out.error && /TEXT_MODEL_API_KEY/.test(out.error)) out.error += '. Pass the text to type in values, or set OPENROUTER_API_KEY'
|
|
772
|
+
return JSON.stringify(out)
|
|
773
|
+
} catch {
|
|
774
|
+
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)
|
|
775
|
+
return JSON.stringify({ error: `browse could not run: ${why}. Chrome needs remote debugging allowed the first time.` })
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- output trim: a long tool output cut to the parts the request needs ---
|
|
780
|
+
|
|
781
|
+
const TRIM_CHUNK = 1500
|
|
782
|
+
const TRIM_CHUNKS = 24
|
|
783
|
+
|
|
784
|
+
/** The output in line-aligned chunks of about TRIM_CHUNK characters, at most TRIM_CHUNKS of them; none when short. */
|
|
785
|
+
export function trimChunks(output: string, c: Config): string[] {
|
|
786
|
+
if (!c.trimOutput || output.length <= c.trimOver) return []
|
|
787
|
+
const chunks: string[] = []
|
|
788
|
+
let cur = ''
|
|
789
|
+
for (const line of output.split('\n')) {
|
|
790
|
+
if (cur && cur.length + line.length + 1 > TRIM_CHUNK) {
|
|
791
|
+
chunks.push(cur)
|
|
792
|
+
cur = ''
|
|
793
|
+
}
|
|
794
|
+
cur = cur ? `${cur}\n${line}` : line
|
|
795
|
+
}
|
|
796
|
+
if (cur) chunks.push(cur)
|
|
797
|
+
return chunks.length >= 4 && chunks.length <= TRIM_CHUNKS ? chunks : []
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** One question per chunk, all in one Jev call; the first and last chunks are always kept and not asked about. */
|
|
801
|
+
export function trimRequest(request: string, chunks: string[]) {
|
|
802
|
+
const middle = chunks.slice(1, -1)
|
|
803
|
+
const questions: Questions = Object.fromEntries(middle.map((_, i) => [`c${i + 1}`, noul(
|
|
804
|
+
`Does \`chunks.c${i + 1}\` hold something \`request\` needs: an error, a failing test, a result, or the data asked for?`,
|
|
805
|
+
'An error or warning, a failure, a value or line the request is about',
|
|
806
|
+
'Progress lines, passing items, boilerplate or data unrelated to the request',
|
|
807
|
+
)]))
|
|
808
|
+
return { state: { request: clip(request, 1500), chunks: Object.fromEntries(middle.map((t, i) => [`c${i + 1}`, t])) }, questions }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The output with the chunks Jev rated unrelated replaced by one line each run; null when too little would go. */
|
|
812
|
+
export function trimmed(chunks: string[], a: Answers, c: Config): string | null {
|
|
813
|
+
const keep = chunks.map((_, i) => i === 0 || i === chunks.length - 1 || nv(a, `c${i}`) >= c.trimKeep)
|
|
814
|
+
const total = chunks.reduce((n, t) => n + t.length, 0)
|
|
815
|
+
const kept = chunks.reduce((n, t, i) => n + (keep[i] ? t.length : 0), 0)
|
|
816
|
+
if (kept > total * 0.7) return null
|
|
817
|
+
const out: string[] = []
|
|
818
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
819
|
+
if (keep[i]) out.push(chunks[i]!)
|
|
820
|
+
else if (keep[i - 1] !== false) {
|
|
821
|
+
let lines = 0
|
|
822
|
+
for (let j = i; j < chunks.length && !keep[j]; j++) lines += chunks[j]!.split('\n').length
|
|
823
|
+
out.push(`[ohmyjev: ${lines} lines left out as unrelated to the request. Rerun the command with grep or sed -n to see them.]`)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return out.join('\n')
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// --- skill picker: the skill listing out of the prompt, Jev naming the one skill a request needs ---
|
|
830
|
+
|
|
831
|
+
export type Skill = { name: string; description: string }
|
|
832
|
+
|
|
833
|
+
/** One request: which skill fits, and three questions about whether the request needs one at all (TypeSafe's cookbook). */
|
|
834
|
+
export const skillQuestions = (skills: readonly Skill[]): Questions => ({
|
|
835
|
+
which: choice('Which skill in the list best fits `request`?', Object.fromEntries(skills.slice(0, 100).map(s => [s.name, clip(s.description, 300) || s.name]))),
|
|
836
|
+
acts_on_user_system: noul('Is the assistant asked to act on the user\'s files, accounts, devices or services, rather than only to explain or advise?'),
|
|
837
|
+
would_follow_documented_procedure: noul('Would a careful expert follow a specific documented procedure or set of commands, rather than answer from general understanding?'),
|
|
838
|
+
prose_suffices: noul('Could a knowledgeable generalist fully satisfy `request` in prose, with no tools and no access to the user\'s files?'),
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
/** The skill to name, when the request needs one (the gate's mean) and Jev's pick is clear enough; else null. */
|
|
842
|
+
export function pickSkill(a: Answers, c: Config): { name: string; gate: number; confidence: number } | null {
|
|
843
|
+
const gate = (nv(a, 'acts_on_user_system') + nv(a, 'would_follow_documented_procedure') + (1 - nv(a, 'prose_suffices'))) / 3
|
|
844
|
+
const w = ch(a, 'which')
|
|
845
|
+
if (!w || gate < c.skillGate || w.confidence < c.skillMin) return null
|
|
846
|
+
return { name: w.choice, gate, confidence: w.confidence }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The second request: does the picked skill do the specific thing asked? It can say no, so an only option isn't forced. */
|
|
850
|
+
export const fitsQuestion = (s: Skill): Questions => ({
|
|
851
|
+
fits: noul(
|
|
852
|
+
`Does the skill "${s.name}" (${clip(s.description, 600)}) do the specific thing \`request\` asks for?`,
|
|
853
|
+
'It is the documented procedure for exactly this task',
|
|
854
|
+
'It is about something else, only nearby, or the request needs no procedure',
|
|
855
|
+
),
|
|
856
|
+
})
|
|
857
|
+
export const skillFits = (a: Answers, c: Config): boolean => nv(a, 'fits') >= c.skillGate
|
|
858
|
+
|
|
859
|
+
export const skillNote = (name: string): string =>
|
|
860
|
+
`<skill_relevance>\nRelevant to the current request: the ${name} skill. Load it with the Skill tool (${name}) before you start, and ` +
|
|
861
|
+
'follow it. Ignore this if it does not fit what the user actually asked for.\n</skill_relevance>'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ohmyjev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -136,6 +136,61 @@
|
|
|
136
136
|
"description": "Deny when Jev rates a call as sending local data out at least this probably (0-1)",
|
|
137
137
|
"default": 0.7
|
|
138
138
|
},
|
|
139
|
+
"autoApprove": {
|
|
140
|
+
"type": "boolean",
|
|
141
|
+
"description": "Where the host would ask you, run a command or in-repo edit Jev is sure is safe instead",
|
|
142
|
+
"default": true
|
|
143
|
+
},
|
|
144
|
+
"approveConfidence": {
|
|
145
|
+
"type": "number",
|
|
146
|
+
"description": "How sure Jev must be that a command only reads or is reversible (0-1)",
|
|
147
|
+
"default": 0.9
|
|
148
|
+
},
|
|
149
|
+
"approveRiskMax": {
|
|
150
|
+
"type": "number",
|
|
151
|
+
"description": "Destructive, data-leaving or credential probability under which a call is approved (0-1)",
|
|
152
|
+
"default": 0.2
|
|
153
|
+
},
|
|
154
|
+
"stuckCheck": {
|
|
155
|
+
"type": "boolean",
|
|
156
|
+
"description": "After repeated failed calls, ask Jev whether the agent is stuck on one approach and tell it to change course",
|
|
157
|
+
"default": true
|
|
158
|
+
},
|
|
159
|
+
"stuck": {
|
|
160
|
+
"type": "number",
|
|
161
|
+
"description": "How sure Jev must be that the failures repeat one approach before the note (0-1)",
|
|
162
|
+
"default": 0.7
|
|
163
|
+
},
|
|
164
|
+
"trimOutput": {
|
|
165
|
+
"type": "boolean",
|
|
166
|
+
"description": "Cut a long shell output to the parts your request needs, Jev rating each part",
|
|
167
|
+
"default": true
|
|
168
|
+
},
|
|
169
|
+
"trimOver": {
|
|
170
|
+
"type": "number",
|
|
171
|
+
"description": "Characters of shell output before trimming is considered",
|
|
172
|
+
"default": 12000
|
|
173
|
+
},
|
|
174
|
+
"trimKeep": {
|
|
175
|
+
"type": "number",
|
|
176
|
+
"description": "How likely Jev must rate a part as needed for it to stay (0-1)",
|
|
177
|
+
"default": 0.3
|
|
178
|
+
},
|
|
179
|
+
"skillPick": {
|
|
180
|
+
"type": "boolean",
|
|
181
|
+
"description": "Claude Code only: leave the skill listing out of every prompt; Jev names the one skill a request needs",
|
|
182
|
+
"default": true
|
|
183
|
+
},
|
|
184
|
+
"skillGate": {
|
|
185
|
+
"type": "number",
|
|
186
|
+
"description": "Claude Code only: how strongly Jev must rate a request as needing a skill (0-1)",
|
|
187
|
+
"default": 0.3
|
|
188
|
+
},
|
|
189
|
+
"skillMin": {
|
|
190
|
+
"type": "number",
|
|
191
|
+
"description": "Claude Code only: how sure Jev must be of which skill fits (0-1)",
|
|
192
|
+
"default": 0.4
|
|
193
|
+
},
|
|
139
194
|
"requested": {
|
|
140
195
|
"type": "number",
|
|
141
196
|
"description": "A gate's Jev deny passes when Jev rates your latest message as asking for exactly that call at least this probable (0-1). Policy and path denies always stand",
|
|
@@ -146,6 +201,11 @@
|
|
|
146
201
|
"description": "Expose the ask_jev tool for quick judgments about repo files or text",
|
|
147
202
|
"default": true
|
|
148
203
|
},
|
|
204
|
+
"browse": {
|
|
205
|
+
"type": "boolean",
|
|
206
|
+
"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",
|
|
207
|
+
"default": false
|
|
208
|
+
},
|
|
149
209
|
"autoCompact": {
|
|
150
210
|
"type": "boolean",
|
|
151
211
|
"description": "Compact after Jev sees a task change at a natural boundary",
|