@prometheus-ai/hashline 0.5.4 → 0.5.8

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/src/prefixes.ts CHANGED
@@ -31,6 +31,16 @@ function stripLeadingHashlinePrefixes(line: string): string {
31
31
  } while (result !== previous);
32
32
  return result;
33
33
  }
34
+ /**
35
+ * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
36
+ * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
37
+ * loop. Use this when the input carries at most one snapshot prefix (e.g. a
38
+ * bare body row paste from `read` output) — recursive stripping would corrupt
39
+ * content whose own text starts with `digits:`.
40
+ */
41
+ export function stripOneLeadingHashlinePrefix(line: string): string {
42
+ return line.replace(HL_PREFIX_RE, "");
43
+ }
34
44
 
35
45
  interface LinePrefixStats {
36
46
  nonEmpty: number;
package/src/prompt.md CHANGED
@@ -5,14 +5,15 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- replace N..M: replace original lines N..M with the body rows below.
9
- replace block N: replace the whole syntactic block that BEGINS on line N its header line through its closing line — resolved with tree-sitter. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line.
10
- delete N..M delete original lines N..M. No body.
11
- delete block N delete the whole syntactic block that BEGINS on line N.
12
- insert before N: insert the body rows immediately before line N.
13
- insert after N: insert the body rows immediately after line N.
14
- insert head: insert the body rows at the very start of the file.
15
- insert tail: insert the body rows at the very end of the file.
8
+ `replace N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too.
9
+ `replace block N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
+ `delete N..M` — delete original lines N..M. No body.
11
+ `delete block N` — delete the whole syntactic block that BEGINS on line N.
12
+ `insert before N:` — insert the body rows immediately before line N.
13
+ `insert after N:` — insert the body rows immediately after line N.
14
+ `insert after block N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `insert after`.
15
+ `insert head:` — insert the body rows at the very start of the file.
16
+ `insert tail:` — insert the body rows at the very end of the file.
16
17
  Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
17
18
  </ops>
18
19
 
@@ -23,15 +24,22 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
23
24
  </body-rows>
24
25
 
25
26
  <rules>
26
- - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
27
- - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
28
- - Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
29
- - A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
30
- - On a stale-tag rejection or any result you cannot fully account for STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
27
+ - Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
28
+ - Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
29
+ - They die with the call: every applied edit mints a fresh `#TAG` and renumbers anchor the next edit on the edit response or a fresh `read`.
30
+ - Touch only lines you literally saw as `LINE:TEXT`; the tag certifies the snapshot, not your knowledge of it.
31
+ - Elided regions (`…`) are UNSEENnever place or span a hunk across one; `read` it first.
32
+ - Never start or end a range mid-expression or mid-block.
33
+ - Indent body rows exactly for the depth they should live at.
34
+ - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
31
35
  - One hunk per range; the body is the final content, never an old/new pair.
32
- - Keep every range as tight as the change: a range must cover ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside change one line with `replace N..N`, not the whole block around it. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale single-line replace corrupts one line, while a stale block replace shreds the whole block and its structure.
33
- - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
34
- - NEVER use this tool to format code reordering imports, re-indenting, aligning columns, or any mechanical restyling. That is the project formatter's job; run it instead of hand-editing layout here.
36
+ - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
37
+ - Whole construct `replace block N` (tree-sitter resolves the end); lines inside it `replace N..M`.
38
+ - `replace block N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept use `replace N..M`.
39
+ - `insert after block N`: N is the opener, never the closer or last visible line; saw the closer? Use plain `insert after M:`.
40
+ - Non-adjacent changes = separate hunks; untouched lines stay out of every range.
41
+ - Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped.
42
+ - NEVER format/restyle code with this tool; run the project formatter instead.
35
43
  </rules>
36
44
 
37
45
  <example>
@@ -81,6 +89,15 @@ replace block 1:
81
89
  +def greet(name):
82
90
  + print(f"Hello, {name}")
83
91
  ```
92
+
93
+ A decorator or doc-comment is a SEPARATE block — `replace block` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
94
+ ```
95
+ [svc.py#C3D4]
96
+ replace block 1:
97
+ +@cache
98
+ +def load(key):
99
+ + return store[key]
100
+ ```
84
101
  </example>
85
102
 
86
103
  <anti-patterns>
@@ -99,11 +116,28 @@ replace 3..3:
99
116
  # RIGHT
100
117
  replace 3..3:
101
118
  + return msg
119
+
120
+ # WRONG — a pure insertion done as a widened `replace`: you only want to add one line after 2,
121
+ # but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
122
+ replace 2..4:
123
+ + msg = "Hello, " + name
124
+ + extra = compute(name)
125
+ + print(msg)
126
+ # RIGHT — touch nothing you keep; the new line is the whole body.
127
+ insert after 2:
128
+ + extra = compute(name)
129
+
130
+ # WRONG — `insert after block N:` anchored on a closing delimiter / last visible line. RIGHT: plain `insert after M:`
131
+ insert after block 3:
132
+ +after()
133
+ # RIGHT
134
+ insert after 3:
135
+ +after()
102
136
  </anti-patterns>
103
137
 
104
138
  <critical>
105
139
  If you remember nothing else:
106
- 1. RE-GROUND AFTER EVERY EDIT. Each applied edit mints a fresh `#TAG` and renumbers the file the tag and line numbers you just used are now dead. Take the next edit's numbers from the edit response or a fresh `read`, never from pre-edit memory. On a stale-tag rejection or any unexpected result, STOP and re-`read`.
107
- 2. RANGES ARE TIGHT AND IN-BOUNDS. Cover only lines whose content actually changes; never widen a range to swallow an unchanged signature, brace, or statement, and never start or end a range mid-expression or mid-block. A stale single-line replace corrupts one line; a stale block replace shreds the whole block.
108
- 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows under a `:` header — never `-old`/bare context lines, never an old/new pair. The range does the deleting.
140
+ 1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers — take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
141
+ 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct `replace block N`.
142
+ 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting.
109
143
  </critical>
package/src/snapshots.ts CHANGED
@@ -62,12 +62,20 @@ export abstract class SnapshotStore {
62
62
 
63
63
  const DEFAULT_MAX_PATHS = 30;
64
64
  const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
65
+ /** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
66
+ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
65
67
 
66
68
  export interface InMemorySnapshotStoreOptions {
67
69
  /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
68
70
  maxPaths?: number;
69
71
  /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
70
72
  maxVersionsPerPath?: number;
73
+ /**
74
+ * Global ceiling on retained snapshot text summed across every path's
75
+ * version history, measured in UTF-16 code units (default 64 MiB).
76
+ * Least-recently-used path histories are evicted to stay under it.
77
+ */
78
+ maxTotalBytes?: number;
71
79
  }
72
80
 
73
81
  /**
@@ -85,7 +93,15 @@ export class InMemorySnapshotStore extends SnapshotStore {
85
93
 
86
94
  constructor(options: InMemorySnapshotStoreOptions = {}) {
87
95
  super();
88
- this.#versions = new LRUCache<string, Snapshot[]>({ max: options.maxPaths ?? DEFAULT_MAX_PATHS });
96
+ this.#versions = new LRUCache<string, Snapshot[]>({
97
+ max: options.maxPaths ?? DEFAULT_MAX_PATHS,
98
+ maxSize: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
99
+ sizeCalculation: history => {
100
+ let total = 1;
101
+ for (const version of history) total += version.text.length;
102
+ return total;
103
+ },
104
+ });
89
105
  this.#maxVersionsPerPath = options.maxVersionsPerPath ?? DEFAULT_MAX_VERSIONS_PER_PATH;
90
106
  }
91
107
 
package/src/tokenizer.ts CHANGED
@@ -204,6 +204,7 @@ export type BlockTarget =
204
204
  | { kind: "delete_block"; anchor: Anchor }
205
205
  | { kind: "insert_before"; anchor: Anchor }
206
206
  | { kind: "insert_after"; anchor: Anchor }
207
+ | { kind: "insert_after_block"; anchor: Anchor }
207
208
  | { kind: "bof" }
208
209
  | { kind: "eof" };
209
210
 
@@ -238,6 +239,16 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
238
239
  }
239
240
  const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
240
241
  if (afterEnd !== null) {
242
+ // `insert after block N:` — resolve N to a tree-sitter block range at
243
+ // apply time and insert after its last line. Try the `block` sub-keyword
244
+ // before falling back to a literal `insert after N:` anchor.
245
+ const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
246
+ if (blockEnd !== null) {
247
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
248
+ if (anchor === null) return null;
249
+ const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
250
+ return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
251
+ }
241
252
  const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
242
253
  if (anchor === null) return null;
243
254
  const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
package/src/types.ts CHANGED
@@ -31,22 +31,32 @@ export type Edit =
31
31
  lineNum: number;
32
32
  index: number;
33
33
  mode?: "replacement";
34
+ /**
35
+ * Present on inserts lowered from `insert after block N:`: the
36
+ * resolved block's first line. Lets the applier slide a body that
37
+ * claims a depth inside the block back across the block's trailing
38
+ * closer lines (never above this line).
39
+ */
40
+ blockStart?: number;
34
41
  }
