dsh-hashline-edit 0.2.2 → 0.2.3

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.
Files changed (108) hide show
  1. package/README.md +57 -45
  2. package/cordis.patch.yml +3 -3
  3. package/lib/index.js +3175 -3943
  4. package/lib/types/dsh/contracts.d.ts +12 -3
  5. package/lib/types/dsh/fs.d.ts +3 -0
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +79 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +21 -9
  10. package/lib/types/dsh/metrics/events.d.ts +1 -1
  11. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  12. package/lib/types/dsh/sandbox.d.ts +2 -2
  13. package/lib/types/dsh/shell-hook.d.ts +1 -1
  14. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  15. package/lib/types/dsh/tools/read.d.ts +3 -3
  16. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  17. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  18. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  19. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  20. package/lib/types/src/apply-patch/render.d.ts +51 -0
  21. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  22. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  23. package/lib/types/src/apply-patch/types.d.ts +362 -0
  24. package/lib/types/src/domain-errors.d.ts +16 -2
  25. package/lib/types/src/edit-undo.d.ts +11 -2
  26. package/lib/types/src/file-content/index.d.ts +1 -1
  27. package/lib/types/src/file-content/preview.d.ts +1 -1
  28. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  29. package/lib/types/src/hashline/alphabet.d.ts +4 -3
  30. package/lib/types/src/hashline/hash-identity.d.ts +12 -7
  31. package/lib/types/src/host-utils.d.ts +63 -0
  32. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  33. package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
  34. package/lib/types/src/mutation-engine/types.d.ts +40 -50
  35. package/lib/types/src/served-session/serve-render.d.ts +6 -0
  36. package/lib/types/src/undo-store.d.ts +17 -1
  37. package/package.json +18 -21
  38. package/prompts/apply-patch-guidelines.md +10 -0
  39. package/prompts/apply-patch.md +1 -0
  40. package/prompts/read-guidelines.md +4 -4
  41. package/prompts/read.md +1 -1
  42. package/prompts/undo-last-edit-guidelines.md +2 -2
  43. package/prompts/undo-last-edit-snippet.md +1 -1
  44. package/prompts/undo-last-edit.md +1 -1
  45. package/src/apply-patch/commit.ts +190 -0
  46. package/src/apply-patch/grammar.ts +620 -0
  47. package/src/apply-patch/index.ts +17 -0
  48. package/src/apply-patch/lifecycle.ts +195 -0
  49. package/src/apply-patch/plan.ts +800 -0
  50. package/src/apply-patch/render.ts +137 -0
  51. package/src/apply-patch/resolve.ts +470 -0
  52. package/src/apply-patch/tool.ts +257 -0
  53. package/src/apply-patch/types.ts +387 -0
  54. package/src/cut-repair.ts +77 -11
  55. package/src/domain-errors.ts +35 -3
  56. package/src/edit-undo.ts +379 -25
  57. package/src/file-content/index.ts +1 -1
  58. package/src/file-content/preview.ts +1 -1
  59. package/src/file-content/served-header.ts +1 -1
  60. package/src/fs-lifecycle.ts +201 -0
  61. package/src/hash-store.ts +18 -1
  62. package/src/hashline/alphabet.ts +7 -7
  63. package/src/hashline/hash-identity.ts +12 -7
  64. package/src/host-utils.ts +224 -0
  65. package/src/mutation-engine/edit-request.ts +39 -0
  66. package/src/mutation-engine/pipeline.ts +198 -27
  67. package/src/mutation-engine/types.ts +39 -57
  68. package/src/mutation-queue.ts +1 -1
  69. package/src/prompts.ts +3 -30
  70. package/src/served-session/serve-render.ts +2 -2
  71. package/src/undo-store.ts +74 -5
  72. package/lib/types/dsh/tools/edit.d.ts +0 -10
  73. package/lib/types/src/edit-tool.d.ts +0 -41
  74. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  75. package/lib/types/src/payload-contract.d.ts +0 -135
  76. package/prompts/edit-guidelines.md +0 -10
  77. package/prompts/edit-snippet.md +0 -1
  78. package/prompts/edit.md +0 -1
  79. package/src/bash-classifier.ts +0 -1626
  80. package/src/bash-conversion/decide.ts +0 -812
  81. package/src/bash-display-transform.ts +0 -50
  82. package/src/bash-multifile-config.ts +0 -239
  83. package/src/bash-view-config.ts +0 -158
  84. package/src/edit-normalize.ts +0 -8
  85. package/src/edit-render.ts +0 -161
  86. package/src/edit-tool.ts +0 -118
  87. package/src/edit.ts +0 -136
  88. package/src/integrations/pi-lens/command.ts +0 -712
  89. package/src/integrations/pi-lens/config.ts +0 -148
  90. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  91. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  92. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  93. package/src/lifecycle-hooks/index.ts +0 -1992
  94. package/src/lifecycle-hooks/types.ts +0 -64
  95. package/src/mutated-files.ts +0 -75
  96. package/src/mutation-engine/engine.ts +0 -239
  97. package/src/mutation-engine/index.ts +0 -17
  98. package/src/payload-contract.ts +0 -685
  99. package/src/preview-controller.ts +0 -84
  100. package/src/read.ts +0 -865
  101. package/src/search-hook.ts +0 -41
  102. package/src/store-config.ts +0 -73
  103. package/src/store-janitor-warning.ts +0 -169
  104. package/src/store-janitor.ts +0 -539
  105. package/src/store-kernel/settings.ts +0 -233
  106. package/src/store.ts +0 -154
  107. package/src/tui-presenter.ts +0 -134
  108. package/src/write-hook.ts +0 -130
