dsh-hashline-edit 0.2.1 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Hash-anchored file editing for [DeepSeek Harness](https://github.com/deepseek-ai) (dsh) on Windows.
4
4
 
5
- `dsh-hashline-edit` is a dsh bundle that shadows the host's built-in `read` with hash-anchored output, registers `apply_patch` as the only file-mutation entry point, and adds `undo_last_edit`. The native `write`/`edit`/`str_replace_editor` surface is hidden; there is no pi host support, because this package targets DeepSeek Harness only. Every line a served read shows carries a stable 4-character base62 anchor (`A–Z`, `a–z`, `0–9`) derived from the file's canonical path and the line's content. An `apply_patch` hunk either names served anchors or matches file content; the model never re-types existing code. Anchors for unchanged lines stay stable across edits, and every successful `apply_patch` call returns fresh anchors for the changed lines, so chained calls need no re-read.
5
+ `dsh-hashline-edit` is a dsh bundle that shadows the host's built-in `read` with hash-anchored output, registers `apply_patch` as the only file-mutation entry point, and adds `undo_last_edit`. The native `write`/`edit`/`str_replace_editor` surface is hidden; there is no pi host support, because this package targets DeepSeek Harness only. Every line a served read shows carries a stable 3- or 4-character base62 anchor (`A–Z`, `a–z`, `0–9`; the width is a build-time setting, see [Anchor width](#anchor-width)) derived from the file's canonical path and the line's content. An `apply_patch` hunk either names served anchors or matches file content; the model never re-types existing code. Anchors for unchanged lines stay stable across edits, and every successful `apply_patch` call returns fresh anchors for the changed lines, so chained calls need no re-read.
6
6
 
7
7
  The semantic core is the [pi-better-edit](https://github.com/Rianico/pi-better-edit) v2.16.0 hashline engine (MIT), carried as an internal engine; the pi host integration has been removed. The `dsh/` tree is this repository's DeepSeek Harness host layer: tool registration, filesystem bridge, UTF-8-only admission, prompt guidance, shell integration, write integration and telemetry. See [`dsh/README.md`](dsh/README.md) for the host-layer map.
8
8
 
@@ -15,6 +15,22 @@ The semantic core is the [pi-better-edit](https://github.com/Rianico/pi-better-e
15
15
  | pnpm | 12.x |
16
16
  | DeepSeek Harness | plugin-capable build; the repository is developed against `@deepseek-ai/dsh-*@0.2.2-rc.1` (host packages are declared as peer dependencies) |
17
17
 
18
+ ### Anchor width (3 or 4)
19
+
20
+ The anchor width is baked into the build. Set `PI_BETTER_EDIT_ANCHOR_WIDTH` when you build or pack:
21
+
22
+ - `PI_BETTER_EDIT_ANCHOR_WIDTH=3 pnpm run build` (and `pnpm pack`) produces a fully 3-char artifact.
23
+ - unset or `4` builds the 4-char default.
24
+
25
+ | Value | Anchor width | Usable space | Anchor generation |
26
+ | --- | --- | --- | --- |
27
+ | unset or `4` (default) | 4 chars | 14,766,336 | 1 |
28
+ | `3` | 3 chars | 237,328 | 2 |
29
+
30
+ The value is inlined into the bundle at build time (`esbuild define`), so the installed plugin never reads an environment variable at runtime — a 3-char build stays 3-char. The persisted store is keyed by the anchor generation, so changing the build width is a hard cache miss (served rows are simply re-read), never a cross-width hit. Width 4 keeps generation 1, so existing stores keep working unchanged.
31
+
32
+ Measured on the DeepSeek-V4.1 tokenizer, a 3-char anchor averages ~2.3 tokens and a 4-char anchor ~3.0 tokens per served line, and neither collapses to one token (see [ADR-0020](docs/adr/0020-configurable-anchor-width-3-4.md)). The default stays 4 to preserve the existing persisted contract; build with `PI_BETTER_EDIT_ANCHOR_WIDTH=3` to ship 3-char.
33
+
18
34
  ## Install
19
35
 
20
36
  Build the plugin first: the dsh loader reads the package through `exports`/`main`, and `lib/` is gitignored.
package/lib/index.js CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  import { dirname as dirname2, join as join4, parse, resolve, sep } from "node:path";
46
46
 
47
47
  // src/hashline/alphabet.ts
48
- var HASH_LEN = 4;
48
+ var HASH_LEN = true ? 3 : resolveAnchorWidth(process.env);
49
49
  var ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
50
50
  var ALPHA_SAFE = ALPHA.replace(/-/g, "\\-");
51
51
  var ALPHA_RE = new RegExp(`^[${ALPHA_SAFE}]+$`);
@@ -1873,23 +1873,22 @@ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
1873
1873
 
1874
1874
  // src/prompts.ts
1875
1875
  import { readFileSync } from "node:fs";
1876
- function loadP(relativePath2, replacements) {
1877
- let content = readFileSync(new URL(relativePath2, import.meta.url), "utf-8").trim();
1876
+ function applyReplacements(content, replacements) {
1877
+ let out = content;
1878
1878
  if (replacements) {
1879
1879
  for (const [key, value] of Object.entries(replacements)) {
1880
- content = content.split(`{{${key}}}`).join(value);
1880
+ out = out.split(`{{${key}}}`).join(value);
1881
1881
  }
1882
1882
  }
1883
- return content;
1883
+ return out.split("{{HASH_LEN}}").join(String(HASH_LEN));
1884
+ }
1885
+ function loadP(relativePath2, replacements) {
1886
+ const content = readFileSync(new URL(relativePath2, import.meta.url), "utf-8").trim();
1887
+ return applyReplacements(content, replacements);
1884
1888
  }
1885
1889
  function loadGuide(relativePath2, replacements) {
1886
- let content = readFileSync(new URL(relativePath2, import.meta.url), "utf-8");
1887
- if (replacements) {
1888
- for (const [key, value] of Object.entries(replacements)) {
1889
- content = content.split(`{{${key}}}`).join(value);
1890
- }
1891
- }
1892
- return content.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("- ")).map((line) => line.slice(2));
1890
+ const content = readFileSync(new URL(relativePath2, import.meta.url), "utf-8");
1891
+ return applyReplacements(content, replacements).split("\n").map((line) => line.trim()).filter((line) => line.startsWith("- ")).map((line) => line.slice(2));
1893
1892
  }
1894
1893
 
1895
1894
  // dsh/guidance.ts
@@ -2612,7 +2611,10 @@ var HASH_SPACE_EXHAUSTED_PAYLOAD = {
2612
2611
  };
2613
2612
  var HASH_PROBE_STRIDE = ALPHA.length ** 2 + ALPHA.length + 1;
2614
2613
  var CANON_VERSION = 3;
2615
- var ANCHOR_GENERATION = 1;
2614
+ function anchorGenerationFor(width) {
2615
+ return width === 4 ? 1 : width === 3 ? 2 : 100 + width;
2616
+ }
2617
+ var ANCHOR_GENERATION = anchorGenerationFor(HASH_LEN);
2616
2618
  var CANON_CODE_POINTS = [
2617
2619
  9,
2618
2620
  10,
@@ -14,9 +14,9 @@ export type StoreOpenHook = (db: DatabaseSync, info: {
14
14
  }) => void | Promise<void>;
15
15
  export declare function onStoreOpen(hook: StoreOpenHook): void;
16
16
  /** `file_snapshots` rows whose persisted anchor generation is not the current one. */
17
- export declare const FOREIGN_GENERATION_SNAPSHOT_SQL = "anchor_generation != 1";
17
+ export declare const FOREIGN_GENERATION_SNAPSHOT_SQL: string;
18
18
  /** `served_leases` rows whose served snapshot hash carries a foreign anchor generation. */
19
- export declare const FOREIGN_GENERATION_LEASE_SQL = "served_snapshot_hash NOT LIKE '%:1:%'";
19
+ export declare const FOREIGN_GENERATION_LEASE_SQL: string;
20
20
  /** `line_lineage` rows naming a snapshot that no longer exists. */
21
21
  export declare const ORPHAN_LINEAGE_SQL = "snapshot_id NOT IN (SELECT snapshot_id FROM file_snapshots)";
22
22
  /**
@@ -1,4 +1,20 @@
1
- export declare const HASH_LEN = 4;
1
+ /**
2
+ * Anchor width — the single owner of the anchor's length and shape.
3
+ *
4
+ * WHY an environment variable rather than a settings-file key: `alphabet.ts` is a zero-import leaf
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. 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.
10
+ */
11
+ export declare const ANCHOR_WIDTH_ENV_VAR = "PI_BETTER_EDIT_ANCHOR_WIDTH";
12
+ /**
13
+ * Resolve the anchor width from an environment map. Pure and dependency-free so the leaf stays a
14
+ * leaf and the ladder is unit-testable without mutating `process.env`.
15
+ */
16
+ export declare function resolveAnchorWidth(env: Record<string, string | undefined>): number;
17
+ export declare const HASH_LEN: number;
2
18
  export declare const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
3
19
  export declare const ALPHA_RE: RegExp;
4
20
  export declare const HASH_CLASS: string;
@@ -42,7 +42,7 @@ export interface AnchorWalk {
42
42
  /** The anchors this content already has in the store, in line order. */
43
43
  cached?: string[];
44
44
  }
45
- export declare const ANCHOR_LEN = 4;
45
+ export declare const ANCHOR_LEN: number;
46
46
  export declare const HASH_SEP = "\u2502";
47
47
  export declare const HASH_SPACE: number;
48
48
  export declare const USABLE_HASH_SPACE: number;
@@ -73,7 +73,18 @@ export declare const CANON_VERSION = 3;
73
73
  * cache key carries both (`${CANON_VERSION}:${ANCHOR_GENERATION}:${checksum}`), so a canon-only change
74
74
  * misses the cache without invalidating anchor state while a generation change invalidates both.
75
75
  */
76
- export declare const ANCHOR_GENERATION = 1;
76
+ /**
77
+ * The file-scoped anchor-derivation generation. It covers the width (`HASH_LEN`, `ALPHA`,
78
+ * `HASH_SPACE`/`USABLE_HASH_SPACE`), the (path, content) seeding, the all-digit reservation and the
79
+ * probe stride. Bump it whenever any of those change.
80
+ *
81
+ * WHY it is a function of the width: the width is a build-time switch (ADR-0020: default 4, optional
82
+ * 3), so a width change MUST flip the generation or a store written at one width could be read at
83
+ * another. Width 4 stays generation 1 — the original persisted generation — so existing stores keep
84
+ * their anchors; width 3 gets generation 2, and any future width gets `100 + width`.
85
+ */
86
+ export declare function anchorGenerationFor(width: number): number;
87
+ export declare const ANCHOR_GENERATION: number;
77
88
  export declare function canon(line: string): string;
78
89
  /**
79
90
  * SAFETY: the canon digest — `String(xxh32(canon(line)))`, the exact value `line_lineage.canon_hash`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-hashline-edit",
3
- "version": "0.2.1",
4
- "description": "Hash-anchored read/apply_patch tools for DeepSeek Harness (dsh). Every line gets a unique 4-char hash (A-Za-z0-9) that stays stable across edits; apply_patch is the only file-mutation entry point and commits one patch batch per transaction. Undo persists across restarts.",
3
+ "version": "0.2.3",
4
+ "description": "Hash-anchored read/apply_patch tools for DeepSeek Harness (dsh). Every line gets a unique 3- or 4-char hash (A-Za-z0-9, configurable) that stays stable across edits; apply_patch is the only file-mutation entry point and commits one patch batch per transaction. Undo persists across restarts.",
5
5
  "keywords": [
6
6
  "anchors",
7
7
  "apply-patch",
@@ -1,10 +1,10 @@
1
1
  - apply_patch: one call = one transaction. Collect every related file change (code, tests, docs, config) into one patch; split only when an earlier RESULT must decide a later edit.
2
2
  - apply_patch: two addressing modes. Fragment mode (`@@` or `@@ <context>`) locates by exact content and needs no served state. Identity mode (`@@<a>` / `@@<a>-<b>`) targets rows this session served and never writes the old text back.
3
- - apply_patch: the character after `@@` selects the mode. `@@` followed immediately by exactly four anchor characters is identity mode; `@@` followed by a space is Codex context mode (`@@ <line>`); a bare `@@` starts the search at the file start. Any other token is a syntax rejection.
3
+ - apply_patch: the character after `@@` selects the mode. `@@` followed immediately by exactly {{HASH_LEN}} anchor characters is identity mode; `@@` followed by a space is Codex context mode (`@@ <line>`); a bare `@@` starts the search at the file start. Any other token is a syntax rejection.
4
4
  - apply_patch: fragment mode must match exactly once. Zero or several hits reject and serve the candidate rows; add surrounding context to disambiguate, never guess.
5
5
  - apply_patch: identity mode accepts only `+` lines; an empty body deletes the span. A `-` or context line in an identity hunk, or body lines in a Copy/Cut hunk, rejects at admission with its patch line number.
6
6
  - apply_patch: every hunk resolves against the file as it was before the call; later windows begin after the previous hunk's end. Overlapping target ranges in one file reject the whole batch.
7
7
  - apply_patch: lifecycle joins the same transaction and undo. `*** Add File:` content lines all start with `+`; `*** Delete File:` removes the file; `*** Move to:` must immediately follow `*** Update File:` and renames before its hunks apply; an existing move target rejects.
8
8
  - apply_patch: a rejection writes nothing. Read the `- patch line N ([E_*], path): ...` lines, then use the immediately following `HASH│content` block — those are the current rows and anchors; retry directly without issuing a separate `read`.
9
9
  - apply_patch: a success starts with `Updated N files:` and ends with the changed lines' fresh anchors per file; use those anchors directly in the next identity hunk. `[W_*]` lines are informational.
10
- - apply_patch: paths are workspace-relative or workspace-absolute; `..`, `.git`, and symlink targets reject. Whole-file replacement is one `*** Update File:` identity hunk spanning every served line with `+` lines for the new content.
10
+ - apply_patch: paths are workspace-relative or workspace-absolute; `..`, `.git`, and symlink targets reject. Whole-file replacement is one `*** Update File:` identity hunk spanning every served line with `+` lines for the new content.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read one text file via `file`, with optional `windows` and top-level `mode`. To read several files, make parallel `read` calls. Served mode (default) returns each line as HASH│content with a 4-char alphanumeric hash. That hash is the `apply_patch` identity anchor, so copy only the 4 chars before `│`. Pass `mode: "verbatim"` for plain text with no anchors when you will not apply_patch. `mode: "verbatim"` is not capped by line budget. Name ranges with `windows`: `[{ offset, limit }]` (offset is 1-indexed, limit is a line count, maximum 16 windows) or `[{ around_anchor: "a1b2", radius: 10 }]` for a line this session served. When a page stops short, continue from the next unshown line with `windows: [{ offset, limit }]`. A requested window of 61 lines or fewer can widen to its enclosing block before serving; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. A widened page begins with `[widened <requested> -> <served>, <label>]`; more than three requested windows collapse to `[widened N windows: <ranges>]`. An over-cap Markdown section clamps to a cap-sized window and names `(section <extent>)`; an over-cap code or plain-text block refuses, serving the requested window. `mode: "verbatim"` widens the same way but adds no anchors. Inline `[...]` notices describe the served result, not file content. Empty files return `HASH│` (use an apply_patch identity hunk to insert). PNG/JPG/GIF/WebP return as image attachments for multimodal models. Binary and directory files are rejected. Hashes are file-scoped: an anchor is bound to the file it was served from.
1
+ Read one text file via `file`, with optional `windows` and top-level `mode`. To read several files, make parallel `read` calls. Served mode (default) returns each line as HASH│content with a {{HASH_LEN}}-char alphanumeric hash. That hash is the `apply_patch` identity anchor, so copy only the {{HASH_LEN}} chars before `│`. Pass `mode: "verbatim"` for plain text with no anchors when you will not apply_patch. `mode: "verbatim"` is not capped by line budget. Name ranges with `windows`: `[{ offset, limit }]` (offset is 1-indexed, limit is a line count, maximum 16 windows) or `[{ around_anchor: "<anchor>", radius: 10 }]` for a line this session served. When a page stops short, continue from the next unshown line with `windows: [{ offset, limit }]`. A requested window of 61 lines or fewer can widen to its enclosing block before serving; overlapping or adjacent windows fold before this test. Caps are 80 lines for code and Markdown, 40 for plain text. A widened page begins with `[widened <requested> -> <served>, <label>]`; more than three requested windows collapse to `[widened N windows: <ranges>]`. An over-cap Markdown section clamps to a cap-sized window and names `(section <extent>)`; an over-cap code or plain-text block refuses, serving the requested window. `mode: "verbatim"` widens the same way but adds no anchors. Inline `[...]` notices describe the served result, not file content. Empty files return `HASH│` (use an apply_patch identity hunk to insert). PNG/JPG/GIF/WebP return as image attachments for multimodal models. Binary and directory files are rejected. Hashes are file-scoped: an anchor is bound to the file it was served from.
@@ -1,4 +1,45 @@
1
- export const HASH_LEN = 4;
1
+ /**
2
+ * Anchor width — the single owner of the anchor's length and shape.
3
+ *
4
+ * WHY an environment variable rather than a settings-file key: `alphabet.ts` is a zero-import leaf
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. 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.
10
+ */
11
+ export const ANCHOR_WIDTH_ENV_VAR = "PI_BETTER_EDIT_ANCHOR_WIDTH";
12
+
13
+ /** Supported widths; anything else is rejected at load so a typo cannot silently pick a stale width. */
14
+ const SUPPORTED_WIDTH_TOKENS = ["3", "4"] as const;
15
+
16
+ /** Default width when the env var is unset/empty — the historical default. */
17
+ const DEFAULT_WIDTH = 4;
18
+
19
+ /**
20
+ * Resolve the anchor width from an environment map. Pure and dependency-free so the leaf stays a
21
+ * leaf and the ladder is unit-testable without mutating `process.env`.
22
+ */
23
+ export function resolveAnchorWidth(env: Record<string, string | undefined>): number {
24
+ const raw = env[ANCHOR_WIDTH_ENV_VAR];
25
+ if (raw === undefined || raw.trim() === "") return DEFAULT_WIDTH;
26
+ const value = raw.trim();
27
+ if (value === "3") return 3;
28
+ if (value === "4") return 4;
29
+ throw new Error(
30
+ `${ANCHOR_WIDTH_ENV_VAR} must be ${SUPPORTED_WIDTH_TOKENS.join(" or ")} (got ${JSON.stringify(raw)}).`,
31
+ );
32
+ }
33
+
34
+ /**
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
+ */
39
+ declare const __ANCHOR_WIDTH__: number | undefined;
40
+
41
+ export const HASH_LEN =
42
+ typeof __ANCHOR_WIDTH__ === "number" ? __ANCHOR_WIDTH__ : resolveAnchorWidth(process.env);
2
43
 
3
44
  export const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
4
45
 
@@ -124,7 +124,21 @@ export const CANON_VERSION = 3;
124
124
  * cache key carries both (`${CANON_VERSION}:${ANCHOR_GENERATION}:${checksum}`), so a canon-only change
125
125
  * misses the cache without invalidating anchor state while a generation change invalidates both.
126
126
  */
127
- export const ANCHOR_GENERATION = 1;
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
+ */
137
+ export function anchorGenerationFor(width: number): number {
138
+ return width === 4 ? 1 : width === 3 ? 2 : 100 + width;
139
+ }
140
+
141
+ export const ANCHOR_GENERATION = anchorGenerationFor(HASH_LEN);
128
142
 
129
143
  /**
130
144
  * WHY: an explicit code-point list, never a Unicode property escape — \p{White_Space} and \p{Cf}
package/src/prompts.ts CHANGED
@@ -1,23 +1,29 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { HASH_LEN } from "./hashline/alphabet.js";
2
3
 
3
- export function loadP(relativePath: string, replacements?: Record<string, string>): string {
4
- let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
4
+ /**
5
+ * Resolve template variables in shipped prompt prose. Beyond caller-supplied replacements, the
6
+ * anchor-width token is ALWAYS resolved from the live `HASH_LEN`: the width is a build-time setting
7
+ * (ADR-0020), so any prompt that quotes it must not hardcode a digit.
8
+ */
9
+ function applyReplacements(content: string, replacements?: Record<string, string>): string {
10
+ let out = content;
5
11
  if (replacements) {
6
12
  for (const [key, value] of Object.entries(replacements)) {
7
- content = content.split(`{{${key}}}`).join(value);
13
+ out = out.split(`{{${key}}}`).join(value);
8
14
  }
9
15
  }
10
- return content;
16
+ return out.split("{{HASH_LEN}}").join(String(HASH_LEN));
17
+ }
18
+
19
+ export function loadP(relativePath: string, replacements?: Record<string, string>): string {
20
+ const content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
21
+ return applyReplacements(content, replacements);
11
22
  }
12
23
 
13
24
  export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
14
- let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
15
- if (replacements) {
16
- for (const [key, value] of Object.entries(replacements)) {
17
- content = content.split(`{{${key}}}`).join(value);
18
- }
19
- }
20
- return content
25
+ const content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
26
+ return applyReplacements(content, replacements)
21
27
  .split("\n")
22
28
  .map((line) => line.trim())
23
29
  .filter((line) => line.startsWith("- "))