@cueloop/schema 0.1.0-alpha.77 → 0.1.0-alpha.79

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.77",
3
+ "version": "0.1.0-alpha.79",
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": {
@@ -320,3 +320,75 @@ so a crash can never leave a torn file on disk.
320
320
  expect(resolved?.endBlockIndex).toBe(secondBullet - 1);
321
321
  });
322
322
  });
323
+
324
+ describe("fuzzy work budget", () => {
325
+ function largePlan(paragraphs: number): string {
326
+ const lines = ["# Plan", ""];
327
+
328
+ for (let index = 0; index < paragraphs; index++) {
329
+ lines.push(
330
+ `This is paragraph number ${index} describing the migration of the session store and its atomic write path in enough prose to be a realistic block a reviewer might quote.`,
331
+ "",
332
+ );
333
+ }
334
+
335
+ return lines.join("\n");
336
+ }
337
+
338
+ test("a stale anchor over a large plan stays bounded across all its blocks", () => {
339
+ // Arrange - a 160-block plan whose quoted paragraph was edited so the exact text is gone,
340
+ // forcing the fuzzy tier over every block; a per-block budget would freeze for many seconds
341
+ const blocks = parseBlocks(largePlan(160));
342
+ const quoted = Math.floor(blocks.length / 2);
343
+ const anchor = makeAnchor(blocks, quoted, 0, Math.min(blocks[quoted]!.text.length, 140));
344
+ const edited = blocks.map((block) => ({
345
+ ...block,
346
+ text: block.text.replaceAll("migration", "rollout").replaceAll("session", "record"),
347
+ }));
348
+
349
+ // Act
350
+ const started = Date.now();
351
+ const resolved = resolveAnchor(anchor, edited);
352
+ const elapsed = Date.now() - started;
353
+
354
+ // Assert - it rebinds via the fuzzy tier, and one shared budget keeps it far under a freeze
355
+ expect(resolved?.strategy).toBe("fuzzy");
356
+ expect(elapsed).toBeLessThan(2000);
357
+ });
358
+
359
+ test("still binds when a distinctive quote moved down a few blocks and was edited", () => {
360
+ // Arrange - a distinctive quote among unrelated blocks; the agent inserts paragraphs above it
361
+ // (shifting it down) and edits one word, so exact/normalized miss and the outward scan must
362
+ // reach the shifted block before the shared budget runs out
363
+ const original =
364
+ "The canonical migration store commits every session write atomically along one path.";
365
+ const plan = (target: string): string => {
366
+ const lines = ["# Plan", ""];
367
+
368
+ for (let index = 0; index < 40; index++) {
369
+ lines.push(
370
+ index === 20
371
+ ? target
372
+ : `Filler paragraph ${index} covers an unrelated topic in its own words.`,
373
+ "",
374
+ );
375
+ }
376
+
377
+ return lines.join("\n");
378
+ };
379
+ const before = parseBlocks(plan(original));
380
+ const quoted = before.findIndex((block) => block.text === original);
381
+ const anchor = makeAnchor(before, quoted, 0, original.length);
382
+ const edited = original.replace("atomically", "carefully");
383
+ const shifted = parseBlocks(
384
+ plan(edited).replace("# Plan\n", "# Plan\n\nInserted A.\n\nInserted B.\n\nInserted C.\n"),
385
+ );
386
+
387
+ // Act
388
+ const resolved = resolveAnchor(anchor, shifted);
389
+
390
+ // Assert - rebinds via fuzzy onto the shifted block, not orphaned or bound to a neighbour
391
+ expect(resolved?.strategy).toBe("fuzzy");
392
+ expect(shifted[resolved!.blockIndex]!.text).toBe(edited);
393
+ });
394
+ });
package/src/anchor.ts CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  import type { Anchor } from "./types";
20
20
  import { type Block, stripLeadingBlockMarker } from "./markdown";
21
- import { fuzzyFindBestMatch } from "./fuzzy";
21
+ import { fuzzyFindBestMatch, newFuzzyBudget } from "./fuzzy";
22
22
 
23
23
  /** Characters of surrounding text captured as prefix/suffix selectors. */
24
24
  const ANCHOR_CONTEXT_CHARS = 24;
@@ -139,7 +139,26 @@ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Ca
139
139
  */
140
140
  const FUZZY_CONTEXT_TIE_BREAK = 0.01;