35
42
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
36
43
  | {
37
44
  /**
38
- * Deferred block edit (`replace block N:` / `delete block N`). The exact
39
- * line span is unknown at parse time — it is computed by
40
- * {@link resolveBlockEdits} once file text + path (→ language) are
41
- * available, then expanded into concrete edits: a non-empty `payloads`
42
- * (from `replace block`) becomes the same `replacement` inserts + deletes
43
- * that `replace start..end:` produces; an empty `payloads` (from `delete
44
- * block`) becomes a pure range deletion. `applyEdits` never sees this
45
+ * Deferred block edit (`replace block N:` / `delete block N` /
46
+ * `insert after block N:`). The exact line span is unknown at parse
47
+ * time — it is computed by {@link resolveBlockEdits} once file text +
48
+ * path (→ language) are available, then expanded into concrete edits:
49
+ * a non-empty `payloads` without `mode` (from `replace block`) becomes
50
+ * the same `replacement` inserts + deletes that `replace start..end:`
51
+ * produces; an empty `payloads` (from `delete block`) becomes a pure
52
+ * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
53
+ * inserts at the block's last line. `applyEdits` never sees this
45
54
  * variant.
46
55
  */
47
56
  kind: "block";
48
57
  anchor: Anchor;
49
58
  payloads: string[];
59
+ mode?: "insert_after";
50
60
  lineNum: number;
51
61
  index: number;
52
62
  };
