@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.90

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/markdown.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  /**
2
- * Markdown block model (map #22): parse source into addressable blocks that
3
- * record their source line ranges. Rendered→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
  */
8
7
 
9
- export type BlockKind = "h1" | "h2" | "h3" | "p" | "li" | "oli" | "quote" | "code" | "hr";
8
+ export type BlockKind =
9
+ | "h1"
10
+ | "h2"
11
+ | "h3"
12
+ | "p"
13
+ | "li"
14
+ | "oli"
15
+ | "quote"
16
+ | "code"
17
+ | "hr"
18
+ | "table"
19
+ | "frontmatter";
10
20
 
11
21
  export interface Block {
12
22
  kind: BlockKind;
13
- /** Content with markers stripped; code blocks keep inner lines verbatim. */
23
+ /** Content with markers stripped; code, table, and frontmatter blocks keep inner lines verbatim. */
14
24
  text: string;
15
25
  /** For code blocks: the fence info string ("ts", "diff", ...). */
16
26
  lang?: string;
@@ -19,113 +29,352 @@ export interface Block {
19
29
  lineEnd: number;
20
30
  }
21
31
 
22
- function isMarkerLine(l: string): boolean {
32
+ export interface BlockTextSourceSegment {
33
+ textStart: number;
34
+ textEnd: number;
35
+ sourceStart: number;
36
+ }
37
+
38
+ /**
39
+ * The leading block markers the parser strips from a line: headings (h1-h3),
40
+ * bullet list, ordered list, and blockquote. Single source of truth so the
41
+ * anchor resolver strips exactly the same set and the two never drift.
42
+ */
43
+ export const LEADING_BLOCK_MARKER = /^(?:#{1,3} |- |\d+\. |> )/;
44
+
45
+ /** Remove one leading markdown block marker, or return the line unchanged. */
46
+ export function stripLeadingBlockMarker(line: string): string {
47
+ return line.replace(LEADING_BLOCK_MARKER, "");
48
+ }
49
+
50
+ /** A GFM table delimiter row: one or more dash cells split by pipes, each optionally `:`-aligned. */
51
+ function isTableDelimiterRow(line: string): boolean {
52
+ if (!line.includes("|")) return false;
53
+ const cells = line
54
+ .trim()
55
+ .replace(/^\||\|$/g, "")
56
+ .split("|");
57
+
58
+ return cells.length >= 1 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
59
+ }
60
+
61
+ /** A GFM table opens on a header row of pipe cells whose next line is a delimiter row. */
62
+ function isTableStart(lines: string[], index: number): boolean {
63
+ const header = lines[index];
64
+ const delimiter = lines[index + 1];
65
+
23
66
  return (
24
- l.startsWith("```") ||
25
- l.startsWith("# ") ||
26
- l.startsWith("## ") ||
27
- l.startsWith("### ") ||
28
- l.startsWith("> ") ||
29
- /^- /.test(l) ||
30
- /^\d+\. /.test(l) ||
31
- /^(---|\*\*\*|___)\s*$/.test(l)
67
+ header !== undefined &&
68
+ header.includes("|") &&
69
+ delimiter !== undefined &&
70
+ isTableDelimiterRow(delimiter)
32
71
  );
33
72
  }
34
73
 
35
- export function parseBlocks(md: string): Block[] {
36
- const lines = md.split("\n");
74
+ function isMarkerLine(line: string): boolean {
75
+ return (
76
+ line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Leading YAML frontmatter: a --- fence on the very first line, closed by a
82
+ * second --- fence. Null when there is no leading fence or no closing fence -
83
+ * a lone leading --- then falls through to an hr.
84
+ */
85
+ function frontmatterBlock(lines: string[]): Block | null {
86
+ if (lines[0]?.trim() !== "---") return null;
87
+ let close = 1;
88
+
89
+ while (close < lines.length && lines[close]!.trim() !== "---") close++;
90
+ if (close >= lines.length) return null;
91
+
92
+ return {
93
+ kind: "frontmatter",
94
+ text: lines.slice(1, close).join("\n"),
95
+ lineStart: 0,
96
+ lineEnd: close,
97
+ };
98
+ }
99
+
100
+ /** A block consumed from a line span, with the next line to resume parsing at. */
101
+ interface BlockScan {
102
+ block: Block;
103
+ next: number;
104
+ }
105
+
106
+ /** A one-line block - a heading, a rule, a bullet, or an ordered item - at `lineIndex`, or null. */
107
+ function singleLineBlock(line: string, lineIndex: number): Block | null {
108
+ const marked = (kind: BlockKind): Block => ({
109
+ kind,
110
+ text: stripLeadingBlockMarker(line),
111
+ lineStart: lineIndex,
112
+ lineEnd: lineIndex,
113
+ });
114
+
115
+ if (line.startsWith("### ")) return marked("h3");
116
+ if (line.startsWith("## ")) return marked("h2");
117
+ if (line.startsWith("# ")) return marked("h1");
118
+ if (/^(---|\*\*\*|___)\s*$/.test(line)) {
119
+ return { kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex };
120
+ }
121
+ if (line.startsWith("- ")) return marked("li");
122
+ if (/^\d+\. /.test(line)) return marked("oli");
123
+
124
+ return null;
125
+ }
126
+
127
+ /** Consume a fenced code block from its opening ``` at `start`. */
128
+ function codeBlock(lines: string[], start: number): BlockScan {
129
+ const lang = lines[start]!.slice(3).trim() || undefined;
130
+ let index = start + 1;
131
+ const body: string[] = [];
132
+
133
+ while (index < lines.length && !lines[index]!.startsWith("```")) {
134
+ body.push(lines[index]!);
135
+ index++;
136
+ }
137
+ const end = Math.min(index, lines.length - 1);
138
+
139
+ return {
140
+ block: { kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end },
141
+ next: index + 1,
142
+ };
143
+ }
144
+
145
+ /** Consume a blockquote's consecutive `> ` lines from `start`. */
146
+ function quoteBlock(lines: string[], start: number): BlockScan {
147
+ let index = start;
148
+ const body: string[] = [];
149
+
150
+ while (index < lines.length && lines[index]!.startsWith("> ")) {
151
+ body.push(stripLeadingBlockMarker(lines[index]!));
152
+ index++;
153
+ }
154
+
155
+ return {
156
+ block: { kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
157
+ next: index,
158
+ };
159
+ }
160
+
161
+ /** Consume a GFM table (header, delimiter, then pipe rows) from `start`. */
162
+ function tableBlock(lines: string[], start: number): BlockScan {
163
+ const body: string[] = [lines[start]!, lines[start + 1]!];
164
+ let index = start + 2;
165
+
166
+ // body rows run until a blank line, a fence, or a line without a pipe
167
+ while (
168
+ index < lines.length &&
169
+ lines[index]!.trim() !== "" &&
170
+ lines[index]!.includes("|") &&
171
+ !lines[index]!.startsWith("```")
172
+ ) {
173
+ body.push(lines[index]!);
174
+ index++;
175
+ }
176
+
177
+ return {
178
+ block: { kind: "table", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
179
+ next: index,
180
+ };
181
+ }
182
+
183
+ /** Consume a paragraph: consecutive prose lines until a blank line, a marker, or a table. */
184
+ function paragraphBlock(lines: string[], start: number): BlockScan {
185
+ let index = start;
186
+ const body: string[] = [];
187
+
188
+ while (
189
+ index < lines.length &&
190
+ lines[index]!.trim() !== "" &&
191
+ !isMarkerLine(lines[index]!) &&
192
+ !isTableStart(lines, index)
193
+ ) {
194
+ body.push(lines[index]!);
195
+ index++;
196
+ }
197
+
198
+ return {
199
+ block: { kind: "p", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
200
+ next: index,
201
+ };
202
+ }
203
+
204
+ /** The next block starting at `lineIndex` (never a blank line, never frontmatter). */
205
+ function nextBlock(lines: string[], lineIndex: number): BlockScan {
206
+ const line = lines[lineIndex]!;
207
+
208
+ if (line.startsWith("```")) return codeBlock(lines, lineIndex);
209
+ const single = singleLineBlock(line, lineIndex);
210
+
211
+ if (single) return { block: single, next: lineIndex + 1 };
212
+ if (line.startsWith("> ")) return quoteBlock(lines, lineIndex);
213
+ if (isTableStart(lines, lineIndex)) return tableBlock(lines, lineIndex);
214
+
215
+ return paragraphBlock(lines, lineIndex);
216
+ }
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
+
313
+ export function parseBlocks(markdown: string): Block[] {
314
+ const lines = markdown.split("\n");
37
315
  const blocks: Block[] = [];
38
- let i = 0;
39
- while (i < lines.length) {
40
- const l = lines[i]!;
41
- if (l.trim() === "") {
42
- i++;
316
+ let lineIndex = 0;
317
+ const frontmatter = frontmatterBlock(lines);
318
+
319
+ if (frontmatter) {
320
+ blocks.push(frontmatter);
321
+ lineIndex = frontmatter.lineEnd + 1;
322
+ }
323
+ while (lineIndex < lines.length) {
324
+ if (lines[lineIndex]!.trim() === "") {
325
+ lineIndex++;
43
326
  continue;
44
327
  }
45
- if (l.startsWith("```")) {
46
- const start = i;
47
- const lang = l.slice(3).trim() || undefined;
48
- i++;
49
- const body: string[] = [];
50
- while (i < lines.length && !lines[i]!.startsWith("```")) {
51
- body.push(lines[i]!);
52
- i++;
53
- }
54
- const end = Math.min(i, lines.length - 1);
55
- i++;
56
- blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
57
- } else if (l.startsWith("### ")) {
58
- blocks.push({ kind: "h3", text: l.slice(4), lineStart: i, lineEnd: i });
59
- i++;
60
- } else if (l.startsWith("## ")) {
61
- blocks.push({ kind: "h2", text: l.slice(3), lineStart: i, lineEnd: i });
62
- i++;
63
- } else if (l.startsWith("# ")) {
64
- blocks.push({ kind: "h1", text: l.slice(2), lineStart: i, lineEnd: i });
65
- i++;
66
- } else if (/^(---|\*\*\*|___)\s*$/.test(l)) {
67
- blocks.push({ kind: "hr", text: "", lineStart: i, lineEnd: i });
68
- i++;
69
- } else if (l.startsWith("> ")) {
70
- const start = i;
71
- const body: string[] = [];
72
- while (i < lines.length && lines[i]!.startsWith("> ")) {
73
- body.push(lines[i]!.slice(2));
74
- i++;
75
- }
76
- blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
77
- } else if (/^- /.test(l)) {
78
- blocks.push({ kind: "li", text: l.slice(2), lineStart: i, lineEnd: i });
79
- i++;
80
- } else if (/^\d+\. /.test(l)) {
81
- blocks.push({ kind: "oli", text: l.replace(/^\d+\. /, ""), lineStart: i, lineEnd: i });
82
- i++;
83
- } else {
84
- const start = i;
85
- const body: string[] = [];
86
- while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
87
- body.push(lines[i]!);
88
- i++;
89
- }
90
- blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
91
- }
328
+ const scan = nextBlock(lines, lineIndex);
329
+
330
+ blocks.push(scan.block);
331
+ lineIndex = scan.next;
92
332
  }
333
+
93
334
  return blocks;
94
335
  }
95
336
 
96
337
  /** Serialize one block back to its markdown chunk. */
97
- export function blockToMd(b: Block, ordinal = 1): string {
98
- switch (b.kind) {
338
+ export function blockToMd(block: Block, ordinal = 1): string {
339
+ switch (block.kind) {
99
340
  case "h1":
100
- return "# " + b.text;
341
+ return "# " + block.text;
101
342
  case "h2":
102
- return "## " + b.text;
343
+ return "## " + block.text;
103
344
  case "h3":
104
- return "### " + b.text;
345
+ return "### " + block.text;
105
346
  case "li":
106
- return "- " + b.text;
347
+ return "- " + block.text;
107
348
  case "oli":
108
- return `${ordinal}. ` + b.text;
349
+ return `${ordinal}. ` + block.text;
109
350
  case "quote":
110
- return b.text
351
+ return block.text
111
352
  .split("\n")
112
- .map((l) => "> " + l)
353
+ .map((line) => "> " + line)
113
354
  .join("\n");
114
355
  case "code":
115
- return "```" + (b.lang ?? "") + "\n" + b.text + "\n```";
356
+ return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
116
357
  case "hr":
117
358
  return "---";
359
+ case "frontmatter":
360
+ return "---\n" + block.text + "\n---";
361
+ case "table":
362
+ return block.text;
118
363
  default:
119
- return b.text;
364
+ return block.text;
120
365
  }
121
366
  }
122
367
 
123
368
  /** The section (nearest preceding heading) a block belongs to. */
124
369
  export function sectionOf(blocks: Block[], index: number): string {
125
- let sec = "";
126
- for (let i = 0; i <= index && i < blocks.length; i++) {
127
- const b = blocks[i]!;
128
- if (b.kind === "h1" || b.kind === "h2" || b.kind === "h3") sec = b.text;
370
+ let sectionTitle = "";
371
+
372
+ for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
373
+ const block = blocks[blockIndex]!;
374
+
375
+ if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
376
+ sectionTitle = block.text;
129
377
  }
130
- return sec;
378
+
379
+ return sectionTitle;
131
380
  }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { registerParticipant, registeredGithubLogin } from "./participants";
3
+ import { SCHEMA_VERSION, type Thread } from "./types";
4
+
5
+ function emptySession(): 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: [],
12
+ annotations: [],
13
+ message: null,
14
+ status: "pending",
15
+ createdAt: "2026-01-01T00:00:00.000Z",
16
+ };
17
+ }
18
+
19
+ describe("registerParticipant", () => {
20
+ test("adds a new author with a display name", () => {
21
+ // Act
22
+ const next = registerParticipant(emptySession(), "SHA256:ana", "Ana");
23
+
24
+ // Assert
25
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
26
+ });
27
+
28
+ test("records presence without a name (renders anonymous)", () => {
29
+ // Act
30
+ const next = registerParticipant(emptySession(), "SHA256:ana");
31
+
32
+ // Assert
33
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh" }]);
34
+ });
35
+
36
+ test("a nameless revisit never erases a name a past visit set", () => {
37
+ // Arrange
38
+ const named = registerParticipant(emptySession(), "SHA256:ana", "Ana");
39
+
40
+ // Act
41
+ const next = registerParticipant(named, "SHA256:ana");
42
+
43
+ // Assert
44
+ expect(next).toBe(named);
45
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
46
+ });
47
+
48
+ test("a later name updates the existing identity in place", () => {
49
+ // Arrange
50
+ const present = registerParticipant(emptySession(), "SHA256:ana");
51
+
52
+ // Act
53
+ const next = registerParticipant(present, "SHA256:ana", "Ana");
54
+
55
+ // Assert
56
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
57
+ });
58
+
59
+ test("records a verified github source with its login handle", () => {
60
+ // Act
61
+ const next = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
62
+ provider: "github",
63
+ handle: "ana",
64
+ });
65
+
66
+ // Assert
67
+ expect(next.participants).toEqual([
68
+ { id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
69
+ ]);
70
+ });
71
+
72
+ test("a later nameless write preserves the github provider and handle", () => {
73
+ // Arrange
74
+ const verified = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
75
+ provider: "github",
76
+ handle: "ana",
77
+ });
78
+
79
+ // Act
80
+ const next = registerParticipant(verified, "SHA256:ana");
81
+
82
+ // Assert
83
+ expect(next.participants).toEqual([
84
+ { id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
85
+ ]);
86
+ });
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
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The participant registry: every annotation carries an `author` id, and the
3
+ * rail resolves that id to a display name through `session.participants`. This
4
+ * registers an author in that registry so a name search on "participant" or
5
+ * "author name" lands here.
6
+ */
7
+
8
+ import type { Identity, Thread } from "./types";
9
+
10
+ /**
11
+ * Return the session with `author` present in the participant registry, setting
12
+ * the display name when one is given. A nameless call only records presence (the
13
+ * rail renders anonymous) and never erases a name a past visit set. Immutable:
14
+ * the input session is not mutated.
15
+ */
16
+ export interface ParticipantSource {
17
+ provider: "ssh" | "github";
18
+ handle?: string;
19
+ }
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
+
27
+ export function registerParticipant(
28
+ session: Thread,
29
+ author: string,
30
+ name?: string,
31
+ source?: ParticipantSource,
32
+ ): Thread {
33
+ const participants = session.participants ?? [];
34
+ const existing = participants.find((participant) => participant.id === author);
35
+ const trimmed = name?.trim();
36
+
37
+ if (existing && !trimmed && !source) return session;
38
+ const resolvedName = trimmed ?? existing?.name;
39
+ const resolvedHandle = source?.handle ?? existing?.handle;
40
+ const next: Identity = { id: author, provider: source?.provider ?? existing?.provider ?? "ssh" };
41
+
42
+ if (resolvedName) next.name = resolvedName;
43
+ if (resolvedHandle) next.handle = resolvedHandle;
44
+
45
+ return {
46
+ ...session,
47
+ participants: existing
48
+ ? participants.map((participant) => (participant.id === author ? next : participant))
49
+ : [...participants, next],
50
+ };
51
+ }