@mammothb/pi-hashline 0.2.0 → 0.2.1

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 ADDED
@@ -0,0 +1,49 @@
1
+ # pi-hashline
2
+
3
+ Hashline anchoring for pi — content-addressed read/edit with stale-edit
4
+ protection.
5
+
6
+ ## Tools
7
+
8
+ | Tool | Description |
9
+ |---|---|
10
+ | `read` | Read files with `\u00b6PATH#TAG` headers. Copy the header to use with `edit`. |
11
+ | `edit` | Edit files using hashline anchoring. Validates tags before writes — stale tags are rejected. |
12
+ | `write` | Create or overwrite files. Returns a `\u00b6PATH#TAG` header for immediate editing. |
13
+ | `grep` | Search with ripgrep. Matching files get `\u00b6PATH#TAG` headers. |
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ pi -e ./index.ts
19
+ ```
20
+
21
+ ## Grammar
22
+
23
+ The hashline grammar is documented in [src/prompt.md](src/prompt.md) —
24
+ this is the same reference injected into the LLM's system prompt. Key
25
+ operations:
26
+
27
+ - `replace N..M:` — replace lines N through M with body rows
28
+ - `delete N..M` — delete lines N through M
29
+ - `insert before|after N:` — insert body rows relative to line N
30
+ - `insert head:|tail:` — insert at file boundaries
31
+
32
+ Body rows use `+TEXT` syntax. There are no `-` rows.
33
+
34
+ ## Recovery
35
+
36
+ Stale-tag edits (file changed between read and edit) are automatically
37
+ recovered via two strategies:
38
+
39
+ 1. **Structured-patch 3-way merge** — apply edits to the cached snapshot,
40
+ create a structured patch, apply to live content.
41
+ 2. **Anchor-content replay** — when anchors still match, apply edits
42
+ directly to the live file.
43
+
44
+ ## Peer Dependencies
45
+
46
+ - `@earendil-works/pi-coding-agent`
47
+ - `@earendil-works/pi-ai`
48
+ - `@earendil-works/pi-tui`
49
+ - `typebox`
package/index.ts CHANGED
@@ -10,16 +10,16 @@
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
 
13
- import { createEditTool } from "./src/edit";
14
- import { createGrepTool } from "./src/grep";
15
- import { injectPrompt } from "./src/prompt";
16
- import { createReadTool } from "./src/read";
17
- import { InMemorySnapshotStore } from "./src/snapshots";
18
- import { createWriteTool } from "./src/write";
13
+ import { createEditTool } from "./src/edit.js";
14
+ import { createGrepTool } from "./src/grep.js";
15
+ import { InMemorySnapshotStore } from "./src/lib/hashline/snapshots.js";
16
+ import { injectPrompt } from "./src/prompt.js";
17
+ import { createReadTool } from "./src/read.js";
18
+ import { createWriteTool } from "./src/write.js";
19
19
 
20
- export * from "./src/format";
21
- export * from "./src/snapshots";
22
- export * from "./src/types";
20
+ export * from "./src/lib/hashline/format.js";
21
+ export * from "./src/lib/hashline/snapshots.js";
22
+ export * from "./src/lib/hashline/types.js";
23
23
 
24
24
  export default function (pi: ExtensionAPI) {
25
25
  const snapshots = new InMemorySnapshotStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mammothb/pi-hashline",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Hashline anchoring for pi — content-addressed read/edit with stale-edit protection",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -22,6 +22,6 @@
22
22
  "typebox": "*"
23
23
  },
24
24
  "dependencies": {
25
- "diff": "^8.0.0"
25
+ "diff": "8.0.4"
26
26
  }
27
27
  }
package/src/edit.ts CHANGED
@@ -14,64 +14,32 @@ import { constants } from "node:fs";
14
14
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
15
15
  import { dirname, isAbsolute, relative, resolve } from "node:path";
16
16
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
17
- import { Type } from "typebox";
18
-
19
- import { applyEdits } from "./apply";
17
+ import { applyEdits } from "./lib/hashline/apply.js";
20
18
  import {
21
19
  computeFileHash,
22
20
  formatHashlineHeader,
23
21
  formatNumberedLines,
24
- } from "./format";
25
- import { Patch, type PatchSection } from "./input";
22
+ } from "./lib/hashline/format.js";
23
+ import { Patch, type PatchSection } from "./lib/hashline/input.js";
26
24
  import {
27
25
  HEADTAIL_DRIFT_WARNING,
28
26
  MismatchError,
29
27
  missingTagMessage,
30
28
  nonExistentFileMessage,
31
29
  unrecognizedHashMessage,
32
- } from "./messages";
30
+ } from "./lib/hashline/messages.js";
33
31
  import {
34
32
  detectLineEnding,
35
33
  normalizeToLF,
36
34
  restoreLineEndings,
37
- } from "./normalize";
38
- import { tryRecover } from "./recovery";
39
- import type { SnapshotStore } from "./snapshots";
40
-
41
- // ─── Schema ──────────────────────────────────────────────────────────
42
-
43
- const EditSchema = Type.Object({
44
- edits: Type.String({
45
- description:
46
- "Hashline patch text: one or more ¶PATH#TAG sections followed by edit operations " +
47
- "(replace N..M:, delete N..M, insert before|after|head|tail:). Copy the ¶PATH#TAG " +
48
- "header from the read tool output.",
49
- }),
50
- });
51
-
52
- // ─── Details type ────────────────────────────────────────────────────
53
-
54
- export interface EditToolDetails {
55
- /** Per-file results. */
56
- files: EditFileResult[];
57
- /** Whether any files were changed. */
58
- changed: boolean;
59
- }
60
-
61
- export interface EditFileResult {
62
- /** Display-relative path. */
63
- path: string;
64
- /** New snapshot tag after the edit. */
65
- fileHash: string;
66
- /** Hashline header for the new version. */
67
- header: string;
68
- /** First changed line (1-indexed), or undefined for no-op. */
69
- firstChangedLine?: number;
70
- /** Warnings from parsing or drift. */
71
- warnings?: string[];
72
- /** Numbered preview lines around the change. */
73
- preview: string;
74
- }
35
+ } from "./lib/hashline/normalize.js";
36
+ import { tryRecover } from "./lib/hashline/recovery.js";
37
+ import type { SnapshotStore } from "./lib/hashline/snapshots.js";
38
+ import {
39
+ type EditFileResult,
40
+ EditSchema,
41
+ type EditToolDetails,
42
+ } from "./schema.js";
75
43
 
76
44
  // ─── Helpers ─────────────────────────────────────────────────────────
77
45
 
package/src/grep.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Hashline grep tool override.
3
3
  *
4
- * Overrides the built-in `grep` tool to emit `¶PATH#TAG` headers for each
4
+ * Overrides the built-in `grep` tool to emit `\u00b6PATH#TAG` headers for each
5
5
  * file with matches. Uses ripgrep (`rg --json`) for fast, gitignore-aware