@@ -59,6 +69,13 @@ export interface ApplyResult {
59
69
  firstChangedLine?: number;
60
70
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
61
71
  warnings?: string[];
72
+ /**
73
+ * Resolved spans for each `replace block`/`delete block` op in this apply,
74
+ * in patch order. Present only when the apply matched the tagged content
75
+ * (the common no-drift path), so the line numbers line up with what the
76
+ * caller read. Absent when there were no block ops.
77
+ */
78
+ blockResolutions?: BlockResolution[];
62
79
  }
63
80
 
64
81
  /** A parsed `[A..B]` line range. */
@@ -96,9 +113,11 @@ export interface CompactDiffPreview {
96
113
  removedLines: number;
97
114
  }
98
115
 
99
- /** Optional knobs for {@link buildCompactDiffPreview}. Reserved for future use. */
116
+ /** Optional knobs for {@link buildCompactDiffPreview}. */
100
117
  export interface CompactDiffOptions {
101
- /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
118
+ /** Added lines kept on each side of a long added-run elision (default 2). */
119
+ maxAddedRunContext?: number;
120
+ /** Back-compat alias for {@link maxAddedRunContext}. */
102
121
  maxUnchangedRun?: number;
103
122
  }
104
123
 
@@ -112,6 +131,24 @@ export interface BlockSpan {
112
131
  end: number;
113
132
  }
114
133
 
134
+ /**
135
+ * One `replace block N:` / `delete block N` / `insert after block N:` anchor
136
+ * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
137
+ * host can echo "block N → lines start..end" and let the model catch a wrong
138
+ * opener — e.g. a decorator or doc-comment that sits in a separate node
139
+ * outside the resolved block.
140
+ */
141
+ export interface BlockResolution {
142
+ /** The 1-indexed line the block op was anchored on (the `N`). */
143
+ anchorLine: number;
144
+ /** First line of the resolved span (1-indexed, inclusive). */
145
+ start: number;
146
+ /** Last line of the resolved span (1-indexed, inclusive). */
147
+ end: number;
148
+ /** Which block op produced this resolution. */
149
+ op: "replace" | "delete" | "insert_after";
150
+ }
151
+
115
152
  /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
116
153
  export interface BlockResolverRequest {
117
154
  /** Target file path (used to infer language by extension). */