212 lines
12 KiB
Markdown
212 lines
12 KiB
Markdown
# omp-edit
|
|
|
|
[oh-my-pi](https://github.com/can1357/oh-my-pi)-style editing for the DeepSeek Harness (DSH). It **replaces DSH's built-in `edit` tool** with oh-my-pi's *hashline* patch language. It also wraps `read` and `grep` so their output carries the content-hash tags the patches anchor on.
|
|
|
|
The model points at line numbers of a tagged snapshot instead of retyping the old text. That avoids whitespace-mismatch "string not found" loops and saves output tokens. A stale snapshot is detected before anything is written, and is rebased automatically when the anchored lines survived.
|
|
|
|
## What the agent sees
|
|
|
|
`read` output carries a `[PATH#TAG]` header and `LINE:TEXT` rows (the 4 hex characters are a hash of the whole file):
|
|
|
|
```
|
|
<path>[src/app.py#1A2B]</path>
|
|
1:def greet(name):
|
|
2: print("Hello, " + name)
|
|
```
|
|
|
|
`grep` groups matches under a tagged header per file. A grep hit counts as having read the file, so it can be edited straight away:
|
|
|
|
```
|
|
[src/app.py#1A2B]
|
|
2: print("Hello, " + name)
|
|
```
|
|
|
|
`edit` takes one `input` string with one or more file sections:
|
|
|
|
```
|
|
[src/app.py#1A2B]
|
|
PUT 2.=2:
|
|
+ print(f"Hi, {name}")
|
|
```
|
|
|
|
It returns the fresh tag plus a compact numbered preview of the changed region, so the agent can chain edits without re-reading. The Web UI shows the usual diff card.
|
|
|
|
## Hashline operations
|
|
|
|
| Form | Effect |
|
|
| --- | --- |
|
|
| `PUT N.=M:` + `+rows` | Replace original lines N..M (`N.=N` for one line) |
|
|
| `PUT N*:` + `+rows` | Replace the syntactic block opening at line N (function/class/object, Markdown heading section, Python/YAML indented block; leading decorators included) |
|
|
| `PUT <N:` / `PUT >N:` | Insert before / after line N (`<1` = file head, `>$` = file tail) |
|
|
| `PUT >N*:` | Insert after the block opening at N |
|
|
| `CUT N.=M [@name]` / `CUT N* [@name]` | Delete and capture (anonymous register, or a named one) |
|
|
| `PUT <N [@name]` / `PUT >N [@name]` | Paste a register at a gap |
|
|
| `PUT N.=M @name` / `PUT N* @name` | Paste a named register over a range/block |
|
|
| `REM` | Delete the file |
|
|
| `MV DEST` | Move the file after the section's edits |
|
|
|
|
The rules match oh-my-pi:
|
|
- Body rows are `+TEXT`; a lone `+` is a blank line; literal `-x` is written `+-x`.
|
|
- All numbers refer to the tagged snapshot, never shifted by earlier hunks.
|
|
- Overlapping ranges are rejected.
|
|
- Named registers persist for the session, and cross-file moves work (`CUT 1* @fn` in one section, `PUT <1 @fn` in another).
|
|
- The whole call is validated before anything is written.
|
|
- Tolerant parsing: an optional `*** Begin/End Patch` envelope and markdown fences are stripped, and `PUT 3-5:` / `PUT 3:` are accepted with a warning.
|
|
|
|
### Stale tags
|
|
|
|
Every tagged `read`/`grep`/`edit` records a snapshot. When the file changed since the tag (another tool, a formatter, the user):
|
|
|
|
- if every anchored line still exists unchanged, with a neighbour that moved by the same offset, the patch is **remapped onto the current file** and applied. A warning says so.
|
|
- otherwise the edit is **rejected without writing**, with a message to re-read.
|
|
|
|
A tag that was never issued for that file (e.g. copied from another file) is rejected with a hint.
|
|
|
|
## Paging: read, grep and glob
|
|
|
|
Like oh-my-pi, outputs are cut into pages so the model never pulls a whole file or huge search into context at once:
|
|
- **`read`**: at most **200 lines** per call (`limit` above 200 is capped); the footer gives the `offset` to continue.
|
|
- **`grep`**: at most **200 matches** per call, grouped under `[PATH#TAG]` headers. Pass `offset` for the next page (`… call grep again with offset=200`).
|
|
- **`glob`**: at most **200 paths** per call, with the same `offset` cursor.
|
|
|
|
Change the page size with `pageLines`.
|
|
|
|
## Guard rule
|
|
|
|
A `tools/pre-execute` listener makes the agent use these tools instead of working around them through the shell. Denied calls get a short reason that names the right tool.
|
|
|
|
| Denied | Use instead |
|
|
| --- | --- |
|
|
| `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd` | `edit` (hashline) |
|
|
| Inline scripts that can touch files, processes or the network (`node -e "require('fs')…"`, `node --input-type=module --eval "import …"`, `python3 -c "open(…)"`, `ruby -e "File.write…"`, `deno eval`, `bun -e`, `php -r`…), or scripts piped in through stdin (`… \| node`, `node - < f`) | `read`/`grep`/`edit` |
|
|
| `cat`/`head`/`tail`/`less`/`bat`/`nl FILE`, `sed -n`, `awk` | `read` (paged) |
|
|
| `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) |
|
|
| `find`/`fd`/`locate`, `ls -R` | `glob` |
|
|
| `str_replace_editor`, `apply_patch` tools; `write` on an **existing** file | `edit` (`write` stays allowed for brand-new files) |
|
|
|
|
What stays allowed:
|
|
- Text inside quotes is ignored (`git commit -m "fix > bug"`).
|
|
- Redirection to `/dev/null` is fine.
|
|
- Filters on piped output keep working (`npm test 2>&1 | tail -5`, `ps aux | grep node`).
|
|
- Computation-only one-liners: `node -e "console.log(1+1)"`, `node -p "2**10"`, `python3 -c "print(sum(range(9)))"`, `ruby -e 'puts 1+1'`. Running a file is a normal command (`node --test test/`, `node build.mjs`).
|
|
|
|
How inline scripts are judged: the guard works out which interpreter runs (also through `env`, `sudo` and absolute paths) and pulls out its program from `-e`/`-p`/`--eval`/`--print`/`--eval=`/`-c`/`-r`/`eval`. The program is allowed only if it contains **none** of the identifiers that start any I/O path, for example:
|
|
- JavaScript: `require`, `import`, `process`, `globalThis`, `eval`, `Function`, `constructor`, `fetch`, `Deno`, `Bun`, or bracket access with a string (`x['y']`);
|
|
- Python: `import`, `__import__`, `open`, `os`, `subprocess`, dunder attributes;
|
|
- Ruby/Perl: `File`, `IO`, `system`, `open`, backticks.
|
|
|
|
Banning the identifiers that start an I/O path covers obfuscation too: `fs['write'+'File']` still needs `require` or `import` to get `fs`. A program the guard can't read (stdin, `-`) is denied.
|
|
|
|
If the guard can't check whether a path exists, `write` is denied rather than allowed (it fails closed).
|
|
|
|
Turn the guard off with `guard: false`, or change the list of blocked tools with `guardTools`.
|
|
|
|
## Diff cards in the Web UI
|
|
|
|
The plugin ships a web client (`client.js`) that registers an `edit` tool view. Hashline, sloppy and apply_patch calls would otherwise show raw patch JSON; instead they render with the **same chrome as DSH's own tool rows**, built from DSH's primitives (`DisclosureRow`, `TextShimmer`, `Tooltip`, its icons) with CSS copied from the stock `ToolRow`, `CodeCard` and `DiffBlock` modules:
|
|
- the standard 24px row: edit icon, `Edit` title, a dot separator, the file path as a dotted link that opens the file, and a small monospace `+N -M` that turns green/red on hover or when expanded. Rows start collapsed and follow the turn's expand/collapse state, like every other tool;
|
|
- a failed edit shows its first error line in red, and expanding it shows the standard **Output** card;
|
|
- the expanded diff is the stock code card: a language header with the wrap and copy icon buttons, 22px-padded rows with the inset +/- bar, and the head/tail fold after 9 rows;
|
|
- one addition: a **line-number gutter** with the old and new file line of every row.
|
|
|
|
Colors come only from DSH theme tokens, so the view follows the active theme.
|
|
|
|
The host attaches `oldStart`, `newStart` and `rows` to every hunk in `meta.diffs`, next to the standard `path`/`oldText`/`newText` fields. Older results without them still render, just without numbers.
|
|
|
|
## Edit modes
|
|
|
|
`mode` picks the wire format of the replacement `edit` tool:
|
|
|
|
| Mode | Input | Notes |
|
|
| --- | --- | --- |
|
|
| `hashline` (default) | `input` | Described above |
|
|
| `replace` | `file_path`, `old_string`, `new_string`, `replace_all`, or `edits: [{old_string,new_string,replace_all}]` | Fuzzy whitespace matching: exact → indentation/trailing-space-insensitive → whitespace-collapsed → smart quotes/dashes → ≥95% similarity. The file's indentation is carried over. Batched edits are atomic |
|
|
| `sloppy` | `input` | `*** Edit File:` / `*** Find` / `*** Replace` · `*** Insert Before` · `*** Insert After`, `…` captures, ` all` suffix |
|
|
| `apply_patch` | `input` | Codex format: `*** Add/Delete/Update File`, `*** Move to`, `@@` scoped hunks, `*** End of File` |
|
|
|
|
**Per-model selection**, like oh-my-pi:
|
|
1. `modelVariants` (substring of `provider/model` → mode), first match wins.
|
|
2. `mode`.
|
|
3. With `hashline` and `strict: false`, model families oh-my-pi considers unreliable at hashline fall back to `fallbackMode` (default `replace`). The list is `fallbackModels`, default `kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash`.
|
|
|
|
The tool schema follows the model the agent actually uses and switches mid-session when you change models.
|
|
|
|
## How it replaces the built-ins
|
|
|
|
DSH registers `edit`/`read`/`grep` in each agent preset. Tools registered in an agent's own scope shadow those, so this plugin listens for `agent/created` and registers into every agent's scope:
|
|
- **`edit`** is replaced outright. Nothing of DSH's `edit` is used.
|
|
- **`read`** and **`grep`** delegate to DSH's implementations (sandbox, limits, ripgrep, spill files all unchanged) and only add tags to the output.
|
|
|
|
DSH's safety rules are kept:
|
|
- **Read first:** you still need to `read` (or `grep`) a file before editing it. This is `fs-observation-policy`.
|
|
- **Guarded writes:** writes go through `ctx.fs` with the version guard.
|
|
- **Sandbox:** the session's sandbox policy applies to writes and `REM`.
|
|
- **Line endings:** CRLF files stay CRLF.
|
|
|
|
To use DSH's original tools again, disable this plugin (Settings → Plugins → OMP Edit) or set `replaceEdit/replaceRead/replaceGrep: false`.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git
|
|
```
|
|
|
|
Or in the Web UI, go to **Settings → Plugins → Install** and paste `git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git`. You can also ask the agent to install it with `plugin_manager` → `install_bundle`.
|
|
|
|
For development:
|
|
|
|
```bash
|
|
git clone https://git.rizzy.dev/DSH-Plugins/omp-edit.git ~/dsh-plugins/omp-edit
|
|
dsh plugin --profile web add link:$HOME/dsh-plugins/omp-edit
|
|
```
|
|
|
|
Then restart DSH (`systemctl --user restart dsh` or re-run `dsh web`). New sessions get the new `edit` tool. Sessions that already exist pick it up once they're re-created, e.g. after the restart.
|
|
|
|
No dependencies: everything is plain Node.js (≥ 20).
|
|
|
|
## Configuration
|
|
|
|
Set options in the `config` of the row in `~/.dsh/profiles/web/cordis.patch.yml`:
|
|
|
|
```yaml
|
|
- id: omp-edit
|
|
config:
|
|
mode: hashline # hashline | replace | sloppy | apply_patch
|
|
strict: false # true: never fall back from hashline
|
|
fallbackMode: replace
|
|
fallbackModels: [kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash]
|
|
modelVariants: # substring of "provider/model" -> mode
|
|
gpt-5-codex: apply_patch
|
|
replaceEdit: true
|
|
replaceRead: true
|
|
replaceGrep: true
|
|
fuzzy: true # replace mode fuzzy matching
|
|
replaceGlob: true
|
|
pageLines: 200 # read/grep/glob page size
|
|
guard: true # deny shell edits/reads/searches and non-hashline write tools
|
|
guardTools: [write, str_replace_editor, apply_patch] # write: only existing files are denied
|
|
```
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
npm test # node --test test/*.test.mjs — 34 tests (engine + integration on the real DSH fs-local backend)
|
|
```
|
|
|
|
## Files
|
|
|
|
| File | Role |
|
|
| --- | --- |
|
|
| `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, paging, guard rule |
|
|
| `client.js` | Web client: `edit` tool view rendering hashline/patch results with DSH's native tool-row and diff-card chrome |
|
|
| `lib/hashline.js` | Hashline parser and applier |
|
|
| `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) |
|
|
| `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase |
|
|
| `lib/replace.js` | Fuzzy `replace` mode |
|
|
| `lib/sloppy.js` | `sloppy` mode |
|
|
| `lib/apply_patch.js` | `apply_patch` mode |
|
|
| `lib/diff.js` | Hunk diffs for the UI diff card |
|
|
| `lib/prompts.js` | Model-facing tool descriptions (adapted from oh-my-pi's `crates/pi-edit/prompts`) |
|
|
|
|
Pairs well with [omp-lsp](https://git.rizzy.dev/DSH-Plugins/omp-lsp), which appends language-server diagnostics to every edit.
|