141
141
 
142
- /** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
142
+ /** Block indices to scan, spiralling out from the anchor's hinted block: hint, hint+1, hint-1, ... */
143
+ function blockScanOrder(hint: number | undefined, count: number): number[] {
144
+ if (count <= 0) return [];
145
+ const first = Number.isInteger(hint) ? Math.min(Math.max(hint!, 0), count - 1) : 0;
146
+ const order = [first];
147
+
148
+ for (const step of Array.from({ length: count - 1 }, (_unused, index) => index + 1)) {
149
+ if (first + step < count) order.push(first + step);
150
+ if (first - step >= 0) order.push(first - step);
151
+ }
152
+
153
+ return order;
154
+ }
155
+
156
+ /**
157
+ * The best fuzzy window per block, ranked by similarity with context as a tiebreak. One work budget
158
+ * is shared across every block scan, so a stale anchor costs a single budget total, not one per
159
+ * block. Blocks are scanned outward from the anchor's hint, so a quote nudged a few blocks by an
160
+ * edit is reached before the budget runs out; a passage that moved far or is truly gone orphans.
161
+ */
143
162
  function findFuzzyCandidates(
144
163
  anchor: Anchor,
145
164
  blocks: Block[],
@@ -147,17 +166,21 @@ function findFuzzyCandidates(
147
166
  minimumSimilarity: number,
148
167
  ): Candidate[] {
149
168
  const candidates: Candidate[] = [];
169
+ const budget = newFuzzyBudget();
150
170
 
151
- blocks.forEach((block, blockIndex) => {
152
- const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
171
+ for (const blockIndex of blockScanOrder(anchor.blockIndex, blocks.length)) {
172
+ const block = blocks[blockIndex]!;
173
+ const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity, budget);
153
174
 
154
- if (match === null) return;
155
- const score =
156
- match.similarity +
157
- contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
175
+ if (match !== null) {
176
+ const score =
177
+ match.similarity +
178
+ contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
158
179
 
159
- candidates.push({ blockIndex, start: match.start, end: match.end, score });
160
- });
180
+ candidates.push({ blockIndex, start: match.start, end: match.end, score });
181
+ }
182
+ if (budget.remaining <= 0) break;
183
+ }
161
184
 
162
185
  return candidates;
163
186
  }
package/src/fuzzy.ts CHANGED
@@ -63,25 +63,43 @@ export function similarityRatio(left: string, right: string): number {
63
63
  }
64
64
 
65
65
  /**
66
- * Character comparisons this scan may spend before it gives up and returns the
67
- * best window found so far (or null). The scan is O(haystack * needle^3) - a
66
+ * Character comparisons a fuzzy search may spend before it gives up and returns
67
+ * the best window found so far (or null). The scan is O(haystack * needle^3) - a
68
68
  * long quote fuzzed over a long document (a stale multi-block anchor whose text
69
69
  * is gone) would otherwise pin the CPU for a minute; a passage that is truly gone
70
70
  * is better left orphaned than fuzzed. Small everyday quotes never approach it.
71
71
  */
72
72
  const FUZZY_WORK_BUDGET = 150_000_000;
73
73
 
74
+ /**
75
+ * A mutable work allowance shared across a group of fuzzy searches. Resolving one
76
+ * anchor scans every block, so a per-call cap would still let a stale multi-block
77
+ * anchor spend budget x blocks; sharing one budget across those scans bounds the
78
+ * whole anchor to a single budget, not one per block.
79
+ */
80
+ export interface FuzzyBudget {
81
+ remaining: number;
82
+ }
83
+
84
+ /** A fresh work allowance for one anchor's fuzzy search across its blocks. */
85
+ export function newFuzzyBudget(): FuzzyBudget {
86
+ return { remaining: FUZZY_WORK_BUDGET };
87
+ }
88
+
74
89
  /**
75
90
  * Find the haystack window most similar to the needle, or null when nothing
76
91
  * clears `minimumSimilarity`. Windows range around the needle length so the
77
92
  * match tolerates a few inserted or deleted characters, not only substitutions.
78
93
  * Ties keep the earliest window. Offsets index the haystack directly. The scan
79
- * stops once it has spent `FUZZY_WORK_BUDGET`, returning the best window so far.
94
+ * spends from `budget` (its own single-budget allowance by default) and stops
95
+ * once the budget is exhausted, returning the best window so far - so a caller
96
+ * scanning many blocks can pass one budget to bound them together.
80
97
  */