6
6
  * search. After finding matches, reads each matching file to compute its
7
7
  * content hash and record a snapshot — so the agent can immediately `edit`
@@ -13,79 +13,26 @@ import { constants } from "node:fs";
13
13
  import { access, readFile } from "node:fs/promises";
14
14
  import { isAbsolute, relative, resolve } from "node:path";
15
15
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
16
- import { Type } from "typebox";
17
-
18
- import { computeFileHash, formatHashlineHeader } from "./format";
19
- import { normalizeToLF } from "./normalize";
20
- import type { SnapshotStore } from "./snapshots";
21
-
22
- // ─── Schema ──────────────────────────────────────────────────────────
23
-
24
- const GrepSchema = Type.Object({
25
- pattern: Type.String({
26
- description: "The regex pattern to search for (ripgrep syntax)",
27
- }),
28
- path: Type.Optional(
29
- Type.String({
30
- description:
31
- "File or directory to search in (default: current working directory)",
32
- }),
33
- ),
34
- glob: Type.Optional(
35
- Type.String({
36
- description:
37
- "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
38
- }),
39
- ),
40
- context: Type.Optional(
41
- Type.Number({
42
- description:
43
- "Number of lines to show before and after each match (default: 0)",
44
- }),
45
- ),
46
- ignoreCase: Type.Optional(
47
- Type.Boolean({
48
- description: "Case-insensitive search (default: false)",
49
- }),
50
- ),
51
- literal: Type.Optional(
52
- Type.Boolean({
53
- description:
54
- "Treat pattern as literal string instead of regex (default: false)",
55
- }),
56
- ),
57
- });
58
-
59
- // ─── Details type ────────────────────────────────────────────────────
60
-
61
- export interface GrepToolDetails {
62
- /** Number of files with matches. */
63
- filesWithMatches: number;
64
- /** Total matching lines across all files. */
65
- totalMatches: number;
66
- /** Per-file results. */
67
- files: GrepFileResult[];
68
- }
69
-
70
- export interface GrepFileResult {
71
- /** Display-relative path. */
72
- path: string;
73
- /** Content hash of the full file (for subsequent editing). */
74
- fileHash: string;
75
- /** Hashline header for this file snapshot. */
76
- header: string;
77
- /** Number of matches in this file. */
78
- matchCount: number;
79
- }
80
-
81
- // ─── Constants ───────────────────────────────────────────────────────
16
+ import {
17
+ computeFileHash,
18
+ formatHashlineHeader,
19
+ } from "./lib/hashline/format.js";
20
+ import { normalizeToLF } from "./lib/hashline/normalize.js";
21
+ import type { SnapshotStore } from "./lib/hashline/snapshots.js";
22
+ import {
23
+ type GrepFileResult,
24
+ GrepSchema,
25
+ type GrepToolDetails,
26
+ } from "./schema.js";
27
+
28
+ // -- Constants ----------------------------------------------------------
82
29
 
