@cueloop/schema 0.1.0-alpha.83 → 0.1.0-alpha.85

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.83",
3
+ "version": "0.1.0-alpha.85",
4
4
  "description": "The Thread primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
package/src/feedback.ts CHANGED
@@ -250,6 +250,7 @@ export function feedbackForSession(
250
250
  outcome: MessageOutcome,
251
251
  summary: string,
252
252
  actionBodies?: Record<string, string>,
253
+ annotations: Annotation[] = session.annotations,
253
254
  ): string {
254
255
  return renderFeedback({
255
256
  outcome,
@@ -257,7 +258,7 @@ export function feedbackForSession(
257
258
  artifactContent: session.artifact.content,
258
259
  workingCopy: session.workingCopy,
259
260
  artifactType: session.artifact.type,
260
- annotations: session.annotations,
261
+ annotations,
261
262
  artifactPath: session.artifact.meta.prototypePath ?? session.artifact.meta.planPath,
262
263
  sessionId: session.id,
263
264
  actionBodies,
package/src/history.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * feedback document read what this module derives.
11
11
  */
12
12
 
13
- import type { Annotation, Thread, Message } from "./types";
13
+ import type { Annotation, Thread, Message, TextCut } from "./types";
14
14
 
15
15
  export type EntryAuthor = "agent" | "reviewer";
16
16
 
@@ -22,7 +22,13 @@ interface EntryBase {
22
22
  }
23
23
 
24
24
  export type SessionEntry =
25
- | (EntryBase & { type: "revision"; by: EntryAuthor; content: string })
25
+ | (EntryBase & {
26
+ type: "revision";
27
+ by: EntryAuthor;
28
+ content: string;
29
+ /** Exact character Cuts that produced a reviewer revision. */
30
+ textCuts?: TextCut[];
31
+ })
26
32
  | (EntryBase & { type: "comment"; annotationId: string })
27
33
  | (EntryBase & { type: "comment-removed"; annotationId: string })
28
34
  | (EntryBase & { type: "message"; message: Message })
package/src/markdown.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Markdown block model: parse source into addressable blocks that record
3
- * their source line ranges. Rendered-to-source mapping is block+line
4
- * granular; the quote carries sub-block precision. Round-trip safe for the
2
+ * Markdown block model: parse source into addressable blocks with source line
3
+ * ranges and project their text offsets back to source on demand. Round-trip safe for the
5
4
  * subset cueloop plans use; unknown constructs fall back to paragraph blocks
6
5
  * so no content is ever lost.
7
6
  */
@@ -30,6 +29,12 @@ export interface Block {
30
29
  lineEnd: number;
31
30
  }
32
31
 
32
+ export interface BlockTextSourceSegment {
33
+ textStart: number;
34
+ textEnd: number;
35
+ sourceStart: number;
36
+ }
37
+
33
38
  /**
34
39
  * The leading block markers the parser strips from a line: headings (h1-h3),
35
40
  * bullet list, ordered list, and blockquote. Single source of truth so the
@@ -210,6 +215,101 @@ function nextBlock(lines: string[], lineIndex: number): BlockScan {
210
215
  return paragraphBlock(lines, lineIndex);
211
216
  }
212
217
 
218
+ /** Project marker-free block text boundaries onto the source consumed by the parser. */
219
+ function blockSourceSegments(
220
+ sourceLines: string[],
221
+ block: Block,
222
+ sourceStart = sourceLines
223
+ .slice(0, block.lineStart)
224
+ .reduce((offset, line) => offset + line.length + 1, 0),
225
+ ): BlockTextSourceSegment[] | undefined {
226
+ const rawLines = sourceLines.slice(block.lineStart, block.lineEnd + 1);
227
+ let contentLines: Array<{ rawLine: number; prefix: number }>;
228
+
229
+ if (block.kind === "code") {
230
+ const hasClosingFence = rawLines.at(-1)?.startsWith("```") ?? false;
231
+ const end = hasClosingFence ? rawLines.length - 1 : rawLines.length;
232
+
233
+ contentLines = rawLines.slice(1, end).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
234
+ } else if (block.kind === "frontmatter") {
235
+ contentLines = rawLines.slice(1, -1).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
236
+ } else if (block.kind === "quote") {
237
+ contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 2 }));
238
+ } else if (
239
+ block.kind === "h1" ||
240
+ block.kind === "h2" ||
241
+ block.kind === "h3" ||
242
+ block.kind === "li" ||
243
+ block.kind === "oli"
244
+ ) {
245
+ contentLines = [{ rawLine: 0, prefix: rawLines[0]!.length - block.text.length }];
246
+ } else {
247
+ contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 0 }));
248
+ }
249
+ const content = contentLines
250
+ .map(({ rawLine, prefix }) => rawLines[rawLine]!.slice(prefix))
251
+ .join("\n");
252
+
253
+ if (content !== block.text) return undefined;
254
+ const rawLineStarts: number[] = [];
255
+ let rawOffset = 0;
256
+
257
+ for (const line of rawLines) {
258
+ rawLineStarts.push(rawOffset);
259
+ rawOffset += line.length + 1;
260
+ }
261
+ let textOffset = 0;
262
+ const segments = contentLines.map(({ rawLine, prefix }) => {
263
+ const line = rawLines[rawLine]!.slice(prefix);
264
+ const lineStart = sourceStart + rawLineStarts[rawLine]! + prefix;
265
+ const segment = {
266
+ textStart: textOffset,
267
+ textEnd: textOffset + line.length,
268
+ sourceStart: lineStart,
269
+ };
270
+
271
+ textOffset += line.length + 1;
272
+
273
+ return segment;
274
+ });
275
+
276
+ return segments;
277
+ }
278
+
279
+ /** Project several parsed blocks onto source offsets without splitting the Markdown per block. */
280
+ export function blockTextSourceSegments(
281
+ markdown: string,
282
+ blocks: readonly Block[],
283
+ ): Array<readonly BlockTextSourceSegment[] | undefined> {
284
+ const sourceLines = markdown.split("\n");
285
+ const lineStarts: number[] = [];
286
+ let sourceOffset = 0;
287
+
288
+ for (const line of sourceLines) {
289
+ lineStarts.push(sourceOffset);
290
+ sourceOffset += line.length + 1;
291
+ }
292
+
293
+ return blocks.map((block) =>
294
+ blockSourceSegments(sourceLines, block, lineStarts[block.lineStart] ?? sourceOffset),
295
+ );
296
+ }
297
+
298
+ /** Resolve a marker-free block-text boundary through the parser's source projection. */
299
+ export function sourceOffsetAt(
300
+ markdown: string,
301
+ block: Block,
302
+ textOffset: number,
303
+ ): number | undefined {
304
+ for (const segment of blockSourceSegments(markdown.split("\n"), block) ?? []) {
305
+ if (textOffset < segment.textStart || textOffset > segment.textEnd) continue;
306
+
307
+ return segment.sourceStart + textOffset - segment.textStart;
308
+ }
309
+
310
+ return undefined;
311
+ }
312
+
213
313
  export function parseBlocks(markdown: string): Block[] {
214
314
  const lines = markdown.split("\n");
215
315
  const blocks: Block[] = [];
@@ -37,6 +37,7 @@ function threaded(): SessionHistory {
37
37
  type: "revision",
38
38
  by: "reviewer",
39
39
  content: "Plan v1 edited",
40
+ textCuts: [{ start: 5, end: 8, quote: "v1 " }],
40
41
  createdAt: AT,
41
42
  }).history;