81
98
  export function fuzzyFindBestMatch(
82
99
  needle: string,
83
100
  haystack: string,
84
101
  minimumSimilarity: number,
102
+ budget: FuzzyBudget = newFuzzyBudget(),
85
103
  ): FuzzyMatch | null {
86
104
  if (needle === "" || haystack === "") return null;
87
105
 
@@ -90,7 +108,6 @@ export function fuzzyFindBestMatch(
90
108
  const maximumWindowLength = needle.length + lengthTolerance;
91
109
 
92
110
  let bestMatch: FuzzyMatch | null = null;
93
- let remainingWork = FUZZY_WORK_BUDGET;
94
111
 
95
112
  for (let start = 0; start < haystack.length; start++) {
96
113
  for (
@@ -98,9 +115,9 @@ export function fuzzyFindBestMatch(
98
115
  windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
99
116
  windowLength++
100
117
  ) {
101
- // each window costs a needle x window edit-distance pass; spend that from the budget
102
- remainingWork -= needle.length * windowLength;
103
- if (remainingWork <= 0) return bestMatch;
118
+ // each window costs a needle x window edit-distance pass; spend that from the shared budget
119
+ budget.remaining -= needle.length * windowLength;
120
+ if (budget.remaining <= 0) return bestMatch;
104
121
  const window = haystack.slice(start, start + windowLength);
105
122
  const similarity = similarityRatio(needle, window);
106
123
 
package/src/history.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A review session's history as an append-only tree of entries. Each entry
2
+ * A thread's history as an append-only tree of entries. Each entry
3
3
  * points at its parent; a branch is a named tip; the active path is the chain
4
4
  * from the current branch's tip back to the root. Nothing is ever deleted:
5
5
  * navigating moves a tip, a comment removal is its own entry, a fork copies a
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./types";
2
2
  export * from "./participants";
3
3
  export * from "./share-access";
4
+ export * from "./share-links";
4
5
  export * from "./markdown";
5
6
  export * from "./inline";
6
7
  export * from "./working-copy";
@@ -102,13 +102,74 @@ describe("parseBlocks", () => {
102
102
 
103
103
  test("unknown constructs degrade to paragraphs, no content lost", () => {
104
104
  // Arrange
105
- const md = "| a | b |\n|---|---|\n| 1 | 2 |";
105
+ const md = "<div>raw html</div>";
106
106
 
107
107
  // Act
108
108
  const blocks = parseBlocks(md);
109
109
 
110
110
  // Assert
111
- expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
111
+ expect(blocks.map((block) => block.kind)).toEqual(["p"]);
112
+ expect(blocks[0]!.text).toBe("<div>raw html</div>");
113
+ });
114
+
115
+ test("a GFM table parses to one table block that keeps its source verbatim", () => {
116
+ // Arrange
117
+ const md = "before\n\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nafter";
118
+
119
+ // Act
120
+ const blocks = parseBlocks(md);
121
+
122
+ // Assert
123
+ expect(blocks.map((block) => block.kind)).toEqual(["p", "table", "p"]);
124
+ const table = blocks.find((block) => block.kind === "table")!;
125
+
126
+ expect(table.text).toBe("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |");
127
+ });
128
+
129
+ test("a single-column table and an aligned delimiter still parse as a table", () => {
130
+ // Assert
131
+ expect(parseBlocks("| only |\n| --- |\n| one |")[0]!.kind).toBe("table");
132
+ expect(parseBlocks("| l | r |\n| :--- | ---: |\n| a | b |")[0]!.kind).toBe("table");
133
+ });
134
+
135
+ test("a plain rule line is an hr, never a table", () => {
136
+ // Assert
137
+ expect(parseBlocks("above\n\n---\n\nbelow").map((block) => block.kind)).toEqual([
138
+ "p",
139
+ "hr",
140
+ "p",
141
+ ]);
142
+ });
143
+
144
+ test("leading frontmatter parses to a frontmatter block, closed by its fence", () => {
145
+ // Arrange
146
+ const md = "---\ntitle: Plan\nowner: me\n---\n\n# Heading\n";
147
+
148
+ // Act
149
+ const blocks = parseBlocks(md);
150
+
151
+ // Assert
152
+ expect(blocks.map((block) => block.kind)).toEqual(["frontmatter", "h1"]);
153
+ expect(blocks[0]!.text).toBe("title: Plan\nowner: me");
154
+ });
155
+
156
+ test("a leading fence with no closing fence stays an hr, not frontmatter", () => {
157
+ // Assert
158
+ expect(parseBlocks("---\njust a paragraph\n").map((block) => block.kind)).toEqual(["hr", "p"]);
159
+ });
160
+
161
+ test("table and frontmatter round-trip through blockToMd", () => {
162
+ // Arrange
163
+ const md = "---\ntitle: Plan\n---\n\n| a | b |\n|---|---|\n| 1 | 2 |";
164
+ const blocks = parseBlocks(md);
165
+
166
+ // Act
167
+ const rebuilt = blocks.map((block) => blockToMd(block)).join("\n\n");
168
+
169
+ // Assert
170
+ expect(parseBlocks(rebuilt).map((block) => [block.kind, block.text])).toEqual(
171
+ blocks.map((block) => [block.kind, block.text]),
172
+ );
112
173
  });
113
174
  });
114
175
 
package/src/markdown.ts CHANGED
@@ -6,11 +6,22 @@
6
6
  * so no content is ever lost.
7
7
  */
8
8
 
9
- export type BlockKind = "h1" | "h2" | "h3" | "p" | "li" | "oli" | "quote" | "code" | "hr";
9
+ export type BlockKind =
10
+ | "h1"
11
+ | "h2"
12
+ | "h3"
13
+ | "p"
14
+ | "li"
15
+ | "oli"
16
+ | "quote"
17
+ | "code"
18
+ | "hr"
19
+ | "table"
20
+ | "frontmatter";
10
21
 
11
22
  export interface Block {
12
23
  kind: BlockKind;
13
- /** Content with markers stripped; code blocks keep inner lines verbatim. */
24
+ /** Content with markers stripped; code, table, and frontmatter blocks keep inner lines verbatim. */
14
25
  text: string;
15
26
  /** For code blocks: the fence info string ("ts", "diff", ...). */
16
27
  lang?: string;
@@ -31,110 +42,193 @@ export function stripLeadingBlockMarker(line: string): string {
31
42
  return line.replace(LEADING_BLOCK_MARKER, "");
32
43
  }
33
44
 
45
+ /** A GFM table delimiter row: one or more dash cells split by pipes, each optionally `:`-aligned. */
46
+ function isTableDelimiterRow(line: string): boolean {
47
+ if (!line.includes("|")) return false;
48
+ const cells = line
49
+ .trim()
50
+ .replace(/^\||\|$/g, "")
51
+ .split("|");
52
+
53
+ return cells.length >= 1 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
54
+ }
55
+
56
+ /** A GFM table opens on a header row of pipe cells whose next line is a delimiter row. */
57
+ function isTableStart(lines: string[], index: number): boolean {
58
+ const header = lines[index];
59
+ const delimiter = lines[index + 1];
60
+
61
+ return (
62
+ header !== undefined &&
63
+ header.includes("|") &&
64
+ delimiter !== undefined &&
65
+ isTableDelimiterRow(delimiter)
66
+ );
67
+ }
68
+
34
69
  function isMarkerLine(line: string): boolean {
35
70
  return (
36
71
  line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
37
72
  );
38
73
  }
39
74
 
75
+ /**
76
+ * Leading YAML frontmatter: a --- fence on the very first line, closed by a
77
+ * second --- fence. Null when there is no leading fence or no closing fence -
78
+ * a lone leading --- then falls through to an hr.
79
+ */
80
+ function frontmatterBlock(lines: string[]): Block | null {
81
+ if (lines[0]?.trim() !== "---") return null;
82
+ let close = 1;
83
+
84
+ while (close < lines.length && lines[close]!.trim() !== "---") close++;
85
+ if (close >= lines.length) return null;
86
+
87
+ return {
88
+ kind: "frontmatter",
89
+ text: lines.slice(1, close).join("\n"),
90
+ lineStart: 0,
91
+ lineEnd: close,
92
+ };
93
+ }
94
+
95
+ /** A block consumed from a line span, with the next line to resume parsing at. */
96
+ interface BlockScan {
97
+ block: Block;
98
+ next: number;
99
+ }
100
+
101
+ /** A one-line block - a heading, a rule, a bullet, or an ordered item - at `lineIndex`, or null. */
102
+ function singleLineBlock(line: string, lineIndex: number): Block | null {
103
+ const marked = (kind: BlockKind): Block => ({
104
+ kind,
105
+ text: stripLeadingBlockMarker(line),
106
+ lineStart: lineIndex,
107
+ lineEnd: lineIndex,
108
+ });
109
+
110
+ if (line.startsWith("### ")) return marked("h3");
111
+ if (line.startsWith("## ")) return marked("h2");
112
+ if (line.startsWith("# ")) return marked("h1");
113
+ if (/^(---|\*\*\*|___)\s*$/.test(line)) {
114
+ return { kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex };
115
+ }
116
+ if (line.startsWith("- ")) return marked("li");
117
+ if (/^\d+\. /.test(line)) return marked("oli");
118
+
119
+ return null;
120
+ }
121
+
122
+ /** Consume a fenced code block from its opening ``` at `start`. */
123
+ function codeBlock(lines: string[], start: number): BlockScan {
124
+ const lang = lines[start]!.slice(3).trim() || undefined;
125
+ let index = start + 1;
126
+ const body: string[] = [];
127
+
128
+ while (index < lines.length && !lines[index]!.startsWith("```")) {
129
+ body.push(lines[index]!);
130
+ index++;
131
+ }
132
+ const end = Math.min(index, lines.length - 1);
133
+
134
+ return {
135
+ block: { kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end },
136
+ next: index + 1,
137
+ };
138
+ }
139
+
140
+ /** Consume a blockquote's consecutive `> ` lines from `start`. */
141
+ function quoteBlock(lines: string[], start: number): BlockScan {
142
+ let index = start;
143
+ const body: string[] = [];
144
+
145
+ while (index < lines.length && lines[index]!.startsWith("> ")) {
146
+ body.push(stripLeadingBlockMarker(lines[index]!));
147
+ index++;
148
+ }
149
+
150
+ return {
151
+ block: { kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
152
+ next: index,
153
+ };
154
+ }
155
+
156
+ /** Consume a GFM table (header, delimiter, then pipe rows) from `start`. */
157
+ function tableBlock(lines: string[], start: number): BlockScan {
158
+ const body: string[] = [lines[start]!, lines[start + 1]!];
159
+ let index = start + 2;
160
+
161
+ // body rows run until a blank line, a fence, or a line without a pipe
162
+ while (
163
+ index < lines.length &&
164
+ lines[index]!.trim() !== "" &&
165
+ lines[index]!.includes("|") &&
166
+ !lines[index]!.startsWith("```")
167
+ ) {
168
+ body.push(lines[index]!);
169
+ index++;
170
+ }
171
+
172
+ return {
173
+ block: { kind: "table", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
174
+ next: index,
175
+ };
176
+ }
177
+
178
+ /** Consume a paragraph: consecutive prose lines until a blank line, a marker, or a table. */
179
+ function paragraphBlock(lines: string[], start: number): BlockScan {
180
+ let index = start;
181
+ const body: string[] = [];
182
+
183
+ while (
184
+ index < lines.length &&
185
+ lines[index]!.trim() !== "" &&
186
+ !isMarkerLine(lines[index]!) &&
187
+ !isTableStart(lines, index)
188
+ ) {
189
+ body.push(lines[index]!);
190
+ index++;
191
+ }
192
+
193
+ return {
194
+ block: { kind: "p", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
195
+ next: index,
196
+ };
197
+ }
198
+
199
+ /** The next block starting at `lineIndex` (never a blank line, never frontmatter). */
200
+ function nextBlock(lines: string[], lineIndex: number): BlockScan {
201
+ const line = lines[lineIndex]!;
202
+
203
+ if (line.startsWith("```")) return codeBlock(lines, lineIndex);
204
+ const single = singleLineBlock(line, lineIndex);
205
+
206
+ if (single) return { block: single, next: lineIndex + 1 };
207
+ if (line.startsWith("> ")) return quoteBlock(lines, lineIndex);
208
+ if (isTableStart(lines, lineIndex)) return tableBlock(lines, lineIndex);
209
+
210
+ return paragraphBlock(lines, lineIndex);
211
+ }
212
+
40
213
  export function parseBlocks(markdown: string): Block[] {
41
214
  const lines = markdown.split("\n");
42
215
  const blocks: Block[] = [];
43
216
  let lineIndex = 0;
217
+ const frontmatter = frontmatterBlock(lines);
44
218
 
219
+ if (frontmatter) {
220
+ blocks.push(frontmatter);
221
+ lineIndex = frontmatter.lineEnd + 1;
222
+ }
45
223
  while (lineIndex < lines.length) {
46
- const line = lines[lineIndex]!;
47
-
48
- if (line.trim() === "") {
224
+ if (lines[lineIndex]!.trim() === "") {
49
225
  lineIndex++;
50
226
  continue;
51
227
  }
52
- if (line.startsWith("```")) {
53
- const start = lineIndex;
54
- const lang = line.slice(3).trim() || undefined;
55
-
56
- lineIndex++;
57
- const body: string[] = [];
58
-
59
- while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
60
- body.push(lines[lineIndex]!);
61
- lineIndex++;
62
- }
63
- const end = Math.min(lineIndex, lines.length - 1);
228
+ const scan = nextBlock(lines, lineIndex);
64
229
 
65
- lineIndex++;
66
- blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
67
- } else if (line.startsWith("### ")) {
68
- blocks.push({
69
- kind: "h3",
70
- text: stripLeadingBlockMarker(line),
71
- lineStart: lineIndex,
72
- lineEnd: lineIndex,
73
- });
74
- lineIndex++;
75
- } else if (line.startsWith("## ")) {
76
- blocks.push({
77
- kind: "h2",
78
- text: stripLeadingBlockMarker(line),
79
- lineStart: lineIndex,
80
- lineEnd: lineIndex,
81
- });
82
- lineIndex++;
83
- } else if (line.startsWith("# ")) {
84
- blocks.push({
85
- kind: "h1",
86
- text: stripLeadingBlockMarker(line),
87
- lineStart: lineIndex,
88
- lineEnd: lineIndex,
89
- });
90
- lineIndex++;
91
- } else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
92
- blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
93
- lineIndex++;
94
- } else if (line.startsWith("> ")) {
95
- const start = lineIndex;
96
- const body: string[] = [];
97
-
98
- while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
99
- body.push(stripLeadingBlockMarker(lines[lineIndex]!));
100
- lineIndex++;
101
- }
102
- blocks.push({
103
- kind: "quote",
104
- text: body.join("\n"),
105
- lineStart: start,
106
- lineEnd: lineIndex - 1,
107
- });
108
- } else if (line.startsWith("- ")) {
109
- blocks.push({
110
- kind: "li",
111
- text: stripLeadingBlockMarker(line),
112
- lineStart: lineIndex,
113
- lineEnd: lineIndex,
114
- });
115
- lineIndex++;
116
- } else if (/^\d+\. /.test(line)) {
117
- blocks.push({
118
- kind: "oli",
119
- text: stripLeadingBlockMarker(line),
120
- lineStart: lineIndex,
121
- lineEnd: lineIndex,
122
- });
123
- lineIndex++;
124
- } else {
125
- const start = lineIndex;
126
- const body: string[] = [];
127
-
128
- while (
129
- lineIndex < lines.length &&
130
- lines[lineIndex]!.trim() !== "" &&
131
- !isMarkerLine(lines[lineIndex]!)
132
- ) {
133
- body.push(lines[lineIndex]!);
134
- lineIndex++;
135
- }
136
- blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
137
- }
230
+ blocks.push(scan.block);
231
+ lineIndex = scan.next;
138
232
  }
139
233
 
140
234
  return blocks;
@@ -162,6 +256,10 @@ export function blockToMd(block: Block, ordinal = 1): string {
162
256
  return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
163
257
  case "hr":
164
258
  return "---";
259
+ case "frontmatter":
260
+ return "---\n" + block.text + "\n---";
261
+ case "table":
262
+ return block.text;
165
263
  default:
166
264
  return block.text;
167
265
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { registerParticipant } from "./participants";
2
+ import { registerParticipant, registeredGithubLogin } from "./participants";
3
3
  import { SCHEMA_VERSION, type Thread } from "./types";
4
4
 
5
5
  function emptySession(): Thread {
@@ -85,3 +85,21 @@ describe("registerParticipant", () => {
85
85
  ]);
86
86
  });
87
87
  });
88
+
89
+ describe("registeredGithubLogin", () => {
90
+ test("returns the handle a fingerprint verified on a past visit", () => {
91
+ const session = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
92
+ provider: "github",
93
+ handle: "ana-gh",
94
+ });
95
+
96
+ expect(registeredGithubLogin(session, "SHA256:ana")).toBe("ana-gh");
97
+ });
98
+
99
+ test("returns undefined for an ssh-only or unknown participant", () => {
100
+ const session = registerParticipant(emptySession(), "SHA256:ana", "Ana");
101
+
102
+ expect(registeredGithubLogin(session, "SHA256:ana")).toBeUndefined();
103
+ expect(registeredGithubLogin(session, "SHA256:unknown")).toBeUndefined();
104
+ });
105
+ });
@@ -18,6 +18,12 @@ export interface ParticipantSource {
18
18
  handle?: string;
19
19
  }
20
20
 
21
+ export function registeredGithubLogin(session: Thread, author: string): string | undefined {
22
+ const existing = (session.participants ?? []).find((participant) => participant.id === author);
23
+
24
+ return existing?.provider === "github" ? existing.handle : undefined;
25
+ }
26
+
21
27
  export function registerParticipant(
22
28
  session: Thread,
23
29
  author: string,
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Thread } from "./types";
3
+ import { threadShareLinks, withShareLinks } from "./share-links";
4
+
5
+ function thread(overrides: Partial<Thread> = {}): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
11
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
12
+ annotations: [],
13
+ verdict: null,
14
+ status: "pending",
15
+ createdAt: "2026-01-01T00:00:00.000Z",
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("share-link migration", () => {
21
+ test("a never-shared thread has no links", () => {
22
+ expect(threadShareLinks(thread())).toBeUndefined();
23
+ expect(withShareLinks(thread()).shares).toBeUndefined();
24
+ });
25
+
26
+ test("a legacy private share migrates into a one-element list", () => {
27
+ const legacy = thread({
28
+ shareId: "p_abc123xy",
29
+ access: { githubLogins: ["octocat"] },
30
+ owner: "SHA256:owner",
31
+ shareBranch: "feature",
32
+ });
33
+
34
+ expect(withShareLinks(legacy).shares).toEqual([
35
+ {
36
+ id: "p_abc123xy",
37
+ requireAuth: true,
38
+ allowlist: ["octocat"],
39
+ owner: "SHA256:owner",
40
+ shareBranch: "feature",
41
+ },
42
+ ]);
43
+ });
44
+
45
+ test("a legacy public share migrates as requireAuth false with an empty allowlist", () => {
46
+ const legacy = thread({ shareId: "p_public00", owner: "SHA256:owner" });
47
+ const [link] = withShareLinks(legacy).shares!;
48
+
49
+ expect(link).toMatchObject({ id: "p_public00", requireAuth: false, allowlist: [] });
50
+ });
51
+
52
+ test("a thread that already has shares is returned untouched", () => {
53
+ const modern = thread({
54
+ shares: [{ id: "p_modern00", name: "team", requireAuth: false, allowlist: [] }],
55
+ // stale legacy fields must not override the explicit shares
56
+ shareId: "p_legacy00",
57
+ });
58
+
59
+ expect(withShareLinks(modern).shares).toEqual([
60
+ { id: "p_modern00", name: "team", requireAuth: false, allowlist: [] },
61
+ ]);
62
+ });
63
+ });
@@ -0,0 +1,29 @@
1
+ import type { ShareLink, Thread } from "./types";
2
+
3
+ /**
4
+ * A thread's share links, migrating a legacy single share (scalar shareId +
5
+ * access + owner + shareBranch) into a one-element list. Undefined when the
6
+ * thread was never shared. Records already carrying `shares` are returned as-is.
7
+ */
8
+ export function threadShareLinks(session: Thread): ShareLink[] | undefined {
9
+ if (session.shares !== undefined) return session.shares;
10
+ if (session.shareId === undefined) return undefined;
11
+
12
+ return [
13
+ {
14
+ id: session.shareId,
15
+ requireAuth: session.access !== undefined,
16
+ allowlist: session.access?.githubLogins ?? [],
17
+ owner: session.owner,
18
+ shareBranch: session.shareBranch,
19
+ },
20
+ ];
21
+ }
22
+
23
+ /** The thread with `shares` populated from the legacy fields when absent; a never-shared thread is unchanged. */
24
+ export function withShareLinks(session: Thread): Thread {
25
+ if (session.shares !== undefined) return session;
26
+ const shares = threadShareLinks(session);
27
+
28
+ return shares ? { ...session, shares } : session;
29
+ }
package/src/types.ts CHANGED
@@ -8,7 +8,7 @@ import type { SessionHistory } from "./history";
8
8
 
9
9
  export const SCHEMA_VERSION = "1";
10
10
 
11
- /** A workspace is a repo/branch context holding review sessions. */
11
+ /** A workspace is a repo/branch context holding threads. */
12
12
  export interface WorkspaceKey {
13
13
  repoRoot: string;
14
14
  branch: string;
@@ -19,7 +19,7 @@ export interface WorkspaceKey {
19
19
  }
20
20
 
21
21
  /**
22
- * What kind of artifact a review session holds. `plan` and `reply` are both
22
+ * What kind of artifact a thread holds. `plan` and `reply` are both
23
23
  * markdown documents (see isMarkdownArtifact) - a plan is a proposal written
24
24
  * forward, a reply is the agent's previous message pulled back for review.
25
25
  * `diff` is a unified-diff patch; `prototype` is a component design doc (API /
@@ -237,6 +237,27 @@ export interface ShareAccess {
237
237
  githubLogins: string[];
238
238
  }
239
239
 
240
+ /**
241
+ * One published share link for a thread. A thread can have several, each an
242
+ * independent gateway blob keyed by its own `id`. `name` is a local-only label
243
+ * to tell links apart; `requireAuth` gates the link behind the `allowlist` of
244
+ * GitHub logins (empty + requireAuth is a private link with no one added yet).
245
+ */
246
+ export interface ShareLink {
247
+ /** The share id (`p_…`), the link's address and gateway blob key. */
248
+ id: string;
249
+ /** A local, cosmetic label for the list; never leaves the planner's machine. */
250
+ name?: string;
251
+ /** True = private (only `allowlist` may open it); false = public. */
252
+ requireAuth: boolean;
253
+ /** GitHub logins allowed when `requireAuth`; ignored when public. */
254
+ allowlist: string[];
255
+ /** SSH fingerprint that created the link; the gateway stamps it to gate pulls/pushes/revokes. */
256
+ owner?: string;
257
+ /** The branch this link follows and shows collaborators; `main` when absent. */
258
+ shareBranch?: string;
259
+ }
260
+
240
261
  export interface Thread {
241
262
  schemaVersion: string;
242
263
  id: string;
@@ -277,13 +298,15 @@ export interface Thread {
277
298
  shelvedAnnotations?: Annotation[];
278
299
  /** The session this one was forked from. */
279
300
  parentSessionId?: string;
280
- /** Share id once published; lets the planner pull collaborator notes back. */
301
+ /** Published share links for this thread; each is an independent gateway blob. Migrated from the legacy scalar fields on read. */
302
+ shares?: ShareLink[];
303
+ /** @deprecated Legacy single-share id; migrated into `shares` on read. */
281
304
  shareId?: string;
282
- /** The branch the share follows and shows collaborators; `main` when absent. */
305
+ /** @deprecated Legacy single-share branch; migrated into `shares` on read. */
283
306
  shareBranch?: string;
284
- /** SSH fingerprint that created the share; the gateway stamps it to gate pulls. */
307
+ /** @deprecated Legacy single-share owner fingerprint; migrated into `shares` on read. */
285
308
  owner?: string;
286
- /** Owner-set allowlist of GitHub logins for a private share; absent = public. */
309
+ /** @deprecated Legacy single-share allowlist; migrated into `shares` on read. */
287
310
  access?: ShareAccess;
288
311
  /**
289
312
  * Identities that authored annotations here, keyed by id (union-by-id, like
@@ -293,9 +316,6 @@ export interface Thread {
293
316
  participants?: Identity[];
294
317
  }
295
318
 
296
- /** @deprecated use Thread */
297
- export type ReviewSession = Thread;
298
-
299
319
  /** comment and request_changes both map to deny in agent-native contracts. */
300
320
  export function verdictAllows(kind: VerdictKind): boolean {
301
321
  return kind === "approve";