Files
oh-my-pi/docs/tools/write.md
T
can1357 31f3fbda61 feat(write): added snapshot header to write tool output in hashline mode
- Prepended `¶path#TAG` hashline header to plain file, ACP-bridge, and conflict resolution write results.
- Bulk conflict resolutions emit a trailing `Snapshots:` block with one header per written file.
- Suppressed when hashline display mode is disabled or for archive/SQLite/internal-URL targets.
- Added tests covering header presence, patcher usability, and disabled-mode suppression.
2026-05-29 06:42:46 +02:00

179 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# write
> Create or overwrite a file, archive entry, or SQLite row.
## Source
- Entry: `packages/coding-agent/src/tools/write.ts`
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/write.md`
- Key collaborators:
- `packages/coding-agent/src/tools/archive-reader.ts` — parse `archive.ext:entry` selectors.
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite paths and perform row insert/update/delete.
- `packages/coding-agent/src/lsp/index.ts` — format-on-write and diagnostics writethrough.
- `packages/coding-agent/src/tools/auto-generated-guard.ts` — block overwriting generated files.
- `packages/coding-agent/src/tools/fs-cache-invalidation.ts` — invalidate shared FS scan caches after writes.
- `packages/coding-agent/src/tools/plan-mode-guard.ts` — resolve paths and enforce plan-mode write policy.
## Inputs
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | Yes | Target path. Plain file path writes a filesystem file. `archive.ext:inner/path` writes an archive entry for `.tar`, `.tar.gz`, `.tgz`, or `.zip`. `db.sqlite:table` inserts a row. `db.sqlite:table:key` updates or deletes a row. |
| `content` | `string` | Yes | Full replacement file content, archive entry content, or SQLite row payload. SQLite non-delete writes must parse as a JSON5 object. Empty or whitespace-only content deletes a SQLite row when `path` includes a row key. |
Worked examples:
```text
path: "src/generated/config.json"
content: "{\n \"enabled\": true\n}\n"
```
```text
path: "fixtures/archive.zip:templates/email.txt"
content: "hello\n"
```
```text
path: "data/app.sqlite:users:42"
content: "{name: 'Ada', active: true}"
```
## Outputs
Single-shot result.
- Success always returns a text block.
- Plain file write: `Successfully wrote <bytes> bytes to <relative-path>`.
- Archive write: `Successfully wrote <bytes> bytes to <relative-archive-path>:<entry-path>`.
- SQLite write: one of `Inserted row into <table>`, `Updated row '<key>' in <table>`, `No row updated ...`, `Deleted row ...`, `No row deleted ...`.
- If hashline prefixes were copied from `read` output and stripped first, the first text block gets an extra note.
- In hashline display mode, plain file writes (including ACP bridge writes) and conflict resolutions prepend a fresh `¶<relative-path>#TAG` header so the next `edit` has a current snapshot tag without an extra `read`. Bulk conflict resolutions append a `Snapshots:` block listing one header per successfully written file.
- Plain file writes may also return `details.diagnostics` plus `details.meta.diagnostics` when LSP diagnostics-on-write is enabled.
- SQLite writes use `toolResult(...).sourcePath(...)`, so `details.meta.sourcePath` points at the database file.
- Archive writes return empty `details`.
## Flow
1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips pasted `¶PATH#HASH` headers and `LINE:` hashline prefixes from `content` when the session is in hashline display mode.
2. It calls `#resolveArchiveWritePath()` first. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/tools/archive-reader.ts`, checks candidate archive files on disk, and falls back to the longest matching archive suffix even when the archive file does not exist yet.
3. Archive writes call `enforcePlanModeWrite(..., { op: exists ? "update" : "create" })`, then `#writeArchiveEntry()`.
- The parent directory of the archive file is created with `fs.mkdir(..., { recursive: true })`.
- `.zip` archives are read with `fflate.unzipSync()`, the target entry is replaced in an in-memory map, and the archive is rewritten with `fflate.zipSync()` + `Bun.write()`.
- `.tar`, `.tar.gz`, and `.tgz` archives are read with `Bun.Archive`, existing entries are copied into an object map, the target entry is replaced, and `Bun.Archive.write()` rewrites the archive.
- `invalidateFsScanAfterWrite()` runs on the archive file path.
4. If the path is not treated as an archive, `execute()` calls `#resolveSqliteWritePath()`. That uses `parseSqlitePathCandidates()` and `isSqliteFile()` from `packages/coding-agent/src/tools/sqlite-reader.ts`. Existing non-SQLite files suppress the SQLite path interpretation.
5. SQLite writes call `enforcePlanModeWrite(..., { op: "update" })`, then `#writeSqliteRow()`.
- The database must already exist; missing DBs throw `SQLite database '<path>' not found`.
- The tool opens `new Database(..., { create: false, strict: true })` and sets `PRAGMA busy_timeout = 3000`.
- Whitespace-only `content` with a row key deletes a row.
- Non-empty `content` is parsed with `Bun.JSON5.parse()`, must be a JSON object, and is routed to insert/update helpers from `packages/coding-agent/src/tools/sqlite-reader.ts`.
- `invalidateFsScanAfterWrite()` runs on the DB path and the connection is closed in `finally`.
6. Otherwise the tool treats `path` as a plain filesystem file.
- `enforcePlanModeWrite(..., { op: "create" })` runs before path resolution.
- Existing files are checked by `assertEditableFile()` to block overwriting detected generated files.
- The session’s writethrough callback writes content. With LSP enabled and `lsp.formatOnWrite` / `lsp.diagnosticsOnWrite` settings on, `createLspWritethrough()` may format content, sync it through LSP servers, save it, and collect diagnostics. Otherwise `writethroughNoop()` writes directly with `Bun.write()` or `file.write()`.
- `invalidateFsScanAfterWrite()` runs on the file path.
7. The tool returns a text result and optional diagnostics metadata.
## Modes / Variants
### Plain file path
- Target is any path that does not resolve as an archive selector and does not resolve as an existing-or-new SQLite selector.
- Existing files are overwritten.
- `write.ts` does not call `fs.mkdir()` on this path; parent-directory creation is only implemented in the archive branch.
Example:
```text
path: "tmp/output.txt"
content: "hello\n"
```
### Archive entry write
- Selector syntax: `archive.ext:inner/path`.
- Supported archive suffixes come from `parseArchivePathCandidates()`: `.tar`, `.tar.gz`, `.tgz`, `.zip`.
- The inner path is normalized to `/`, strips empty and `.` segments, rejects `..`, and rejects directory targets ending in `/`.
- Rewrites the whole archive file after replacing one entry.
- Creates the parent directory for the archive file if needed.
Example:
```text
path: "build/assets.tar.gz:css/app.css"
content: "body { color: black; }\n"
```
### SQLite table insert
- Selector syntax: `db.sqlite:table`.
- `content` must parse as a JSON5 object.
- Empty object is allowed and becomes `INSERT INTO <table> DEFAULT VALUES`.
- Query parameters are rejected for SQLite writes.
Example:
```text
path: "data/app.db:users"
content: "{name: 'Ada', active: true}"
```
### SQLite row update / delete
- Selector syntax: `db.sqlite:table:key`.
- Non-empty `content` updates the row.
- Empty or whitespace-only `content` deletes the row.
- Row lookup uses the single-column primary key if present; otherwise it falls back to `rowid`. Composite primary keys and `WITHOUT ROWID` tables are rejected for key-based writes.
Example update:
```text
path: "data/app.sqlite:users:42"
content: "{email: 'ada@example.com'}"
```
Example delete:
```text
path: "data/app.sqlite:users:42"
content: ""
```
## Side Effects
- Filesystem
- Creates or overwrites plain files.
- Rewrites entire archive files when writing an archive entry.
- Creates parent directories for archive files only.
- Mutates existing SQLite databases; never creates a new SQLite DB.
- Subprocesses / native bindings
- Uses Bun SQLite bindings via `bun:sqlite`.
- Uses Bun archive APIs and lazily imports `fflate` for ZIP reads/writes.
- May talk to configured LSP servers through `packages/coding-agent/src/lsp/index.ts`.
- Session state (transcript, memory, jobs, checkpoints, registries)
- Invalidates shared filesystem scan cache entries through `invalidateFsScanAfterWrite()`.
- Enforces plan-mode write restrictions before mutating the target.
- Background work / cancellation
- Marks the tool `nonAbortable = true` and `concurrency = "exclusive"` in `WriteTool`.
- LSP writethrough can schedule deferred diagnostics fetches after a timeout, but plain `write.ts` only consumes the immediate return value.
## Limits & Caps
- `WriteTool` itself exposes no byte cap beyond storing `content` in memory and, for archives, rebuilding the archive in memory.
- Generated-file detection reads at most `CHECK_BYTE_COUNT = 1024` bytes and `HEADER_LINE_LIMIT = 40` header lines from an existing file in `packages/coding-agent/src/tools/auto-generated-guard.ts`.
- SQLite writes set `PRAGMA busy_timeout = 3000`.
- LSP writethrough uses a `5_000` ms operation timeout in `runLspWritethrough()` and may schedule a deferred diagnostics fetch with `AbortSignal.timeout(25_000)` in `scheduleDeferredDiagnosticsFetch()`.
## Errors
- Invalid archive subpaths throw `ToolError` with messages such as:
- `Archive write path must target a file inside the archive`
- `Archive write path must target a file, not a directory`
- `Archive path cannot contain '..'`
- SQLite path parsing throws on unsupported forms:
- `SQLite write paths do not support query parameters`
- `SQLite write path must target a table`
- `SQLite row writes require a non-empty row key`
- Missing SQLite DBs surface as `SQLite database '<path>' not found`.
- SQLite content errors are model-visible `ToolError`s, including invalid JSON5, non-object payloads, unknown columns, non-scalar values, empty update objects, composite primary keys, and `WITHOUT ROWID` tables.
- Existing plain files may be rejected by `assertEditableFile()` when they look generated.
- Archive read/write failures and unexpected SQLite exceptions are wrapped in `ToolError(error.message)`.
- If no LSP server matches or LSP formatting/diagnostics times out, file writes still fall back to writing content; diagnostics may be omitted.
## Notes
- Archive path detection runs before SQLite detection. A path that matches an archive selector is never treated as SQLite.
- SQLite detection declines when an existing file with a `.sqlite` / `.db` suffix is present but does not have SQLite magic bytes; then the path falls back to a plain file write.
- ZIP entry content is encoded with `new TextEncoder().encode(content)` in `#writeArchiveEntry()`. Non-ZIP archive writes pass the string directly to `Bun.Archive.write()`.
- The prompt forbids two common anti-patterns: using `write` for routine edits that should use `edit`, and creating `*.md` / `README` files unless explicitly requested. It also forbids emojis unless requested.
- Plain file writes report byte count using `cleanContent.length`, which is UTF-16 code units in JS, not an on-disk byte measurement.
- `stripWriteContent()` only removes hashline prefixes when the session’s file display mode has `hashLines` enabled; otherwise content is written unchanged.