213 lines
12 KiB
Markdown
213 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, glob and bash
|
||
|
||
Outputs are cut into pages so the model never pulls a whole file, huge search, or giant shell stream into context at once:
|
||
- **`read`**: at most **200 lines** per call (`limit` above 200 is capped); the footer gives the `offset` to continue. The UI row indicates the line range being read (`Read lines A–B`).
|
||
- **`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.
|
||
- **`bash`**: output is capped at **200 lines** per stream (`stdout` / `stderr`). The complete, uncut output is saved to a temporary file (`/tmp/omp-edit-output/...txt`), and the footer instructs the model how to continue reading with `read` (`read file_path="..." offset=201`).
|
||
|
||
Change the page size with `pageLines`.
|
||
|
||
## Guard rule
|
||
|
||
A `tools/pre-execute` listener ensures file modifications go through appropriate tools while lifting restrictions on reading files via shell. Writing through shell is blocked.
|
||
|
||
| Denied | Use instead |
|
||
| --- | --- |
|
||
| `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd` | `edit` (hashline) |
|
||
| Inline scripts that write files (`node -e "require('fs').writeFileSync…"`, `python3 -c "open(…,'w')"`, `ruby -e "File.write…"`, `awk` writes) | `edit` / `write` |
|
||
| `find -delete`, `find -fprint` | `edit` / file tools |
|
||
| `str_replace_editor`, `apply_patch` tools; `write` on an **existing** file | `edit` (`write` stays allowed for brand-new files) |
|
||
|
||
What stays allowed:
|
||
- **Reading files and searching via shell:** `cat`, `head`, `tail`, `less`, `more`, `bat`, `nl`, `sed -n`, `grep`, `rg`, `ag`, `find`, `ls -R`, read-only `awk` are all permitted. Their output is automatically paged to 200 lines by the `bash` wrapper.
|
||
- Text inside quotes is ignored (`git commit -m "fix > bug"`).
|
||
- Redirection to `/dev/null`, `/dev/stderr`, `/dev/stdout` 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/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
|
||
```
|
||
|
||
Outside `hashline` mode, `read` and `grep` return the plain built-in output with no
|
||
`[PATH#TAG]` headers. A grep hit still counts as an observation, so the file can be edited
|
||
straight away. For Claude Code–style tools, use `mode: replace`, `strict: true`, and
|
||
`guard: false`.
|
||
|
||
## 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.
|