42
43
  history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
@@ -52,6 +53,7 @@ describe("viewOfPath", () => {
52
53
  // Assert
53
54
  expect(view.content).toBe("Plan v1");
54
55
  expect(view.workingCopy).toBe("Plan v1 edited");
56
+ expect(view.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
55
57
  expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
56
58
  expect(view.shelvedAnnotations).toEqual([]);
57
59
  });
@@ -73,9 +75,11 @@ describe("viewOfPath", () => {
73
75
 
74
76
  // Assert
75
77
  expect(back.workingCopy).toBeUndefined();
78
+ expect(back.textCuts).toBeUndefined();
76
79
  expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
77
80
  expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
78
81
  expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
82
+ expect(forward.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
79
83
  expect(forward.shelvedAnnotations).toEqual([]);
80
84
  });
81
85
 
@@ -119,6 +123,7 @@ describe("applyPathView", () => {
119
123
  applyPathView(session, {
120
124
  content: "Plan v1",
121
125
  workingCopy: undefined,
126
+ textCuts: undefined,
122
127
  annotations: [annotation("a1"), annotation("a2")],
123
128
  shelvedAnnotations: [],
124
129
  });
package/src/path-view.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  */
8
8
 
9
9
  import { derivePath, followBranch, MAIN_BRANCH, pathOf, type SessionHistory } from "./history";
10
- import type { Annotation, Thread } from "./types";
10
+ import type { Annotation, Thread, TextCut } from "./types";
11
11
 
12
12
  export interface PathView {
13
13
  /** The last agent revision on the path: what the artifact shows. */
14
14
  content: string;
15
15
  /** The reviewer's edits over it, when the path's head is a reviewer revision. */
16
16
  workingCopy: string | undefined;
17
+ /** Exact character Cuts attached to the reviewer revision at the path head. */
18
+ textCuts: TextCut[] | undefined;
17
19
  /** The comments open on the path, in path order. */
18
20
  annotations: Annotation[];
19
21
  /** Every other comment the session knows. */
@@ -45,6 +47,7 @@ export function viewOfPath(history: SessionHistory, known: Annotation[]): PathVi
45
47
  return {
46
48
  content,
47
49
  workingCopy: derived.head.content === content ? undefined : derived.head.content,
50
+ textCuts: derived.head.by === "reviewer" ? derived.head.textCuts : undefined,
48
51
  annotations,
49
52
  shelvedAnnotations: [...byId.values()],
50
53
  };
@@ -54,6 +57,8 @@ export function viewOfPath(history: SessionHistory, known: Annotation[]): PathVi
54
57
  export function applyPathView(session: Thread, view: PathView): void {
55
58
  session.artifact = { ...session.artifact, content: view.content };
56
59
  session.annotations = view.annotations;
60
+ if (view.textCuts?.length) session.textCuts = view.textCuts;
61
+ else delete session.textCuts;
57
62
  if (view.workingCopy === undefined) delete session.workingCopy;
58
63
  else session.workingCopy = view.workingCopy;
59
64
  if (view.shelvedAnnotations.length === 0) delete session.shelvedAnnotations;
package/src/types.test.ts CHANGED
@@ -45,7 +45,7 @@ describe("WORKFLOW_KINDS", () => {
45
45
  describe("messageAllows", () => {
46
46
  test("only approve maps to allow", () => {
47
47
  expect(messageAllows("approved")).toBe(true);
48
- expect(messageAllows("changes_requested")).toBe(false);
48
+ expect(messageAllows("comment")).toBe(false);
49
49
  expect(messageAllows("changes_requested")).toBe(false);
50
50
  });
51
51
  });
package/src/types.ts CHANGED
@@ -232,7 +232,8 @@ export interface PendingDelivery {
232
232
  message: Message;
233
233
  }
234
234
 
235
- export type MessageOutcome = "approved" | "changes_requested";
235
+ export const MESSAGE_OUTCOMES = ["comment", "approved", "changes_requested"] as const;
236
+ export type MessageOutcome = (typeof MESSAGE_OUTCOMES)[number];
236
237
 
237
238
  export interface Message {
238
239
  /** Stable identity used to deduplicate at-least-once harness delivery. */
@@ -241,6 +242,8 @@ export interface Message {
241
242
  summary: string;
242
243
  /** The structured review document sent to the harness. */
243
244
  body: string;
245
+ /** Annotation snapshots included in this delivery, for incremental sends. */
246
+ annotations?: Annotation[];
244
247
  sentAt: string;
245
248
  }
246
249
 
@@ -294,6 +297,13 @@ export interface ShareLink {
294
297
  shareBranch?: string;
295
298
  }
296
299
 
300
+ /** One exact character range removed from the submitted artifact source. */
301
+ export interface TextCut {
302
+ start: number;
303
+ end: number;
304
+ quote: string;
305
+ }
306
+
297
307
  export interface Thread {
298
308
  schemaVersion: string;
299
309
  id: string;
@@ -317,6 +327,8 @@ export interface Thread {
317
327
  * Undefined = no direct edits.
318
328
  */
319
329
  workingCopy?: string;
330
+ /** Exact source ranges behind character Cuts in the current working copy. */
331
+ textCuts?: TextCut[];
320
332
  /**
321
333
  * File paths the reviewer marked viewed during the guided walk (diff
322
334
  * sessions). Persisting with the session means a resumed review keeps its
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
2
+ import {
3
+ applyTextCuts,
4
+ blockCutSourceRange,
5
+ cutBlock,
6
+ cutTextRange,
7
+ mergeTextCut,
8
+ restoreTextCut,
9
+ restoreBlock,
10
+ restoreLine,
11
+ sourceChunk,
12
+ } from "./working-copy";
3
13
  import { parseBlocks, type Block } from "./markdown";
4
14
 
5
15
  const BASE = `# Plan
@@ -64,6 +74,134 @@ describe("cutBlock", () => {
64
74
  expect(cut).not.toContain("const x");
65
75
  expect(cut).toContain("- second item");
66
76
  });
77
+
78
+ test.each(["Plan", "spans two lines", "first item", "const x"])(
79
+ "reports the exact source interval cut from %s",
80
+ (text) => {
81
+ const target = block(BASE, text);
82
+ const range = blockCutSourceRange(BASE, target);
83
+
84
+ expect(BASE.slice(0, range.start) + BASE.slice(range.end)).toBe(cutBlock(BASE, target));
85
+ },
86
+ );
87
+ });
88
+
89
+ describe("cutTextRange", () => {
90
+ test("cuts only the selected characters inside a block", () => {
91
+ const paragraph = block(BASE, "spans two lines");
92
+ const start = paragraph.text.indexOf("paragraph");
93
+ const cut = cutTextRange(BASE, paragraph, start, paragraph, start + "paragraph".length);
94
+
95
+ expect(cut).toContain("A that\nspans two lines.");
96
+ expect(cut).toContain("## Context");
97
+ expect(cut).toContain("- first item");
98
+ });
99
+
100
+ test("preserves a list marker around a partial cut", () => {
101
+ const item = block(BASE, "first item");
102
+ const cut = cutTextRange(BASE, item, 0, item, "first ".length);
103
+
104
+ expect(cut).toContain("- item");
105
+ expect(cut).not.toContain("- first item");
106
+ });
107
+
108
+ test.each([
109
+ ["heading", "# remove keep", "# keep"],
110
+ ["ordered list", "12. remove keep", "12. keep"],
111
+ ["fenced code", "```ts\nremove keep\n```", "```ts\n keep\n```"],
112
+ ["frontmatter", "---\nname: remove keep\n---", "---\nname: keep\n---"],
113
+ ["table", "| remove | keep |\n| --- | --- |", "| | keep |\n| --- | --- |"],
114
+ ])("preserves %s source markers", (_label, source, expected) => {
115
+ const target = block(source, "remove");
116
+ const start = target.text.indexOf("remove");
117
+
118
+ expect(cutTextRange(source, target, start, target, start + "remove".length)).toBe(expected);
119
+ });
120
+
121
+ test("cuts an exact range across marked quote lines", () => {
122
+ const source = "> keep alpha\n> remove beta\n> keep gamma";
123
+ const quote = parseBlocks(source)[0]!;
124
+ const start = quote.text.indexOf("alpha");
125
+ const end = quote.text.indexOf("keep gamma");
126
+
127
+ const cut = cutTextRange(source, quote, start, quote, end);
128
+
129
+ expect(cut).toBe("> keep keep gamma");
130
+ });
131
+
132
+ test("cuts an exact range across separate blocks", () => {
133
+ const source = "Keep before remove this.\n\n- remove that keep after";
134
+ const blocks = parseBlocks(source);
135
+ const first = blocks[0]!;
136
+ const second = blocks[1]!;
137
+ const start = first.text.indexOf("remove");
138
+ const end = second.text.indexOf("keep after");
139
+
140
+ const cut = cutTextRange(source, first, start, second, end);
141
+
142
+ expect(cut).toBe("Keep before keep after");
143
+ });
144
+ });
145
+
146
+ describe("exact text Cuts", () => {
147
+ test("stores and applies the selected source characters exactly", () => {
148
+ const source = "It lands under unin Threads then in the sidebar.";
149
+ const start = source.indexOf("under");
150
+ const end = source.indexOf(" in the sidebar");
151
+ const cuts = mergeTextCut(source, [], start, end);
152
+
153
+ expect(cuts).toEqual([{ start, end, quote: "under unin Threads then" }]);
154
+ expect(applyTextCuts(source, cuts)).toBe("It lands in the sidebar.");
155
+ });
156
+
157
+ test("merges overlapping and adjacent ranges in one linear source interval", () => {
158
+ const source = "alpha beta gamma delta";
159
+ const first = mergeTextCut(source, [], 6, 10);
160
+ const merged = mergeTextCut(source, first, 10, 16);
161
+
162
+ expect(merged).toEqual([{ start: 6, end: 16, quote: "beta gamma" }]);
163
+ expect(applyTextCuts(source, merged)).toBe("alpha delta");
164
+ });
165
+
166
+ test("returns the original references for no-op ranges", () => {
167
+ const source = "unchanged";
168
+ const cuts = [{ start: 0, end: 2, quote: "un" }];
169
+
170
+ expect(mergeTextCut(source, cuts, 4, 4)).toBe(cuts);
171
+ expect(applyTextCuts(source, [])).toBe(source);
172
+ });
173
+
174
+ test("applies out-of-order Cuts and ignores stale or overlapping records", () => {
175
+ const source = "alpha beta gamma";
176
+
177
+ expect(
178
+ applyTextCuts(source, [
179
+ { start: 11, end: 16, quote: "gamma" },
180
+ { start: 0, end: 5, quote: "alpha" },
181
+ { start: 2, end: 8, quote: "stale" },
182
+ { start: 6, end: 10, quote: "beta" },
183
+ ]),
184
+ ).toBe(" ");
185
+ });
186
+
187
+ test("restores an exact subrange without disturbing the rest of its Cut", () => {
188
+ const source = "alpha beta gamma delta";
189
+ const cuts = mergeTextCut(source, [], 6, 16);
190
+ const restored = restoreTextCut(source, cuts, 11, 16);
191
+
192
+ expect(restored).toEqual([{ start: 6, end: 11, quote: "beta " }]);
193
+ expect(applyTextCuts(source, restored ?? [])).toBe("alpha gamma delta");
194
+ });
195
+
196
+ test("does not restore across separate Cuts", () => {
197
+ const source = "alpha beta gamma delta";
198
+ const cuts = [
199
+ { start: 6, end: 10, quote: "beta" },
200
+ { start: 11, end: 16, quote: "gamma" },
201
+ ];
202
+
203
+ expect(restoreTextCut(source, cuts, 6, 16)).toBeNull();
204
+ });
67
205
  });
68
206
 
69
207
  describe("restoreBlock", () => {
@@ -1,11 +1,90 @@
1
- /**
2
- * Working-copy block surgery: the only module that slices raw source text by
3
- * Block line ranges. Cut removes a block's lines; restore re-inserts a base
4
- * block's chunk and reports when the working copy round-trips back to the
5
- * submitted revision (pristine). Callers never touch lineStart/lineEnd.
6
- */
1
+ /** Working-copy source surgery for block and character cuts plus block restoration. */
2
+
3
+ import { parseBlocks, sourceOffsetAt, type Block } from "./markdown";
4
+ import type { TextCut } from "./types";
5
+
6
+ /** Add one source range and normalize overlaps without copying on a no-op. */
7
+ export function mergeTextCut(
8
+ source: string,
9
+ cuts: readonly TextCut[],
10
+ start: number,
11
+ end: number,
12
+ ): TextCut[] {
13
+ const boundedStart = Math.max(0, Math.min(start, source.length));
14
+ const boundedEnd = Math.max(boundedStart, Math.min(end, source.length));
15
+
16
+ if (boundedEnd === boundedStart) {
17
+ // SAFETY: a no-op returns the caller's array unchanged; callers never mutate the result.
18
+ return cuts as TextCut[];
19
+ }
20
+ const ranges = [
21
+ ...cuts.map((cut) => ({ start: cut.start, end: cut.end })),
22
+ { start: boundedStart, end: boundedEnd },
23
+ ]
24
+ .map((range) => ({
25
+ start: Math.max(0, Math.min(range.start, source.length)),
26
+ end: Math.max(0, Math.min(range.end, source.length)),
27
+ }))
28
+ .filter((range) => range.end > range.start)
29
+ .toSorted((left, right) => left.start - right.start || left.end - right.end);
30
+ const merged: Array<{ start: number; end: number }> = [];
31
+
32
+ for (const range of ranges) {
33
+ const previous = merged.at(-1);
34
+
35
+ if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end);
36
+ else merged.push(range);
37
+ }
38
+
39
+ return merged.map((range) => ({
40
+ ...range,
41
+ quote: source.slice(range.start, range.end),
42
+ }));
43
+ }
44
+
45
+ /** Restore a selected part of one contiguous character Cut. */
46
+ export function restoreTextCut(
47
+ source: string,
48
+ cuts: readonly TextCut[],
49
+ start: number,
50
+ end: number,
51
+ ): TextCut[] | null {
52
+ const cutIndex = cuts.findIndex((cut) => cut.start <= start && end <= cut.end);
53
+
54
+ if (cutIndex === -1 || end <= start) return null;
55
+ const cut = cuts[cutIndex]!;
56
+ const replacements: TextCut[] = [];
57
+
58
+ if (cut.start < start) {
59
+ replacements.push({ start: cut.start, end: start, quote: source.slice(cut.start, start) });
60
+ }
61
+ if (end < cut.end) {
62
+ replacements.push({ start: end, end: cut.end, quote: source.slice(end, cut.end) });
63
+ }
64
+
65
+ return [...cuts.slice(0, cutIndex), ...replacements, ...cuts.slice(cutIndex + 1)];
66
+ }
67
+
68
+ /** Apply valid source ranges in source order, ignoring stale or overlapping records. */
69
+ export function applyTextCuts(source: string, cuts: readonly TextCut[]): string {
70
+ if (cuts.length === 0) return source;
71
+ let cursor = 0;
72
+ const pieces: string[] = [];
7
73
 
8
- import { parseBlocks, type Block } from "./markdown";
74
+ for (const cut of cuts.toSorted(
75
+ (left, right) => left.start - right.start || left.end - right.end,
76
+ )) {
77
+ if (cut.start < cursor || cut.start < 0 || cut.end > source.length || cut.end <= cut.start)
78
+ continue;
79
+ if (source.slice(cut.start, cut.end) !== cut.quote) continue;
80
+
81
+ pieces.push(source.slice(cursor, cut.start));
82
+ cursor = cut.end;
83
+ }
84
+ pieces.push(source.slice(cursor));
85
+
86
+ return pieces.join("");
87
+ }
9
88
 
10
89
  /** Chunk of the base source a block occupies (for restore and display). */
11
90
  export function sourceChunk(base: string, block: Block): string {
@@ -25,6 +104,44 @@ export function cutBlock(working: string, block: Block): string {
25
104
  return [...before, ...after].join("\n");
26
105
  }
27
106
 
107
+ /** Source interval removed by a whole-block Cut, including its leading blank-line padding. */
108
+ export function blockCutSourceRange(source: string, block: Block) {
109
+ const lines = source.split("\n");
110
+ let startLine = block.lineStart;
111
+
112
+ while (startLine > 0 && lines[startLine - 1]!.trim() === "") startLine--;
113
+ const lineStarts = [0];
114
+
115
+ for (let index = 0; index < source.length; index++) {
116
+ if (source.charCodeAt(index) === 10) lineStarts.push(index + 1);
117
+ }
118
+
119
+ return {
120
+ start: lineStarts[startLine] ?? source.length,
121
+ end: lineStarts[block.lineEnd + 1] ?? source.length,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Remove exactly the selected block-text range while retaining surrounding
127
+ * Markdown markers. A cross-block range also removes the source between its
128
+ * endpoint characters.
129
+ */
130
+ export function cutTextRange(
131
+ working: string,
132
+ startBlock: Block,
133
+ start: number,
134
+ endBlock: Block,
135
+ end: number,
136
+ ): string {
137
+ const sourceStart = sourceOffsetAt(working, startBlock, start) ?? -1;
138
+ const sourceEnd = sourceOffsetAt(working, endBlock, end) ?? -1;
139
+
140
+ if (sourceStart < 0 || sourceEnd <= sourceStart) return working;
141
+
142
+ return working.slice(0, sourceStart) + working.slice(sourceEnd);
143
+ }
144
+
28
145
  /** The line a restored block re-enters at: the next surviving block's start. */
29
146
  export function restoreLine(next: Block | undefined, workingLineCount: number): number {
30
147
  return next ? next.lineStart : workingLineCount;