@cueloop/schema 0.1.0-alpha.63 → 0.1.0-alpha.65

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.63",
3
+ "version": "0.1.0-alpha.65",
4
4
  "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { parseBlocks } from "./markdown";
3
- import { makeAnchor, resolveAnchor } from "./anchor";
3
+ import { BLOCK_SEPARATOR, makeAnchor, resolveAnchor } from "./anchor";
4
4
 
5
5
  const DOC = `# Plan
6
6
 
@@ -140,7 +140,11 @@ describe("anchor cascade", () => {
140
140
  const blocks = blocksOf(DOC);
141
141
  const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
142
142
  // a typo the reviewer never saw: "sesssions"
143
- const anchor = { quote: "Review sesssions currently live", prefix: "", suffix: "" };
143
+ const anchor = {
144
+ quote: "Review sesssions currently live",
145
+ prefix: "",
146
+ suffix: "",
147
+ };
144
148
 
145
149
  // Act
146
150
  const resolved = resolveAnchor(anchor, blocks)!;
@@ -207,3 +211,112 @@ describe("anchor cascade", () => {
207
211
  expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
208
212
  });
209
213
  });
214
+
215
+ describe("anchors spanning blocks", () => {
216
+ const SPANNING_DOC = `## Phase 1
217
+
218
+ All persistence lives in a new module,
219
+ so a crash can never leave a torn file on disk.
220
+
221
+ - server/storage/store.ts - the SessionStore class
222
+ - server/storage/schema.ts - the on-disk record shape
223
+
224
+ ## Phase 2
225
+ `;
226
+ const blocks = parseBlocks(SPANNING_DOC);
227
+ const paragraph = blocks.findIndex((block) => block.text.startsWith("All persistence"));
228
+ const secondBullet = blocks.findIndex((block) => block.text.startsWith("server/storage/schema"));
229
+ const start = blocks[paragraph]!.text.indexOf("so a crash");
230
+ const end = "server/storage/schema.ts".length;
231
+ const anchor = makeAnchor(blocks, paragraph, start, end, secondBullet);
232
+
233
+ test("the quote is the spanned text joined by a blank line, with an end block hint", () => {
234
+ // Assert
235
+ expect(anchor.quote).toBe(
236
+ [
237
+ "so a crash can never leave a torn file on disk.",
238
+ "server/storage/store.ts - the SessionStore class",
239
+ "server/storage/schema.ts",
240
+ ].join(BLOCK_SEPARATOR),
241
+ );
242
+ expect(anchor.blockIndex).toBe(paragraph);
243
+ expect(anchor.endBlockIndex).toBe(secondBullet);
244
+ expect(anchor.suffix).toBe(" - the on-disk record sh");
245
+ });
246
+
247
+ test("a single-block anchor carries no end block hint", () => {
248
+ // Assert
249
+ expect(makeAnchor(blocks, paragraph, 0, 3)).not.toHaveProperty("endBlockIndex");
250
+ });
251
+
252
+ test("resolves exactly back onto the first and last block offsets", () => {
253
+ // Act
254
+ const resolved = resolveAnchor(anchor, blocks);
255
+
256
+ // Assert
257
+ expect(resolved).toEqual({
258
+ blockIndex: paragraph,
259
+ start,
260
+ endBlockIndex: secondBullet,
261
+ end,
262
+ approximate: false,
263
+ strategy: "exact",
264
+ });
265
+ });
266
+
267
+ test("follows the span when blocks are inserted above it", () => {
268
+ // Arrange: a new paragraph shifts every block index by one
269
+ const shifted = parseBlocks(
270
+ SPANNING_DOC.replace("## Phase 1\n", "## Phase 1\n\nA new intro.\n"),
271
+ );
272
+
273
+ // Act
274
+ const resolved = resolveAnchor(anchor, shifted);
275
+
276
+ // Assert
277
+ expect(resolved?.blockIndex).toBe(paragraph + 1);
278
+ expect(resolved?.endBlockIndex).toBe(secondBullet + 1);
279
+ expect(resolved?.start).toBe(start);
280
+ expect(resolved?.end).toBe(end);
281
+ });
282
+
283
+ test("re-binds fuzzily after a light edit inside the span", () => {
284
+ // Arrange
285
+ const edited = parseBlocks(SPANNING_DOC.replace("SessionStore class", "SessionStore type"));
286
+
287
+ // Act
288
+ const resolved = resolveAnchor(anchor, edited);
289
+
290
+ // Assert: still one span from the paragraph to the second bullet
291
+ expect(resolved?.strategy).toBe("fuzzy");
292
+ expect(resolved?.approximate).toBe(true);
293
+ expect(resolved?.blockIndex).toBe(paragraph);
294
+ expect(resolved?.endBlockIndex).toBe(secondBullet);
295
+ });
296
+
297
+ test("orphans when the spanned passage is gone", () => {
298
+ // Arrange
299
+ const gutted = parseBlocks("## Phase 1\n\nSomething else entirely.\n");
300
+
301
+ // Assert
302
+ expect(resolveAnchor(anchor, gutted)).toBeNull();
303
+ });
304
+
305
+ test("a match landing on a separator edge is pulled onto the neighbouring block", () => {
306
+ // Arrange: a quote whose exact text starts with the separator only exists
307
+ // when its first block portion is empty, which makeAnchor never produces;
308
+ // simulate the stale case directly
309
+ const staleAnchor = {
310
+ ...anchor,
311
+ quote: `${BLOCK_SEPARATOR}server/storage/store.ts - the SessionStore class`,
312
+ };
313
+
314
+ // Act
315
+ const resolved = resolveAnchor(staleAnchor, blocks);
316
+
317
+ // Assert: the span opens at the bullet, not on the blank line above it
318
+ expect(resolved?.blockIndex).toBe(secondBullet - 1);
319
+ expect(resolved?.start).toBe(0);
320
+ expect(resolved?.endBlockIndex).toBe(secondBullet - 1);
321
+ });
322
+ });
package/src/anchor.ts CHANGED
@@ -10,6 +10,10 @@
10
10
  * 5. orphan - none matched; keep the quote for display, drop the highlight
11
11
  * Prefix/suffix selectors and the recorded position break ties within a tier.
12
12
  * Never silently bind to the wrong text: fuzzy needs a high similarity floor.
13
+ *
14
+ * A quote may span consecutive blocks: its text is the blocks' text joined by
15
+ * BLOCK_SEPARATOR, and it resolves against the document joined the same way,
16
+ * so one comment can cover the tail of a paragraph and the bullets under it.
13
17
  */
14
18
 
15
19
  import type { Anchor } from "./types";
@@ -22,12 +26,18 @@ const ANCHOR_CONTEXT_CHARS = 24;
22
26
  /** Minimum similarity a fuzzy match needs before the resolver will trust it. */
23
27
  const FUZZY_MINIMUM_SIMILARITY = 0.75;
24
28
 
29
+ /** Joins block texts in a spanning quote and in the document it resolves against. */
30
+ export const BLOCK_SEPARATOR = "\n\n";
31
+
25
32
  /** How the quote bound to the block text, weakest binding named last. */
26
33
  export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
27
34
 
28
35
  export interface ResolvedAnchor {
29
36
  blockIndex: number;
30
37
  start: number;
38
+ /** Last block of the quote; equals blockIndex unless the quote spans blocks. */
39
+ endBlockIndex: number;
40
+ /** Offset within the end block. */
31
41
  end: number;
32
42
  /** True whenever the binding was not an exact quote match. */
33
43
  approximate: boolean;
@@ -40,17 +50,32 @@ export function makeAnchor(
40
50
  blockIndex: number,
41
51
  start: number,
42
52
  end: number,
53
+ endBlockIndex: number = blockIndex,
43
54
  ): Anchor {
44
- const blockText = blocks[blockIndex]?.text ?? "";
45
-
46
- return {
47
- quote: blockText.slice(start, end),
48
- prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
49
- suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
55
+ const startText = blocks[blockIndex]?.text ?? "";
56
+ const endText = blocks[endBlockIndex]?.text ?? "";
57
+ const quote = blocks
58
+ .slice(blockIndex, endBlockIndex + 1)
59
+ .map((block, offset, spanned) => {
60
+ const isFirst = offset === 0;
61
+ const isLast = offset === spanned.length - 1;
62
+
63
+ return block.text.slice(isFirst ? start : 0, isLast ? end : block.text.length);
64
+ })
65
+ .join(BLOCK_SEPARATOR);
66
+
67
+ const anchor: Anchor = {
68
+ quote,
69
+ prefix: startText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
70
+ suffix: endText.slice(end, end + ANCHOR_CONTEXT_CHARS),
50
71
  blockIndex,
51
72
  start,
52
73
  end,
53
74
  };
75
+
76
+ if (endBlockIndex !== blockIndex) anchor.endBlockIndex = endBlockIndex;
77
+
78
+ return anchor;
54
79
  }
55
80
 
56
81
  interface Candidate {
@@ -149,15 +174,183 @@ function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): Resolved
149
174
  return {
150
175
  blockIndex: candidate.blockIndex,
151
176
  start: candidate.start,
177
+ endBlockIndex: candidate.blockIndex,
152
178
  end: candidate.end,
153
179
  approximate: strategy !== "exact",
154
180
  strategy,
155
181
  };
156
182
  }
157
183
 
184
+ /* ------------------------------------------------ quotes spanning blocks */
185
+
186
+ /** The blocks as one document, with where each block's text starts in it. */
187
+ interface JoinedDocument {
188
+ text: string;
189
+ starts: number[];
190
+ }
191
+
192
+ function joinBlocks(blocks: Block[]): JoinedDocument {
193
+ const starts: number[] = [];
194
+ let text = "";
195
+
196
+ blocks.forEach((block, blockIndex) => {
197
+ if (blockIndex > 0) text += BLOCK_SEPARATOR;
198
+ starts.push(text.length);
199
+ text += block.text;
200
+ });
201
+
202
+ return { text, starts };
203
+ }
204
+
205
+ /**
206
+ * The block and in-block offset of a document offset. An offset inside a
207
+ * separator belongs to the block after it when it opens a range and to the
208
+ * block before it when it closes one, so a span never starts or ends on the
209
+ * blank line between blocks.
210
+ */
211
+ interface BlockOffset {
212
+ blockIndex: number;
213
+ offset: number;
214
+ }
215
+
216
+ function locateInBlocks(
217
+ blocks: Block[],
218
+ starts: number[],
219
+ offset: number,
220
+ edge: "start" | "end",
221
+ ): BlockOffset {
222
+ let blockIndex = starts.findLastIndex((start) => start <= offset);
223
+
224
+ if (blockIndex < 0) blockIndex = 0;
225
+ const blockEnd = starts[blockIndex]! + blocks[blockIndex]!.text.length;
226
+
227
+ if (edge === "start" && offset >= blockEnd && blockIndex < blocks.length - 1) {
228
+ return { blockIndex: blockIndex + 1, offset: 0 };
229
+ }
230
+ if (edge === "end" && offset === starts[blockIndex] && blockIndex > 0) {
231
+ return {
232
+ blockIndex: blockIndex - 1,
233
+ offset: blocks[blockIndex - 1]!.text.length,
234
+ };
235
+ }
236
+
237
+ return {
238
+ blockIndex,
239
+ offset: Math.min(offset, blockEnd) - starts[blockIndex]!,
240
+ };
241
+ }
242
+
243
+ /** Context agreement plus position hints for a window of the joined document. */
244
+ function documentScore(
245
+ anchor: Anchor,
246
+ documentText: string,
247
+ start: number,
248
+ end: number,
249
+ startBlockIndex: number,
250
+ ): number {
251
+ const prefix = documentText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
252
+ const suffix = documentText.slice(end, end + ANCHOR_CONTEXT_CHARS);
253
+ let score = 0;
254
+
255
+ if (prefix === anchor.prefix) score += 2;
256
+ if (suffix === anchor.suffix) score += 2;
257
+ if (anchor.blockIndex === startBlockIndex) score += 1;
258
+
259
+ return score;
260
+ }
261
+
262
+ interface DocumentCandidate {
263
+ start: number;
264
+ end: number;
265
+ score: number;
266
+ }
267
+
268
+ function findSpanningExact(
269
+ anchor: Anchor,
270
+ documentText: string,
271
+ quote: string,
272
+ starts: number[],
273
+ blocks: Block[],
274
+ ): DocumentCandidate[] {
275
+ const candidates: DocumentCandidate[] = [];
276
+
277
+ for (
278
+ let matchStart = documentText.indexOf(quote);
279
+ matchStart !== -1;
280
+ matchStart = documentText.indexOf(quote, matchStart + 1)
281
+ ) {
282
+ const matchEnd = matchStart + quote.length;
283
+ const startBlockIndex = locateInBlocks(blocks, starts, matchStart, "start").blockIndex;
284
+
285
+ candidates.push({
286
+ start: matchStart,
287
+ end: matchEnd,
288
+ score: documentScore(anchor, documentText, matchStart, matchEnd, startBlockIndex),
289
+ });
290
+ }
291
+
292
+ return candidates;
293
+ }
294
+
295
+ function resolvedSpan(
296
+ blocks: Block[],
297
+ starts: number[],
298
+ candidate: DocumentCandidate,
299
+ strategy: AnchorMatchStrategy,
300
+ ): ResolvedAnchor {
301
+ const startPosition = locateInBlocks(blocks, starts, candidate.start, "start");
302
+ const endPosition = locateInBlocks(blocks, starts, candidate.end, "end");
303
+
304
+ return {
305
+ blockIndex: startPosition.blockIndex,
306
+ start: startPosition.offset,
307
+ endBlockIndex: endPosition.blockIndex,
308
+ end: endPosition.offset,
309
+ approximate: strategy !== "exact",
310
+ strategy,
311
+ };
312
+ }
313
+
314
+ /** The cascade for a quote that spans blocks, run over the joined document. */
315
+ function resolveSpanningAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
316
+ const document = joinBlocks(blocks);
317
+ const best = (candidates: DocumentCandidate[]): DocumentCandidate | null =>
318
+ candidates.toSorted((left, right) => right.score - left.score)[0] ?? null;
319
+
320
+ const exact = best(
321
+ findSpanningExact(anchor, document.text, anchor.quote, document.starts, blocks),
322
+ );
323
+
324
+ if (exact) return resolvedSpan(blocks, document.starts, exact, "exact");
325
+
326
+ const trimmedQuote = anchor.quote.trim();
327
+
328
+ if (trimmedQuote !== anchor.quote) {
329
+ const trimmed = best(
330
+ findSpanningExact(anchor, document.text, trimmedQuote, document.starts, blocks),
331
+ );
332
+
333
+ if (trimmed) return resolvedSpan(blocks, document.starts, trimmed, "trimmed");
334
+ }
335
+
336
+ const fuzzy = fuzzyFindBestMatch(trimmedQuote, document.text, FUZZY_MINIMUM_SIMILARITY);
337
+
338
+ if (fuzzy) {
339
+ return resolvedSpan(
340
+ blocks,
341
+ document.starts,
342
+ { start: fuzzy.start, end: fuzzy.end, score: 0 },
343
+ "fuzzy",
344
+ );
345
+ }
346
+
347
+ return null;
348
+ }
349
+
158
350
  /** Resolve an anchor against the current blocks, or null when orphaned. */
159
351
  export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
160
352
  if (anchor.quote === "") return null;
353
+ if (anchor.quote.includes(BLOCK_SEPARATOR)) return resolveSpanningAnchor(anchor, blocks);
161
354
 
162
355
  const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
163
356
 
@@ -153,6 +153,73 @@ describe("renderFeedback", () => {
153
153
  expect(feedback).toContain("Please clarify.");
154
154
  });
155
155
 
156
+ test("a discussion renders as one item: the root comment, then its replies in order", () => {
157
+ // Arrange: a root, a later reply, an earlier reply, and a reply whose root is gone
158
+ const anchor = { quote: "daemon memory", prefix: "live only in ", suffix: " today." };
159
+
160
+ // Act
161
+ const feedback = renderFeedback({
162
+ verdictKind: "comment",
163
+ summary: "",
164
+ artifactContent: PLAN,
165
+ annotations: [
166
+ makeAnnotation({ id: "root", anchor, body: "Which daemon?" }),
167
+ makeAnnotation({
168
+ id: "late",
169
+ anchor,
170
+ body: "The local one, I think.",
171
+ replyTo: "root",
172
+ createdAt: "2026-08-07T00:02:00.000Z",
173
+ }),
174
+ makeAnnotation({
175
+ id: "early",
176
+ anchor,
177
+ body: "Worth stating in the plan.",
178
+ replyTo: "root",
179
+ author: "SHA256:ana",
180
+ createdAt: "2026-08-07T00:01:00.000Z",
181
+ }),
182
+ makeAnnotation({ id: "stray", anchor, body: "Standalone.", replyTo: "gone" }),
183
+ ],
184
+ });
185
+
186
+ // Assert: two items, replies listed under the root by time
187
+ expect(feedback).toContain("## Annotations (2)");
188
+ expect(feedback).toContain(
189
+ "Which daemon?\n\nReplies:\n\n- Worth stating in the plan.\n- The local one, I think.\n\nannotation id: `root`",
190
+ );
191
+ expect(feedback).toContain("### 2. Comment");
192
+ expect(feedback).toContain("Standalone.");
193
+ expect(feedback).not.toContain("annotation id: `late`");
194
+ });
195
+
196
+ test("a reply whose root was addressed leaves the document with it", () => {
197
+ // Arrange: the root is addressed, its reply is not marked, a stray reply's root is unknown
198
+ const anchor = { quote: "daemon memory", prefix: "live only in ", suffix: " today." };
199
+
200
+ // Act
201
+ const feedback = renderFeedback({
202
+ verdictKind: "comment",
203
+ summary: "",
204
+ artifactContent: PLAN,
205
+ annotations: [
206
+ makeAnnotation({
207
+ id: "root",
208
+ anchor,
209
+ body: "Which daemon?",
210
+ resolution: { revision: 2, source: "agent" },
211
+ }),
212
+ makeAnnotation({ id: "reply", anchor, body: "The local one.", replyTo: "root" }),
213
+ makeAnnotation({ id: "stray", anchor, body: "Standalone.", replyTo: "gone" }),
214
+ ],
215
+ });
216
+
217
+ // Assert: only the stray reply remains, as its own item
218
+ expect(feedback).toContain("## Annotations (1)");
219
+ expect(feedback).not.toContain("The local one.");
220
+ expect(feedback).toContain("Standalone.");
221
+ });
222
+
156
223
  test("orphaned anchors are flagged, never dropped", () => {
157
224
  // Act
158
225
  const feedback = renderFeedback({
package/src/feedback.ts CHANGED
@@ -43,9 +43,23 @@ export function renderFeedback(input: FeedbackInput): string {
43
43
  // agent notes are the submitter's own context - never echoed back as
44
44
  // feedback - and annotations a previous revision already addressed stay out
45
45
  // of the next document, so the agent only ever sees the open items
46
- const annotations = input.annotations.filter(
46
+ const open = input.annotations.filter(
47
47
  (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
48
48
  );
49
+ // a discussion is one item: the root comment, then its replies in order. A
50
+ // reply whose root was addressed leaves with it; only a reply whose root is
51
+ // gone entirely stands on its own
52
+ const openIds = new Set(open.map((annotation) => annotation.id));
53
+ const knownIds = new Set(input.annotations.map((annotation) => annotation.id));
54
+ const annotations = open.filter(
55
+ (annotation) =>
56
+ annotation.replyTo === undefined ||
57
+ (!openIds.has(annotation.replyTo) && !knownIds.has(annotation.replyTo)),
58
+ );
59
+ const repliesTo = (root: Annotation): Annotation[] =>
60
+ open
61
+ .filter((annotation) => annotation.replyTo === root.id)
62
+ .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
49
63
  const isDiff = input.artifactType === "diff";
50
64
  const isPrototype = input.artifactType === "prototype";
51
65
  // A diff's annotations anchor to the submitted patch rows; resolve against it,
@@ -131,6 +145,14 @@ export function renderFeedback(input: FeedbackInput): string {
131
145
  lines.push("");
132
146
  lines.push(annotation.body);
133
147
  lines.push("");
148
+ const replies = repliesTo(annotation);
149
+
150
+ if (replies.length > 0) {
151
+ lines.push("Replies:");
152
+ lines.push("");
153
+ for (const reply of replies) lines.push(`- ${reply.body.replace(/\n/g, "\n ")}`);
154
+ lines.push("");
155
+ }
134
156
  lines.push(`annotation id: \`${annotation.id}\``);
135
157
  lines.push("");
136
158
  });