@@ -0,0 +1,201 @@
1
+ import { lstat, mkdir, realpath, rename, rm, rmdir } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { DomainError } from "./domain-errors.js";
4
+ import { reportDiagnostic } from "./diagnostics.js";
5
+ import { resolveTarget } from "./fs-write.js";
6
+ import { errCode } from "./utils.js";
7
+
8
+ /**
9
+ * Filesystem primitives for apply_patch's file lifecycle: classify, create directories, delete a
10
+ * regular file and rename one. Every entry point resolves through {@link resolveTarget}, the same
11
+ * symlink-resolving boundary every read/write uses, so a lifecycle action can never escape it.
12
+ *
13
+ * These are primitives, not a second write engine: file CONTENTS still go through writeAtomic;
14
+ * this module only owns namespace operations that writeAtomic deliberately does not express.
15
+ *
16
+ * @module dsh-hashline-edit/fs-lifecycle
17
+ */
18
+
19
+ /** The physical kind of a path as the file layer sees it (symlinks resolved first). */
20
+ export type PathKind = "file" | "directory" | "other" | "absent";
21
+
22
+ /** Classify one path through the file layer's symlink-resolving boundary. */
23
+ export async function pathKind(path: string): Promise<PathKind> {
24
+ const targetPath = await resolveTarget(path);
25
+ try {
26
+ const info = await lstat(targetPath);
27
+ if (info.isSymbolicLink()) return "other";
28
+ if (info.isFile()) return "file";
29
+ if (info.isDirectory()) return "directory";
30
+ return "other";
31
+ } catch (error: unknown) {
32
+ if (errCode(error) === "ENOENT") return "absent";
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * True when `path` resolves inside `root` once every existing symlink/junction component is
39
+ * followed. A missing tail is checked against its nearest existing ancestor: an Add File under a
40
+ * junction ancestor therefore fails before mkdir can create anything outside the workspace.
41
+ */
42
+ export async function isPathWithinWorkspace(root: string, path: string): Promise<boolean> {
43
+ const absolutePath = resolve(path);
44
+ const { root: fsRoot } = parse(absolutePath);
45
+ let cursor = fsRoot;
46
+ for (const part of absolutePath
47
+ .slice(fsRoot.length)
48
+ .split(sep)
49
+ .filter((value) => value.length > 0)) {
50
+ cursor = join(cursor, part);
51
+ try {
52
+ const info = await lstat(cursor);
53
+ if (info.isSymbolicLink()) return false;
54
+ // WHY: Windows junctions are reparse points that some Node builds do not classify as
55
+ // WHY: symlinks; a realpath that differs from the lexical path proves a link component.
56
+ if ((await realpath(cursor)) !== cursor) return false;
57
+ } catch (error: unknown) {
58
+ if (errCode(error) === "ENOENT") break;
59
+ throw error;
60
+ }
61
+ }
62
+ const rootReal = await realpath(resolve(root));
63
+ let ancestor = absolutePath;
64
+ for (;;) {
65
+ try {
66
+ const real = await realpath(ancestor);
67
+ const rel = relative(rootReal, real);
68
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
69
+ } catch (error: unknown) {
70
+ if (errCode(error) !== "ENOENT") throw error;
71
+ const parent = dirname(ancestor);
72
+ if (parent === ancestor) return false;
73
+ ancestor = parent;
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Throw the E_PATCH_SYNTAX boundary refusal for one escaping lifecycle path. */
79
+ export async function assertPathWithinWorkspace(root: string, path: string): Promise<void> {
80
+ if (!(await isPathWithinWorkspace(root, path))) {
81
+ throw new DomainError("E_PATCH_SYNTAX", {
82
+ message: `path escapes the workspace through a symlink or junction: ${path}`,
83
+ path,
84
+ });
85
+ }
86
+ }
87
+
88
+ /** Create one directory (and missing parents), through the symlink-resolving boundary. */
89
+ export async function mkdirp(path: string, workspaceRoot?: string): Promise<void> {
90
+ if (path.includes("\0")) {
91
+ throw new DomainError("E_INTERNAL_INVARIANT", { message: "Path contains null byte." });
92
+ }
93
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
94
+ const targetPath = await resolveTarget(path);
95
+ await mkdir(targetPath, { recursive: true });
96
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
97
+ }
98
+
99
+ /**
100
+ * Delete one regular file. Deliberately NOT recursive: a lifecycle delete targets a file, and a
101
+ * directory at the same path is a caller bug that must surface, never be removed wholesale.
102
+ */
103
+ export async function removeFile(path: string, signal?: AbortSignal): Promise<void> {
104
+ signal?.throwIfAborted();
105
+ const targetPath = await resolveTarget(path);
106
+ const kind = await pathKind(targetPath);
107
+ if (kind === "absent") {
108
+ const error = new Error(
109
+ "ENOENT: no such file or directory, unlink '" + targetPath + "'",
110
+ ) as NodeJS.ErrnoException;
111
+ error.code = "ENOENT";
112
+ throw error;
113
+ }
114
+ if (kind !== "file") {
115
+ throw new DomainError("E_INTERNAL_INVARIANT", {
116
+ message: "Refusing to delete a non-file lifecycle target: " + path,
117
+ });
118
+ }
119
+ await rm(targetPath, { force: false });
120
+ }
121
+
122
+ /**
123
+ * Rename one regular file, creating the destination parent first. The destination must not exist:
124
+ * a move never clobbers another file (spec §6: target-exists is a rejection, not an overwrite).
125
+ */
126
+ export async function renameFile(
127
+ from: string,
128
+ to: string,
129
+ signal?: AbortSignal,
130
+ workspaceRoot?: string,
131
+ ): Promise<void> {
132
+ signal?.throwIfAborted();
133
+ if (workspaceRoot !== undefined) {
134
+ await assertPathWithinWorkspace(workspaceRoot, from);
135
+ await assertPathWithinWorkspace(workspaceRoot, to);
136
+ }
137
+ const fromPath = await resolveTarget(from);
138
+ const toPath = await resolveTarget(to);
139
+ const destinationKind = await pathKind(toPath);
140
+ if (destinationKind !== "absent") {
141
+ const error = new Error(
142
+ "EEXIST: file already exists, rename '" + fromPath + "' -> '" + toPath + "'",
143
+ ) as NodeJS.ErrnoException;
144
+ error.code = "EEXIST";
145
+ throw error;
146
+ }
147
+ await mkdirp(dirname(toPath), workspaceRoot);
148
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, to);
149
+ await rename(fromPath, toPath);
150
+ }
151
+
152
+ /**
153
+ * The directories on the way to `path` that do not exist yet, OUTERMOST FIRST. Captured before a
154
+ * lifecycle add writes, so an undo removes exactly the directories the add created and no others.
155
+ */
156
+ export async function missingAncestorDirs(path: string): Promise<string[]> {
157
+ const targetPath = await resolveTarget(path);
158
+ const missing: string[] = [];
159
+ let cursor = dirname(targetPath);
160
+ const { root } = parse(cursor);
161
+ while (cursor !== root) {
162
+ try {
163
+ const info = await lstat(cursor);
164
+ if (info.isDirectory()) break;
165
+ break;
166
+ } catch (error: unknown) {
167
+ if (errCode(error) !== "ENOENT") throw error;
168
+ missing.unshift(cursor);
169
+ cursor = dirname(cursor);
170
+ }
171
+ }
172
+ return missing;
173
+ }
174
+
175
+ /**
176
+ * Best-effort removal of directories a lifecycle add created, deepest first. Returns the removed
177
+ * paths. A directory that is no longer empty (an outside writer put something there) is kept.
178
+ */
179
+ export async function pruneEmptyDirs(dirs: readonly string[]): Promise<string[]> {
180
+ const removed: string[] = [];
181
+ for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
182
+ try {
183
+ await rmdir(dir);
184
+ removed.push(dir);
185
+ } catch (error: unknown) {
186
+ const code = errCode(error);
187
+ if (
188
+ code === "ENOENT" ||
189
+ code === "ENOTEMPTY" ||
190
+ code === "EEXIST" ||
191
+ code === "EPERM" ||
192
+ code === "EISDIR" ||
193
+ code === "ENOTDIR"
194
+ ) {
195
+ continue;
196
+ }
197
+ reportDiagnostic("fs-lifecycle", "failed to prune a lifecycle-created directory:", error);
198
+ }
199
+ }
200
+ return removed;
201
+ }
package/src/hash-store.ts CHANGED
@@ -156,7 +156,16 @@ function openDb(storePath: string, budget?: BusyBudget): OpenedDb {
156
156
  const MIGRATION_COLUMNS = {
157
157
  cut_intent: { direction: "TEXT" },
158
158
  file_snapshots: { stat_key: "TEXT" },
159
- file_undo: { snapshot_hash: "TEXT", transaction_id: "TEXT", raw_pre: "TEXT" },
159
+ file_undo: {
160
+ snapshot_hash: "TEXT",
161
+ transaction_id: "TEXT",
162
+ raw_pre: "TEXT",
163
+ lifecycle: "TEXT",
164
+ from_path: "TEXT",
165
+ to_path: "TEXT",
166
+ raw_post: "TEXT",
167
+ created_dirs: "TEXT",
168
+ },
160
169
  served: { retired: "TEXT", canons: "TEXT", snapshotId: "TEXT" },
161
170
  undo: { result_content: "TEXT NOT NULL DEFAULT ''" },
162
171
  } as const;
@@ -625,6 +634,14 @@ export function ensureFileUndoSchema(db: DatabaseSync): void {
625
634
  addColumnIfMissing(db, "file_undo", "snapshot_hash");
626
635
  addColumnIfMissing(db, "file_undo", "transaction_id");
627
636
  addColumnIfMissing(db, "file_undo", "raw_pre");
637
+ // WHY: apply_patch lifecycle rows are still edit-shaped rows: one file_undo row per member,
638
+ // WHY: one shared transaction id. These columns only widen what a row can describe — an
639
+ // WHY: existing row (all NULL) stays an ordinary edit, so the addition is back-compatible.
640
+ addColumnIfMissing(db, "file_undo", "lifecycle");
641
+ addColumnIfMissing(db, "file_undo", "from_path");
642
+ addColumnIfMissing(db, "file_undo", "to_path");
643
+ addColumnIfMissing(db, "file_undo", "raw_post");
644
+ addColumnIfMissing(db, "file_undo", "created_dirs");
628
645
  // WHY: versions the persisted anchor set — existing rows keep 0 (never current),
629
646
  // WHY: so a pre-bump store is refused, not migrated, on first open after upgrade.
630
647
  // WHY: `canon_version` (pre-rename builds) is renamed in place, preserving stamped values.
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * WHY an environment variable rather than a settings-file key: `alphabet.ts` is a zero-import leaf
5
5
  * and every shape, regex, bitset and capacity constant below is derived at module load, so the
6
- * width can only be a process-wide, load-time value. Switching it changes every served anchor; the
7
- * persisted store stays safe because the anchor generation (`hash-identity.ts`) folds the width in,
8
- * so a switch is a hard generation miss (re-read), never a cross-width hit.
6
+ * width can only be a process-wide, load-time value. The build bakes it via esbuild `define`
7
+ * (`__ANCHOR_WIDTH__`), so a shipped bundle carries a literal and never reads the environment; the
8
+ * anchor generation (`hash-identity.ts`) folds the width in, so a width switch is a hard generation
9
+ * miss (re-read), never a cross-width hit.
9
10
  */
10
11
  export const ANCHOR_WIDTH_ENV_VAR = "PI_BETTER_EDIT_ANCHOR_WIDTH";
11
12
 
@@ -31,10 +32,9 @@ export function resolveAnchorWidth(env: Record<string, string | undefined>): num
31
32
  }
32
33
 
33
34
  /**
34
- * Build-time bake hook. The dsh/pi builds pass esbuild `define: __ANCHOR_WIDTH__ = <width>` so the
35
- * shipped bundle carries a literal width and NEVER consults the environment at runtime. When there
36
- * is no define (vitest / an unbundled dev import), the `typeof` guard falls back to the resolver
37
- * below, keeping the zero-import leaf usable in tests without a build step.
35
+ * Build-time bake hook. The dsh build passes esbuild `define: __ANCHOR_WIDTH__ = <width>` so the
36
+ * shipped bundle carries a literal width and NEVER consults the environment at runtime. Without a
37
+ * define (vitest / an unbundled dev import) the `typeof` guard falls back to the resolver above.
38
38
  */
39
39
  declare const __ANCHOR_WIDTH__: number | undefined;
40
40
 
@@ -112,23 +112,28 @@ export const HASH_PROBE_STRIDE = ALPHA.length ** 2 + ALPHA.length + 1;
112
112
  export const CANON_VERSION = 3;
113
113
 
114
114
  /**
115
- * The file-scoped anchor-derivation generation (issue #20, ADR-0031; ADR-0020 for the width axis).
115
+ * ANCHOR_GENERATION 1 (issue #20, ADR-0031): the file-scoped anchor-derivation generation.
116
116
  * It covers exactly: (1) the width (`HASH_LEN`, `ALPHA`, `HASH_SPACE`/`USABLE_HASH_SPACE`);
117
117
  * (2) the (path, content) seeding (`fileBaseIndex` seeds xxh32 with the canonical absolute path,
118
118
  * `contentBaseIndex` is the explicit content-only fallback); (3) the all-digit reservation
119
119
  * (`DIGIT_CHARS`/`RESERVED_HASH_SPELLINGS`/`RESERVED_BITS` pre-marked in allocation); (4) the probe
120
120
  * stride (`HASH_PROBE_STRIDE`, coprime with both spaces). Bump it whenever any of those change.
121
121
  *
122
- * WHY it is a function of the width now: the width is a runtime switch (default 4, optional 3), so a
123
- * width change MUST flip the generation or a store written at one width could be read at another.
124
- * Width 4 stays generation 1 — the original persisted generation — so existing stores keep their
125
- * anchors across this change; width 3 gets generation 2, and any future width gets `100 + width`.
126
- *
127
- * WHY: single owner of the anchor-generation value — anchor-bearing artifacts (the undo/snapshot
122
+ * WHY: single owner of the anchor-generation literal — anchor-bearing artifacts (the undo/snapshot
128
123
  * generation gate and the open-time sweep) read this axis only, never `CANON_VERSION`; the snapshot
129
124
  * cache key carries both (`${CANON_VERSION}:${ANCHOR_GENERATION}:${checksum}`), so a canon-only change
130
125
  * misses the cache without invalidating anchor state while a generation change invalidates both.
131
126
  */
127
+ /**
128
+ * The file-scoped anchor-derivation generation. It covers the width (`HASH_LEN`, `ALPHA`,
129
+ * `HASH_SPACE`/`USABLE_HASH_SPACE`), the (path, content) seeding, the all-digit reservation and the
130
+ * probe stride. Bump it whenever any of those change.
131
+ *
132
+ * WHY it is a function of the width: the width is a build-time switch (ADR-0020: default 4, optional
133
+ * 3), so a width change MUST flip the generation or a store written at one width could be read at
134
+ * another. Width 4 stays generation 1 — the original persisted generation — so existing stores keep
135
+ * their anchors; width 3 gets generation 2, and any future width gets `100 + width`.
136
+ */
132
137
  export function anchorGenerationFor(width: number): number {
133
138
  return width === 4 ? 1 : width === 3 ? 2 : 100 + width;
134
139
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Host-neutral utilities the hashline core needs from the historical pi runtime.
3
+ *
4
+ * WHY: the dsh plugin ships without the pi agent package. Three core modules used
5
+ * to import small utility functions from `@earendil-works/pi-coding-agent`:
6
+ * `src/file-content/preview.ts` and `src/file-content/served-header.ts` use
7
+ * `formatSize` / `truncateHead` / `DEFAULT_MAX_LINES` / `DEFAULT_MAX_BYTES`
8
+ * to render read pages, and `src/mutation-queue.ts` uses
9
+ * `withFileMutationQueue` to serialize same-file mutations for the cut
10
+ * transaction and its correlated undo. This module owns that surface as plain
11
+ * host-neutral code; `dsh/host-compat.ts` re-exports it for the dsh bundle.
12
+ *
13
+ * @module dsh-hashline-edit/host-utils
14
+ */
15
+ import { realpath } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+
18
+ /** Retained line budget, matching pi's `DEFAULT_MAX_LINES`. */
19
+ export const DEFAULT_MAX_LINES = 2000;
20
+
21
+ /** Retained byte budget, matching pi's `DEFAULT_MAX_BYTES` (50KB). */
22
+ export const DEFAULT_MAX_BYTES = 50 * 1024;
23
+
24
+ /** The truncation report the core's read page renders. */
25
+ export interface TruncationResult {
26
+ /** The truncated content. */
27
+ content: string;
28
+ /** Whether truncation occurred. */
29
+ truncated: boolean;
30
+ /** Which limit was hit: `"lines"`, `"bytes"`, or `null` when nothing was cut. */
31
+ truncatedBy: "lines" | "bytes" | null;
32
+ /** Total number of lines in the original content. */
33
+ totalLines: number;
34
+ /** Total number of bytes in the original content. */
35
+ totalBytes: number;
36
+ /** Number of complete lines in the truncated output. */
37
+ outputLines: number;
38
+ /** Number of bytes in the truncated output. */
39
+ outputBytes: number;
40
+ /** Whether the last line was partially truncated (tail truncation only). */
41
+ lastLinePartial: boolean;
42
+ /** Whether the first line exceeded the byte limit (head truncation only). */
43
+ firstLineExceedsLimit: boolean;
44
+ /** The line limit that was applied. */
45
+ maxLines: number;
46
+ /** The byte limit that was applied. */
47
+ maxBytes: number;
48
+ }
49
+
50
+ /** The two independent limits a truncation call applies. */
51
+ export interface TruncationOptions {
52
+ /** Maximum number of lines; defaults to {@link DEFAULT_MAX_LINES}. */
53
+ maxLines?: number;
54
+ /** Maximum number of bytes; defaults to {@link DEFAULT_MAX_BYTES}. */
55
+ maxBytes?: number;
56
+ }
57
+
58
+ /** Split content into lines for counting, dropping the empty tail of a trailing newline. */
59
+ function splitLinesForCounting(content: string): string[] {
60
+ if (content.length === 0) return [];
61
+ const lines = content.split("\n");
62
+ if (content.endsWith("\n")) lines.pop();
63
+ return lines;
64
+ }
65
+
66
+ /**
67
+ * Format bytes as a human-readable size (`512B`, `1.5KB`, `2.0MB`).
68
+ * @param bytes - the byte count to render.
69
+ * @returns the pi-compatible size label.
70
+ */
71
+ export function formatSize(bytes: number): string {
72
+ if (bytes < 1024) return `${bytes}B`;
73
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
74
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
75
+ }
76
+
77
+ /**
78
+ * Truncate content from the head (keep the first N lines / bytes).
79
+ *
80
+ * WHY: the read page's oversize and truncation warnings must agree with the pi
81
+ * implementation the rest of the contract was written against, so this is a
82
+ * faithful port including its two edge rules: complete lines only, and an
83
+ * over-long first line yields empty content with `firstLineExceedsLimit`.
84
+ * @param content - the text to truncate.
85
+ * @param options - the line and byte limits.
86
+ * @returns the truncation report.
87
+ */
88
+ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
89
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
90
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
91
+ const totalBytes = Buffer.byteLength(content, "utf-8");
92
+ const lines = splitLinesForCounting(content);
93
+ const totalLines = lines.length;
94
+
95
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
96
+ return {
97
+ content,
98
+ truncated: false,
99
+ truncatedBy: null,
100
+ totalLines,
101
+ totalBytes,
102
+ outputLines: totalLines,
103
+ outputBytes: totalBytes,
104
+ lastLinePartial: false,
105
+ firstLineExceedsLimit: false,
106
+ maxLines,
107
+ maxBytes,
108
+ };
109
+ }
110
+
111
+ const firstLineBytes = Buffer.byteLength(lines[0] ?? "", "utf-8");
112
+ if (firstLineBytes > maxBytes) {
113
+ return {
114
+ content: "",
115
+ truncated: true,
116
+ truncatedBy: "bytes",
117
+ totalLines,
118
+ totalBytes,
119
+ outputLines: 0,
120
+ outputBytes: 0,
121
+ lastLinePartial: false,
122
+ firstLineExceedsLimit: true,
123
+ maxLines,
124
+ maxBytes,
125
+ };
126
+ }
127
+
128
+ const outputLinesArr: string[] = [];
129
+ let outputBytesCount = 0;
130
+ let truncatedBy: "lines" | "bytes" = "lines";
131
+ for (let index = 0; index < lines.length && index < maxLines; index += 1) {
132
+ const line = lines[index] ?? "";
133
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (index > 0 ? 1 : 0);
134
+ if (outputBytesCount + lineBytes > maxBytes) {
135
+ truncatedBy = "bytes";
136
+ break;
137
+ }
138
+ outputLinesArr.push(line);
139
+ outputBytesCount += lineBytes;
140
+ }
141
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
142
+ truncatedBy = "lines";
143
+ }
144
+
145
+ const outputContent = outputLinesArr.join("\n");
146
+ return {
147
+ content: outputContent,
148
+ truncated: true,
149
+ truncatedBy,
150
+ totalLines,
151
+ totalBytes,
152
+ outputLines: outputLinesArr.length,
153
+ outputBytes: Buffer.byteLength(outputContent, "utf-8"),
154
+ lastLinePartial: false,
155
+ firstLineExceedsLimit: false,
156
+ maxLines,
157
+ maxBytes,
158
+ };
159
+ }
160
+
161
+ /** The per-path queue tails; an entry exists only while a mutation holds or awaits it. */
162
+ const fileMutationQueues = new Map<string, Promise<void>>();
163
+
164
+ /** WHY: queue registration itself must be serialized so two callers cannot adopt the same tail. */
165
+ let registrationQueue: Promise<void> = Promise.resolve();
166
+
167
+ /** Whether a realpath failure means "no such path" (the queue then keys by the resolved path). */
168
+ function isMissingPathError(error: unknown): boolean {
169
+ return (
170
+ typeof error === "object" &&
171
+ error !== null &&
172
+ "code" in error &&
173
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
174
+ );
175
+ }
176
+
177
+ /** Key a mutation by the canonical path, falling back to the resolved path for missing files. */
178
+ async function getMutationQueueKey(filePath: string): Promise<string> {
179
+ const resolvedPath = resolve(filePath);
180
+ try {
181
+ return await realpath(resolvedPath);
182
+ } catch (error) {
183
+ if (isMissingPathError(error)) return resolvedPath;
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Serialize file mutation operations that target the same file; different files still run
190
+ * in parallel. A faithful port of pi's queue so the core's cut transaction keeps its
191
+ * lock ordering guarantees under the dsh host.
192
+ * @param filePath - the file the mutation targets.
193
+ * @param run - the critical section to run once the path is free.
194
+ * @returns the critical section's result.
195
+ */
196
+ export async function withFileMutationQueue<T>(
197
+ filePath: string,
198
+ run: () => Promise<T>,
199
+ ): Promise<T> {
200
+ const registration = registrationQueue.then(async () => {
201
+ const key = await getMutationQueueKey(filePath);
202
+ const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
203
+ let releaseNext: () => void = () => {};
204
+ const nextQueue = new Promise<void>((resolveQueue) => {
205
+ releaseNext = resolveQueue;
206
+ });
207
+ const chainedQueue = currentQueue.then(() => nextQueue);
208
+ fileMutationQueues.set(key, chainedQueue);
209
+ return { key, currentQueue, chainedQueue, releaseNext };
210
+ });
211
+ registrationQueue = registration.then(
212
+ () => undefined,
213
+ () => undefined,
214
+ );
215
+
216
+ const { key, currentQueue, chainedQueue, releaseNext } = await registration;
217
+ await currentQueue;
218
+ try {
219
+ return await run();
220
+ } finally {
221
+ releaseNext();
222
+ if (fileMutationQueues.get(key) === chainedQueue) fileMutationQueues.delete(key);
223
+ }
224
+ }
@@ -0,0 +1,39 @@
1
+ import type { EditPlacement } from "../hashline/resolve.js";
2
+
3
+ /** The internal payload vocabulary the mutation engine consumes; no wire schema lives here. */
4
+ type SpanRef = {
5
+ anchor_from: string;
6
+ anchor_to: string;
7
+ file?: string;
8
+ mode: "copy" | "cut";
9
+ };
10
+
11
+ type EditMode = "general" | "literal";
12
+
13
+ type Placement = "in-place" | "before" | "after";
14
+
15
+ export type DesiredContent =
16
+ | { kind: "literal"; text: string }
17
+ | { kind: "reference"; span: SpanRef; mode: "copy" | "cut" }
18
+ | { kind: "empty" };
19
+
20
+ export type NormalizedEditItem = {
21
+ target: { anchor_from: string; anchor_to: string };
22
+ at: Placement;
23
+ payload: DesiredContent;
24
+ file?: string;
25
+ };
26
+
27
+ export type NormalizedEditRequest = {
28
+ file: string;
29
+ edits: NormalizedEditItem[];
30
+ mode?: EditMode;
31
+ };
32
+
33
+ // WHY: the engine seam's placement union must stay mutually assignable with the hashline resolver's
34
+ // WHY: EditPlacement; this assertion fails typecheck if either side drifts.
35
+ const _placementAgreement: [
36
+ Placement extends EditPlacement ? true : never,
37
+ EditPlacement extends Placement ? true : never,
38
+ ] = [true, true];
39
+ void _placementAgreement;