83
30
  const DEFAULT_MAX_BYTES = 50 * 1024;
84
31
  const DEFAULT_MAX_FILES = 50;
85
32
  const MAX_CONCURRENT_READS = 8;
86
33
  const GREP_MAX_LINE_LENGTH = 500;
87
34
 
88
- // ─── Helpers ─────────────────────────────────────────────────────────
35
+ // -- Helpers ------------------------------------------------------------
89
36
 
90
37
  function resolveDisplayPath(rawPath: string, cwd: string): string {
91
38
  const resolved = resolve(cwd, rawPath);
@@ -100,7 +47,7 @@ function resolveDisplayPath(rawPath: string, cwd: string): string {
100
47
  return resolved;
101
48
  }
102
49
 
103
- // ─── rg invocation ───────────────────────────────────────────────────
50
+ // -- rg invocation ------------------------------------------------------
104
51
 
105
52
  interface RgMatch {
106
53
  path: string;
@@ -228,7 +175,7 @@ function runRg(
228
175
  });
229
176
  }
230
177
 
231
- // ─── Tool creator ────────────────────────────────────────────────────
178
+ // -- Tool creator -------------------------------------------------------
232
179
 
233
180
  export function createGrepTool(
234
181
  snapshots: SnapshotStore,
@@ -237,13 +184,13 @@ export function createGrepTool(
237
184
  name: "grep",
238
185
  label: "Grep",
239
186
  description:
240
- "Search file contents using ripgrep. Results include ¶PATH#TAG headers " +
187
+ "Search file contents using ripgrep. Results include \u00b6PATH#TAG headers " +
241
188
  "so you can immediately edit matching files without re-reading them. " +
242
189
  "Requires ripgrep (rg) to be installed.",
243
190
  promptSnippet:
244
- "Search file contents — matching files get ¶PATH#TAG headers for immediate editing",
191
+ "Search file contents — matching files get \u00b6PATH#TAG headers for immediate editing",
245
192
  promptGuidelines: [
246
- "Use grep to find code by pattern. Every matching file starts with a ¶PATH#TAG header — use that tag to edit the file without re-reading it. Use read if you need to see the full file content around the match.",
193
+ "Use grep to find code by pattern. Every matching file starts with a \u00b6PATH#TAG header — use that tag to edit the file without re-reading it. Use read if you need to see the full file content around the match.",
247
194
  "Use glob to filter by file extension (e.g. '*.ts'), context to show surrounding lines, ignoreCase for case-insensitive search, and literal to match a fixed string instead of regex.",
248
195
  ],
249
196
  parameters: GrepSchema,
@@ -7,8 +7,8 @@
7
7
  * line preserve order (before-anchor → replacement → after-anchor).
8
8
  */
9
9
 
10
- import { cloneCursor } from "./tokenizer";
11
- import type { Anchor, ApplyResult, Edit } from "./types";
10
+ import { cloneCursor } from "./tokenizer.js";
11
+ import type { Anchor, ApplyResult, Edit } from "./types.js";
12
12
 
13
13
  // ─── Internal types ──────────────────────────────────────────────────
14
14
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { createHash } from "node:crypto";
8
8
 
9
- import type { Cursor } from "./types";
9
+ import type { Cursor } from "./types.js";
10
10
 
11
11
  /** File-section header prefix: `¶path#hash`. */
12
12
  export const HL_FILE_PREFIX = "¶";
@@ -11,9 +11,9 @@
11
11
 
12
12
  import * as path from "node:path";
13
13
 
14
- import { parsePatch } from "./parser";
15
- import { Tokenizer } from "./tokenizer";
16
- import type { Edit, SplitOptions } from "./types";
14
+ import { parsePatch } from "./parser.js";
15
+ import { Tokenizer } from "./tokenizer.js";
16
+ import type { Edit, SplitOptions } from "./types.js";
17
17
 
18
18
  // ─── Header parsing ──────────────────────────────────────────────────
19
19
 
@@ -4,7 +4,7 @@
4
4
  * keeps wording stable across the rendering paths that surface them.
5
5
  */
6
6
 
7
- import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
7
+ import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format.js";
8
8
 
9
9
  // ─── Error messages ──────────────────────────────────────────────────
10
10
 
@@ -7,10 +7,10 @@
7
7
  * requires file text + language detection and happens at apply time.
8
8
  */
9
9
 
10
- import { HL_PAYLOAD_REPLACE } from "./format";
11
- import type { BlockTarget, Token } from "./tokenizer";
12
- import { Tokenizer } from "./tokenizer";
13
- import type { Anchor, Cursor, Edit, ParsedRange } from "./types";
10
+ import { HL_PAYLOAD_REPLACE } from "./format.js";
11
+ import type { BlockTarget, Token } from "./tokenizer.js";
12
+ import { Tokenizer } from "./tokenizer.js";
13
+ import type { Anchor, Cursor, Edit, ParsedRange } from "./types.js";
14
14
 
15
15
  // ─── Warning / error message constants ───────────────────────────────
16
16
 
@@ -291,7 +291,6 @@ function feedToken(state: ParseState, token: Token): void {
291
291
 
292
292
  switch (token.kind) {
293
293
  case "envelope-begin":
294
- case "envelope-separator":
295
294
  case "blank":
296
295
  // Silently consumed
297
296
  return;
@@ -14,14 +14,14 @@
14
14
  */
15
15
 
16
16
  import { applyPatch, structuredPatch } from "diff";
17
- import { applyEdits } from "./apply";
17
+ import { applyEdits } from "./apply.js";
18
18
  import {
19
19
  RECOVERY_EXTERNAL_WARNING,
20
20
  RECOVERY_SESSION_CHAIN_WARNING,
21
21
  RECOVERY_SESSION_REPLAY_WARNING,
22
- } from "./messages";
23
- import type { SnapshotStore } from "./snapshots";
24
- import type { Edit } from "./types";
22
+ } from "./messages.js";
23
+ import type { SnapshotStore } from "./snapshots.js";
24
+ import type { Edit } from "./types.js";
25
25
 
26
26
  // ─── Recovery result ─────────────────────────────────────────────────
27
27
 
@@ -16,7 +16,7 @@
16
16
  * edit onto the live content.
17
17
  */
18
18
 
19
- import { computeFileHash } from "./format";
19
+ import { computeFileHash } from "./format.js";
20
20
 
21
21
  /**
22
22
  * One full-file version observed at a point in time. The tag the model sees
@@ -17,8 +17,8 @@ import {
17
17
  HL_INSERT_TAIL,
18
18
  HL_PAYLOAD_REPLACE,
19
19
  HL_REPLACE_KEYWORD,
20
- } from "./format";
21
- import type { Anchor, Cursor, ParsedRange } from "./types";
20
+ } from "./format.js";
21
+ import type { Anchor, Cursor, ParsedRange } from "./types.js";
22
22
 
23
23
  // ─── BlockTarget (the target of a hunk header) ───────────────────────
24
24
 
@@ -42,7 +42,6 @@ export type Token =
42
42
  | (TokenBase & { kind: "blank" })
43
43
  | (TokenBase & { kind: "envelope-begin" })
44
44
  | (TokenBase & { kind: "envelope-end" })
45
- | (TokenBase & { kind: "envelope-separator" })
46
45
  | (TokenBase & { kind: "abort" })
47
46
  | (TokenBase & { kind: "header"; path: string; fileHash?: string })
48
47
  | (TokenBase & { kind: "op-block"; target: BlockTarget })
@@ -51,10 +50,9 @@ export type Token =
51
50
 
52
51
  // ─── Envelope / abort markers ────────────────────────────────────────
53
52
 
54
- const ENVELOPE_BEGIN = "<<<";
55
- const ENVELOPE_END = ">>>";
56
- const ENVELOPE_SEPARATOR = "---";
57
- const ABORT_MARKER = "...";
53
+ const ENVELOPE_BEGIN = "*** Begin Patch";
54
+ const ENVELOPE_END = "*** End Patch";
55
+ const ABORT_MARKER = "*** Abort";
58
56
 
59
57
  // ─── Line-number scanner ─────────────────────────────────────────────
60
58
 
@@ -290,9 +288,6 @@ function classifyLine(line: string, lineNum: number): Token {
290
288
  if (trimmed === ENVELOPE_END) {
291
289
  return { kind: "envelope-end", lineNum };
292
290
  }
293
- if (trimmed === ENVELOPE_SEPARATOR) {
294
- return { kind: "envelope-separator", lineNum };
295
- }
296
291
  if (trimmed === ABORT_MARKER) {
297
292
  return { kind: "abort", lineNum };
298
293
  }
@@ -373,7 +368,6 @@ export class Tokenizer {
373
368
  return (
374
369
  trimmed === ENVELOPE_BEGIN ||
375
370
  trimmed === ENVELOPE_END ||
376
- trimmed === ENVELOPE_SEPARATOR ||
377
371
  trimmed === ABORT_MARKER
378
372
  );
379
373
  }
@@ -389,6 +383,3 @@ export function cloneCursor(cursor: Cursor): Cursor {
389
383
  }
390
384
  return cursor;
391
385
  }
392
-
393
- // Re-export for convenience
394
- export type { Anchor, Cursor, ParsedRange } from "./types";
package/src/read.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Hashline read tool override.
3
3
  *
4
- * Overrides the built-in `read` tool to emit `¶PATH#TAG` headers and
4
+ * Overrides the built-in `read` tool to emit `\u00b6PATH#TAG` headers and
5
5
  * record content snapshots. Text files get tagged; images delegate to the
6
6
  * native read implementation.
7
7
  */
@@ -13,33 +13,16 @@ import {
13
13
  createReadToolDefinition,
14
14
  type ToolDefinition,
15
15
  } from "@earendil-works/pi-coding-agent";
16
- import { Type } from "typebox";
17
-
18
16
  import {
19
17
  computeFileHash,
20
18
  formatHashlineHeader,
21
19
  formatNumberedLines,
22
- } from "./format";
23
- import type { SnapshotStore } from "./snapshots";
20
+ } from "./lib/hashline/format.js";
21
+ import type { SnapshotStore } from "./lib/hashline/snapshots.js";
22
+ import { ReadSchema, type ReadToolDetails } from "./schema.js";
24
23
 
25
24
  const DEFAULT_MAX_BYTES = 50 * 1024;
26
25
 
27
- const ReadSchema = Type.Object({
28
- path: Type.String({
29
- description: "Path to the file to read (relative or absolute)",
30
- }),
31
- offset: Type.Optional(
32
- Type.Number({
33
- description: "Line number to start reading from (1-indexed)",
34
- }),
35
- ),
36
- limit: Type.Optional(
37
- Type.Number({
38
- description: "Maximum number of lines to read",
39
- }),
40
- ),
41
- });
42
-
43
26
  /** File extensions handled by the native read tool (images, etc.). */
44
27
  const IMAGE_EXTENSIONS = new Set([
45
28
  ".png",
@@ -71,19 +54,6 @@ function resolvePath(rawPath: string, cwd: string): string {
71
54
  return resolved;
72
55
  }
73
56
 
74
- export interface ReadToolDetails {
75
- /** Total lines in the file (before offset/limit). */
76
- totalLines: number;
77
- /** Total bytes in the file. */
78
- totalBytes: number;
79
- /** Whether the displayed output was truncated. */
80
- truncated: boolean;
81
- /** Content hash of the full file. */
82
- fileHash: string;
83
- /** Hashline header for this file snapshot. */
84
- header: string;
85
- }
86
-
87
57
  function errorResult(message: string): {
88
58
  content: { type: "text"; text: string }[];
89
59
  details: ReadToolDetails;
@@ -124,12 +94,12 @@ export function createReadTool(
124
94
  label: "Read",
125
95
  description:
126
96
  "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). " +
127
- "Every text file view includes a ¶PATH#TAG header — copy this tag when editing " +
97
+ "Every text file view includes a \u00b6PATH#TAG header — copy this tag when editing " +
128
98
  "the file so the edit tool can validate you're working against the current version.",
129
99
  promptSnippet:
130
- "Read file contents — every output includes a ¶PATH#TAG header required by the edit tool",
100
+ "Read file contents — every output includes a \u00b6PATH#TAG header required by the edit tool",
131
101
  promptGuidelines: [
132
- "Use read to inspect file content instead of cat or tail. Every text output starts with a ¶PATH#TAG header — copy the entire header (including the tag) into edit tool calls to validate you're editing the current file version.",
102
+ "Use read to inspect file content instead of cat or tail. Every text output starts with a \u00b6PATH#TAG header — copy the entire header (including the tag) into edit tool calls to validate you're editing the current file version.",
133
103
  "Use offset/limit to read large files in sections. Tags are per-file, not per-section — any section of a file carries the same tag.",
134
104
  ],
135
105
  parameters: ReadSchema,
@@ -142,14 +112,17 @@ export function createReadTool(
142
112
  const ext = extname(absolutePath).toLowerCase();
143
113
  if (IMAGE_EXTENSIONS.has(ext)) {
144
114
  // Native read details type differs from hashline. Safe cast.
115
+ // biome-ignore lint/suspicious/noExplicitAny: native tool type mismatch in override
116
+ const onUpdateCast = onUpdate as any;
145
117
  const result = nativeRead(ctx).execute(
146
118
  toolCallId,
147
119
  params,
148
120
  signal,
149
- onUpdate,
121
+ onUpdateCast,
150
122
  ctx,
151
123
  );
152
- return result;
124
+ // biome-ignore lint/suspicious/noExplicitAny: native tool type mismatch in override
125
+ return result as any;
153
126
  }
154
127
 
155
128
  // Check readability.
package/src/schema.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * TypeBox schemas and Details types for all four hashline tools.
3
+ * Centralized here so the API surface is visible in one place.
4
+ */
5
+
6
+ import { Type } from "typebox";
7
+
8
+ // -- Edit tool ------------------------------------------------------------
9
+
10
+ export const EditSchema = Type.Object({
11
+ edits: Type.String({
12
+ description:
13
+ "Hashline patch text: one or more \u00b6PATH#TAG sections followed by edit operations " +
14
+ "(replace N..M:, delete N..M, insert before|after|head|tail:). Copy the \u00b6PATH#TAG " +
15
+ "header from the read tool output.",
16
+ }),
17
+ });
18
+
19
+ export interface EditToolDetails {
20
+ /** Per-file results. */
21
+ files: EditFileResult[];
22
+ /** Whether any files were changed. */
23
+ changed: boolean;
24
+ }
25
+
26
+ export interface EditFileResult {
27
+ /** Display-relative path. */
28
+ path: string;
29
+ /** New snapshot tag after the edit. */
30
+ fileHash: string;
31
+ /** Hashline header for the new version. */
32
+ header: string;
33
+ /** First changed line (1-indexed), or undefined for no-op. */
34
+ firstChangedLine?: number;
35
+ /** Warnings from parsing or drift. */
36
+ warnings?: string[];
37
+ /** Numbered preview lines around the change. */
38
+ preview: string;
39
+ }
40
+
41
+ // -- Read tool ------------------------------------------------------------
42
+
43
+ export const ReadSchema = Type.Object({
44
+ path: Type.String({
45
+ description: "Path to the file to read (relative or absolute)",
46
+ }),
47
+ offset: Type.Optional(
48
+ Type.Number({
49
+ description: "Line number to start reading from (1-indexed)",
50
+ }),
51
+ ),
52
+ limit: Type.Optional(
53
+ Type.Number({
54
+ description: "Maximum number of lines to read",
55
+ }),
56
+ ),
57
+ });
58
+
59
+ export interface ReadToolDetails {
60
+ /** Total lines in the file (before offset/limit). */
61
+ totalLines: number;
62
+ /** Total bytes in the file. */
63
+ totalBytes: number;
64
+ /** Whether the displayed output was truncated. */
65
+ truncated: boolean;
66
+ /** Content hash of the full file. */
67
+ fileHash: string;
68
+ /** Hashline header for this file snapshot. */
69
+ header: string;
70
+ }
71
+
72
+ // -- Write tool -----------------------------------------------------------
73
+
74
+ export const WriteSchema = Type.Object({
75
+ path: Type.String({
76
+ description: "Path to the file to write (relative or absolute)",
77
+ }),
78
+ content: Type.String({
79
+ description: "Content to write to the file",
80
+ }),
81
+ });
82
+
83
+ export interface WriteToolDetails {
84
+ /** Total lines written. */
85
+ totalLines: number;
86
+ /** Content hash of the written file. */
87
+ fileHash: string;
88
+ /** Hashline header for the new version. */
89
+ header: string;
90
+ }
91
+
92
+ // -- Grep tool ------------------------------------------------------------
93
+
94
+ export const GrepSchema = Type.Object({
95
+ pattern: Type.String({
96
+ description: "The regex pattern to search for (ripgrep syntax)",
97
+ }),
98
+ path: Type.Optional(
99
+ Type.String({
100
+ description:
101
+ "File or directory to search in (default: current working directory)",
102
+ }),
103
+ ),
104
+ glob: Type.Optional(
105
+ Type.String({
106
+ description:
107
+ "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
108
+ }),
109
+ ),
110
+ context: Type.Optional(
111
+ Type.Number({
112
+ description:
113
+ "Number of lines to show before and after each match (default: 0)",
114
+ }),
115
+ ),
116
+ ignoreCase: Type.Optional(
117
+ Type.Boolean({
118
+ description: "Case-insensitive search (default: false)",
119
+ }),
120
+ ),
121
+ literal: Type.Optional(
122
+ Type.Boolean({
123
+ description:
124
+ "Treat pattern as literal string instead of regex (default: false)",
125
+ }),
126
+ ),
127
+ });
128
+
129
+ export interface GrepToolDetails {
130
+ /** Number of files with matches. */
131
+ filesWithMatches: number;
132
+ /** Total matching lines across all files. */
133
+ totalMatches: number;
134
+ /** Per-file results. */
135
+ files: GrepFileResult[];
136
+ }
137
+
138
+ export interface GrepFileResult {
139
+ /** Display-relative path. */
140
+ path: string;
141
+ /** Content hash of the full file (for subsequent editing). */
142
+ fileHash: string;
143
+ /** Hashline header for this file snapshot. */
144
+ header: string;
145
+ /** Number of matches in this file. */
146
+ matchCount: number;
147
+ }
package/src/write.ts CHANGED
@@ -3,46 +3,23 @@
3
3
  *
4
4
  * Overrides the built-in `write` tool to record content snapshots so the
5
5
  * agent can immediately `edit` the newly created file with a hashline tag.
6
- * The result includes a `¶PATH#TAG` header matching the read tool output
6
+ * The result includes a `\u00b6PATH#TAG` header matching the read tool output
7
7
  * format.
8
8
  */
9
9
 
10
10
  import { mkdir, writeFile } from "node:fs/promises";
11
11
  import { dirname, isAbsolute, relative, resolve } from "node:path";
12
12
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
13
- import { Type } from "typebox";
14
-
15
13
  import {
16
14
  computeFileHash,
17
15
  formatHashlineHeader,
18
16
  formatNumberedLines,
19
- } from "./format";
20
- import { normalizeToLF } from "./normalize";
21
- import type { SnapshotStore } from "./snapshots";
22
-
23
- // ─── Schema ──────────────────────────────────────────────────────────
24
-
25
- const WriteSchema = Type.Object({
26
- path: Type.String({
27
- description: "Path to the file to write (relative or absolute)",
28
- }),
29
- content: Type.String({
30
- description: "Content to write to the file",
31
- }),
32
- });
33
-
34
- // ─── Details type ────────────────────────────────────────────────────
35
-
36
- export interface WriteToolDetails {
37
- /** Total lines written. */
38
- totalLines: number;
39
- /** Content hash of the written file. */
40
- fileHash: string;
41
- /** Hashline header for the new version. */
42
- header: string;
43
- }
17
+ } from "./lib/hashline/format.js";
18
+ import { normalizeToLF } from "./lib/hashline/normalize.js";
19
+ import type { SnapshotStore } from "./lib/hashline/snapshots.js";
20
+ import { WriteSchema, type WriteToolDetails } from "./schema.js";
44
21
 
45
- // ─── Helpers ─────────────────────────────────────────────────────────
22
+ // -- Helpers ------------------------------------------------------------
46
23
 
47
24
  function resolveDisplayPath(rawPath: string, cwd: string): string {
48
25
  const resolved = resolve(cwd, rawPath);
@@ -57,7 +34,7 @@ function resolveDisplayPath(rawPath: string, cwd: string): string {
57
34
  return resolved;
58
35
  }
59
36
 
60
- // ─── Tool creator ────────────────────────────────────────────────────
37
+ // -- Tool creator -------------------------------------------------------
61
38
 
62
39
  export function createWriteTool(
63
40
  snapshots: SnapshotStore,
@@ -66,13 +43,13 @@ export function createWriteTool(
66
43
  name: "write",
67
44
  label: "Write",
68
45
  description:
69
- "Create or overwrite a file. The result includes a ¶PATH#TAG header — " +
46
+ "Create or overwrite a file. The result includes a \u00b6PATH#TAG header — " +
70
47
  "copy this tag when editing the file so the edit tool can validate " +
71
48
  "you're working against the current version.",
72
49
  promptSnippet:
73
- "Create or overwrite files — returns a ¶PATH#TAG header for immediate editing",
50
+ "Create or overwrite files — returns a \u00b6PATH#TAG header for immediate editing",
74
51
  promptGuidelines: [
75
- "Use write to create new files or completely overwrite existing ones. The result includes a ¶PATH#TAG header — use this tag to edit the file immediately without re-reading.",
52
+ "Use write to create new files or completely overwrite existing ones. The result includes a \u00b6PATH#TAG header — use this tag to edit the file immediately without re-reading.",
76
53
  ],
77
54
  parameters: WriteSchema,
78
55
 
File without changes
File without changes