pi-sessions 0.5.1 → 0.7.0

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.
Files changed (57) hide show
  1. package/README.md +22 -1
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +82 -134
  5. package/extensions/session-auto-title/command.ts +1 -1
  6. package/extensions/session-auto-title/controller.ts +3 -3
  7. package/extensions/session-auto-title/generate.ts +2 -2
  8. package/extensions/session-auto-title/model.ts +4 -12
  9. package/extensions/session-auto-title/retitle.ts +5 -5
  10. package/extensions/session-auto-title/state.ts +1 -1
  11. package/extensions/session-auto-title/wizard.ts +4 -4
  12. package/extensions/session-auto-title.ts +8 -8
  13. package/extensions/session-handoff/extract.ts +18 -5
  14. package/extensions/session-handoff/metadata.ts +4 -1
  15. package/extensions/session-handoff/picker.ts +52 -6
  16. package/extensions/session-handoff/query.ts +78 -62
  17. package/extensions/session-handoff/refs.ts +14 -20
  18. package/extensions/session-handoff/spawn.ts +1 -1
  19. package/extensions/session-handoff.ts +54 -35
  20. package/extensions/session-hooks.ts +3 -3
  21. package/extensions/session-index.ts +4 -4
  22. package/extensions/session-messaging/broker/process.ts +302 -0
  23. package/extensions/session-messaging/broker/spawn.ts +164 -0
  24. package/extensions/session-messaging/pi/incoming-runtime.ts +114 -0
  25. package/extensions/session-messaging/pi/message-contracts.ts +36 -0
  26. package/extensions/session-messaging/pi/message-view.ts +131 -0
  27. package/extensions/session-messaging/pi/renderer.ts +16 -0
  28. package/extensions/session-messaging/pi/service.ts +370 -0
  29. package/extensions/session-messaging/pi/tools.ts +92 -0
  30. package/extensions/session-messaging.ts +30 -0
  31. package/extensions/session-search/extract.ts +90 -440
  32. package/extensions/session-search/hooks.ts +20 -28
  33. package/extensions/session-search/normalize.ts +1 -1
  34. package/extensions/session-search/reindex.ts +3 -2
  35. package/extensions/session-search.ts +161 -132
  36. package/extensions/shared/model.ts +18 -0
  37. package/extensions/shared/session-broker/active.ts +26 -0
  38. package/extensions/shared/session-broker/client.ts +253 -0
  39. package/extensions/shared/session-broker/framing.ts +72 -0
  40. package/extensions/shared/session-broker/protocol.ts +95 -0
  41. package/extensions/shared/session-broker/socket-path.ts +30 -0
  42. package/extensions/shared/session-index/access.ts +128 -0
  43. package/extensions/shared/session-index/common.ts +46 -51
  44. package/extensions/shared/session-index/index.ts +6 -5
  45. package/extensions/shared/session-index/lineage.ts +19 -2
  46. package/extensions/shared/session-index/query/ast.ts +40 -0
  47. package/extensions/shared/session-index/query/compiler.ts +146 -0
  48. package/extensions/shared/session-index/query/lexer.ts +140 -0
  49. package/extensions/shared/session-index/query/parser.ts +178 -0
  50. package/extensions/shared/session-index/schema.ts +25 -9
  51. package/extensions/shared/session-index/scoring.ts +80 -0
  52. package/extensions/shared/session-index/search.ts +555 -281
  53. package/extensions/shared/session-index/sqlite.ts +16 -9
  54. package/extensions/shared/session-index/store.ts +14 -23
  55. package/extensions/shared/settings.ts +62 -5
  56. package/extensions/shared/text.ts +50 -0
  57. package/package.json +4 -3
@@ -0,0 +1,880 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
4
+ import {
5
+ type SessionEntry,
6
+ type SessionHeader,
7
+ SessionManager,
8
+ type SessionMessageEntry,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { contentToText, isRecord, truncateBlock, truncateInline } from "../shared/text.ts";
11
+
12
+ const MAX_TOOL_RESULT_PREVIEW_CHARS = 300;
13
+ const MAX_TOOL_CALL_ARGUMENT_PREVIEW_CHARS = 300;
14
+ const MAX_ENTRY_RENDER_CHARS = 12_000;
15
+ const MAX_SESSION_READ_RESULT_CHARS = 50 * 1024;
16
+ const MAX_AGENTS_MD_CHARS = 20_000;
17
+
18
+ export interface SessionNavigationData {
19
+ header: SessionHeader;
20
+ sessionName: string;
21
+ entries: SessionEntry[];
22
+ entryById: Map<string, SessionEntry>;
23
+ childrenByParent: Map<string | null, SessionEntry[]>;
24
+ activeLeafId: string | undefined;
25
+ branches: SessionBranch[];
26
+ spans: ConversationSpan[];
27
+ branchPaths: Map<string, SessionEntry[]>;
28
+ entryBranchesById: Map<string, SessionSearchHitBranch[]>;
29
+ spanByEntryId: Map<string, EntrySpanLocation>;
30
+ entrySizes: Map<string, number>;
31
+ }
32
+
33
+ export interface SessionBranch {
34
+ leafId: string;
35
+ startsAtEntryId: string;
36
+ label: string;
37
+ lastActivityAt: string;
38
+ entryCount: number;
39
+ isActive: boolean;
40
+ }
41
+
42
+ export interface ConversationSpan {
43
+ startsAtEntryId: string;
44
+ endsAtEntryId: string;
45
+ branchesTo: string[];
46
+ isActive: boolean;
47
+ firstUserMessage?: SessionOutlineEntry | undefined;
48
+ entryCount: number;
49
+ lastActivityAt: string;
50
+ branchSummary?: SessionOutlineEntry | undefined;
51
+ compaction?: SessionOutlineEntry | undefined;
52
+ }
53
+
54
+ export type SessionReadBodyMode = "preview" | "full";
55
+
56
+ export interface SessionReadEntry {
57
+ entryId: string;
58
+ type: string;
59
+ timestamp: string;
60
+ pathTargetEntryId: string;
61
+ body: SessionReadBodyMode;
62
+ size: number;
63
+ content: string;
64
+ truncated: boolean;
65
+ }
66
+
67
+ export interface SessionReadResult {
68
+ entries: SessionReadEntry[];
69
+ nextEntryId?: string | undefined;
70
+ pathTargetEntryId: string;
71
+ }
72
+
73
+ export interface SessionSearchHitBranch {
74
+ branchLeafId: string;
75
+ label: string;
76
+ isActive: boolean;
77
+ }
78
+
79
+ export interface SessionSearchHitSpan {
80
+ startsAtEntryId: string;
81
+ endsAtEntryId: string;
82
+ isActive: boolean;
83
+ entriesBefore: number;
84
+ entriesAfter: number;
85
+ }
86
+
87
+ interface EntrySpanLocation {
88
+ span: ConversationSpan;
89
+ pathEntries: SessionEntry[];
90
+ startIndex: number;
91
+ endIndex: number;
92
+ entryIndex: number;
93
+ }
94
+
95
+ export interface SessionMetadataSummary {
96
+ sessionId: string;
97
+ sessionName: string;
98
+ cwd: string;
99
+ startedAt: string;
100
+ modifiedAt: string;
101
+ entryCount: number;
102
+ messageCount: number;
103
+ }
104
+
105
+ export interface SessionOutlineEntry {
106
+ entryId: string;
107
+ type: string;
108
+ timestamp: string;
109
+ text: string;
110
+ }
111
+
112
+ export function loadSessionNavigationData(sessionPath: string): SessionNavigationData {
113
+ const session = SessionManager.open(sessionPath);
114
+ const header = session.getHeader();
115
+ if (!header) {
116
+ throw new Error(`Unable to parse session file: ${sessionPath}`);
117
+ }
118
+
119
+ const entries = session.getEntries().filter(hasEntryId);
120
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
121
+ const childrenByParent = groupChildrenByParent(entries);
122
+ const activeLeafId = session.getLeafId() ?? undefined;
123
+ const leafIds = getLeafIds(entries, childrenByParent, activeLeafId);
124
+ const branchPaths = new Map(leafIds.map((leafId) => [leafId, getPathToEntry(entryById, leafId)]));
125
+ const pathCounts = countBranchPathEntries(branchPaths);
126
+ const branches = leafIds.map((leafId) =>
127
+ buildBranch(branchPaths.get(leafId) ?? [], leafId, activeLeafId, pathCounts),
128
+ );
129
+ const activePathIds = new Set(
130
+ activeLeafId ? (branchPaths.get(activeLeafId) ?? []).map((entry) => entry.id) : [],
131
+ );
132
+ const spans = buildConversationSpans(childrenByParent, activePathIds);
133
+ const entryBranchesById = buildEntryBranchesById(branches, branchPaths);
134
+ const spanByEntryId = buildSpanByEntryId(spans, entryById);
135
+ const entrySizes = new Map(entries.map((entry) => [entry.id, measureEntrySize(entry)]));
136
+
137
+ return {
138
+ header,
139
+ sessionName: session.getSessionName() ?? "",
140
+ entries,
141
+ entryById,
142
+ childrenByParent,
143
+ activeLeafId,
144
+ branches,
145
+ spans,
146
+ branchPaths,
147
+ entryBranchesById,
148
+ spanByEntryId,
149
+ entrySizes,
150
+ };
151
+ }
152
+
153
+ export function buildSessionMetadata(data: SessionNavigationData): SessionMetadataSummary {
154
+ const timestamps = data.entries
155
+ .map((entry) => entry.timestamp)
156
+ .filter(Boolean)
157
+ .sort();
158
+ return {
159
+ sessionId: data.header.id,
160
+ sessionName: data.sessionName,
161
+ cwd: data.header.cwd,
162
+ startedAt: data.header.timestamp,
163
+ modifiedAt: timestamps[timestamps.length - 1] ?? data.header.timestamp,
164
+ entryCount: data.entries.length,
165
+ messageCount: data.entries.filter((entry) => entry.type === "message").length,
166
+ };
167
+ }
168
+
169
+ export function buildSessionMap(data: SessionNavigationData): string {
170
+ return `- Entry ids are short hex-like ids such as \`1a2b3c4d\`.
171
+ - Pi sessions are trees: rewinds create multiple root-to-leaf conversation branches. Spans marked \`on_active_branch\` are part of the current live path; shared trunk spans can be on the active branch while still branching to abandoned spans.
172
+ - \`branches_to\` lists the next span start ids that continue from the current span. Multiple values mean the conversation forked there.
173
+ - Start with this map for structure. Prefer \`session_search\` to locate specific terms, decisions, files, or tool activity, then use \`session_read\` to inspect full evidence around the most relevant hits.
174
+
175
+ ### Conversation Spans
176
+ ${formatConversationSpans(data.spans)}`;
177
+ }
178
+
179
+ function formatConversationSpans(spans: ConversationSpan[]): string {
180
+ if (spans.length === 0) {
181
+ return "No conversation spans found.";
182
+ }
183
+
184
+ return spans.map(formatConversationSpan).join("\n\n");
185
+ }
186
+
187
+ function formatConversationSpan(span: ConversationSpan): string {
188
+ const lines = [
189
+ `#### Span (${span.startsAtEntryId}, ${span.endsAtEntryId})${span.isActive ? " (on_active_branch)" : ""}`,
190
+ ...(span.branchesTo.length > 0 ? [`- branches_to: ${span.branchesTo.join(" | ")}`] : []),
191
+ `- entries: ${span.entryCount}`,
192
+ `- last_activity: ${span.lastActivityAt}`,
193
+ ];
194
+
195
+ if (span.firstUserMessage) {
196
+ lines.push("- first_user_message:", formatMarkdownBlock(span.firstUserMessage.text, " "));
197
+ }
198
+
199
+ if (span.branchSummary) {
200
+ lines.push("- branch_summary:", formatBlockEntry(span.branchSummary));
201
+ }
202
+
203
+ if (span.compaction) {
204
+ lines.push("- compaction:", formatBlockEntry(span.compaction));
205
+ }
206
+
207
+ return lines.join("\n");
208
+ }
209
+
210
+ function formatBlockEntry(entry: SessionOutlineEntry): string {
211
+ return [
212
+ ` entry: ${entry.entryId}`,
213
+ ` timestamp: ${entry.timestamp}`,
214
+ " content:",
215
+ formatMarkdownBlock(entry.text, " "),
216
+ ].join("\n");
217
+ }
218
+
219
+ function formatMarkdownBlock(value: string, prefix: string): string {
220
+ return [`${prefix}\`\`\`\`md`, indentBlock(value, prefix), `${prefix}\`\`\`\``].join("\n");
221
+ }
222
+
223
+ export function readProjectAgentsMd(cwd: string): string | undefined {
224
+ const agentsPath = path.join(cwd, "AGENTS.md");
225
+ if (!existsSync(agentsPath)) {
226
+ return undefined;
227
+ }
228
+
229
+ return truncateBlock(readFileSync(agentsPath, "utf8"), MAX_AGENTS_MD_CHARS).text;
230
+ }
231
+
232
+ export function readEntriesFromEntry(
233
+ data: SessionNavigationData,
234
+ params: {
235
+ entryId: string;
236
+ before?: number | undefined;
237
+ after?: number | undefined;
238
+ pathTargetEntryId?: string | undefined;
239
+ body?: SessionReadBodyMode | undefined;
240
+ },
241
+ ): SessionReadResult {
242
+ const anchor = data.entryById.get(params.entryId);
243
+ if (!anchor) {
244
+ throw new Error(`Entry not found: ${params.entryId}`);
245
+ }
246
+
247
+ if (params.before === undefined && params.after === undefined && !params.pathTargetEntryId) {
248
+ throw new Error("session_read requires before/after or pathTarget.");
249
+ }
250
+ if (params.pathTargetEntryId && (params.before !== undefined || params.after !== undefined)) {
251
+ throw new Error("session_read accepts either pathTarget or before/after, not both.");
252
+ }
253
+
254
+ const pathTargetEntryId = resolvePathTargetEntryId(data, anchor, params.pathTargetEntryId);
255
+ const pathEntries = resolvePathEntries(data, pathTargetEntryId);
256
+ const anchorIndex = pathEntries.findIndex((entry) => entry.id === anchor.id);
257
+ if (anchorIndex < 0) {
258
+ throw new Error(`Entry ${anchor.id} is not on path to ${pathTargetEntryId}.`);
259
+ }
260
+
261
+ const pathTargetIndex = pathEntries.findIndex((entry) => entry.id === pathTargetEntryId);
262
+ const readRange = resolveReadRange({
263
+ anchorIndex,
264
+ pathTargetIndex,
265
+ before: params.before,
266
+ after: params.after,
267
+ });
268
+ assertReadStaysWithinSpan(data, pathEntries, readRange, params.pathTargetEntryId);
269
+ const body = params.body ?? "preview";
270
+ const requestedEntries = pathEntries.slice(readRange.startIndex, readRange.endIndex + 1);
271
+ const page = renderReadPage(requestedEntries, pathTargetEntryId, data.entrySizes, body);
272
+ const nextEntry =
273
+ page.entries.length < requestedEntries.length
274
+ ? requestedEntries[page.entries.length]
275
+ : undefined;
276
+
277
+ return {
278
+ pathTargetEntryId,
279
+ nextEntryId: nextEntry?.id,
280
+ entries: page.entries,
281
+ };
282
+ }
283
+
284
+ export function findBranchesForEntry(
285
+ data: SessionNavigationData,
286
+ entryId: string,
287
+ ): SessionSearchHitBranch[] {
288
+ return data.entryBranchesById.get(entryId) ?? [];
289
+ }
290
+
291
+ export function findSpanForEntry(
292
+ data: SessionNavigationData,
293
+ entryId: string,
294
+ ): SessionSearchHitSpan | undefined {
295
+ const location = data.spanByEntryId.get(entryId);
296
+ if (!location) {
297
+ return undefined;
298
+ }
299
+
300
+ const entryIndexInSpan = location.entryIndex - location.startIndex;
301
+ const entryCount = location.endIndex - location.startIndex + 1;
302
+ return {
303
+ startsAtEntryId: location.span.startsAtEntryId,
304
+ endsAtEntryId: location.span.endsAtEntryId,
305
+ isActive: location.span.isActive,
306
+ entriesBefore: entryIndexInSpan,
307
+ entriesAfter: entryCount - entryIndexInSpan - 1,
308
+ };
309
+ }
310
+
311
+ function hasEntryId(entry: SessionEntry): boolean {
312
+ return typeof entry.id === "string" && entry.id.length > 0;
313
+ }
314
+
315
+ function groupChildrenByParent(entries: SessionEntry[]): Map<string | null, SessionEntry[]> {
316
+ const groups = new Map<string | null, SessionEntry[]>();
317
+ for (const entry of entries) {
318
+ const parentId = entry.parentId ?? null;
319
+ const group = groups.get(parentId) ?? [];
320
+ group.push(entry);
321
+ groups.set(parentId, group);
322
+ }
323
+
324
+ for (const group of groups.values()) {
325
+ group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
326
+ }
327
+
328
+ return groups;
329
+ }
330
+
331
+ function getLeafIds(
332
+ entries: SessionEntry[],
333
+ childrenByParent: Map<string | null, SessionEntry[]>,
334
+ activeLeafId: string | undefined,
335
+ ): string[] {
336
+ const childParentIds = new Set(
337
+ [...childrenByParent.keys()].filter((id): id is string => id !== null),
338
+ );
339
+ const leafIds = entries.filter((entry) => !childParentIds.has(entry.id)).map((entry) => entry.id);
340
+
341
+ if (activeLeafId && !leafIds.includes(activeLeafId)) {
342
+ leafIds.push(activeLeafId);
343
+ }
344
+
345
+ return leafIds;
346
+ }
347
+
348
+ function buildBranch(
349
+ pathEntries: SessionEntry[],
350
+ leafId: string,
351
+ activeLeafId: string | undefined,
352
+ pathCounts: Map<string, number>,
353
+ ): SessionBranch {
354
+ const leaf = pathEntries[pathEntries.length - 1];
355
+ const startsAt = findBranchStart(pathEntries, pathCounts) ?? leaf;
356
+ const startIndex = startsAt ? pathEntries.findIndex((entry) => entry.id === startsAt.id) : 0;
357
+ const uniqueEntries = pathEntries.slice(Math.max(0, startIndex));
358
+ return {
359
+ leafId,
360
+ startsAtEntryId: startsAt?.id ?? leafId,
361
+ label: buildBranchLabel(uniqueEntries),
362
+ lastActivityAt: leaf?.timestamp ?? "",
363
+ entryCount: pathEntries.length,
364
+ isActive: leafId === activeLeafId,
365
+ };
366
+ }
367
+
368
+ function buildBranchLabel(entries: SessionEntry[]): string {
369
+ const userMessage = findFirstUserMessage(entries);
370
+ if (userMessage && "content" in userMessage.message) {
371
+ return truncateInline(contentToText(userMessage.message.content), 80);
372
+ }
373
+
374
+ const leaf = entries[entries.length - 1];
375
+ return leaf ? `${leaf.type} ${leaf.id}` : "empty branch";
376
+ }
377
+
378
+ function buildEntryBranchesById(
379
+ branches: SessionBranch[],
380
+ branchPaths: Map<string, SessionEntry[]>,
381
+ ): Map<string, SessionSearchHitBranch[]> {
382
+ const result = new Map<string, SessionSearchHitBranch[]>();
383
+ const branchByLeafId = new Map(branches.map((branch) => [branch.leafId, branch]));
384
+
385
+ for (const [leafId, pathEntries] of branchPaths) {
386
+ const branch = branchByLeafId.get(leafId);
387
+ if (!branch) {
388
+ continue;
389
+ }
390
+ const hitBranch: SessionSearchHitBranch = {
391
+ branchLeafId: branch.leafId,
392
+ label: branch.label,
393
+ isActive: branch.isActive,
394
+ };
395
+ for (const entry of pathEntries) {
396
+ const current = result.get(entry.id) ?? [];
397
+ current.push(hitBranch);
398
+ result.set(entry.id, current);
399
+ }
400
+ }
401
+
402
+ return result;
403
+ }
404
+
405
+ function buildSpanByEntryId(
406
+ spans: ConversationSpan[],
407
+ entryById: Map<string, SessionEntry>,
408
+ ): Map<string, EntrySpanLocation> {
409
+ const result = new Map<string, EntrySpanLocation>();
410
+
411
+ for (const span of spans) {
412
+ const pathEntries = getPathToEntry(entryById, span.endsAtEntryId);
413
+ const startIndex = pathEntries.findIndex((entry) => entry.id === span.startsAtEntryId);
414
+ const endIndex = pathEntries.findIndex((entry) => entry.id === span.endsAtEntryId);
415
+ if (startIndex < 0 || endIndex < startIndex) {
416
+ continue;
417
+ }
418
+
419
+ for (let entryIndex = startIndex; entryIndex <= endIndex; entryIndex++) {
420
+ const entry = pathEntries[entryIndex];
421
+ if (!entry) {
422
+ continue;
423
+ }
424
+ result.set(entry.id, { span, pathEntries, startIndex, endIndex, entryIndex });
425
+ }
426
+ }
427
+
428
+ return result;
429
+ }
430
+
431
+ function countBranchPathEntries(branchPaths: Map<string, SessionEntry[]>): Map<string, number> {
432
+ const counts = new Map<string, number>();
433
+ for (const pathEntries of branchPaths.values()) {
434
+ for (const entry of pathEntries) {
435
+ counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1);
436
+ }
437
+ }
438
+ return counts;
439
+ }
440
+
441
+ function findBranchStart(
442
+ pathEntries: SessionEntry[],
443
+ pathCounts: Map<string, number>,
444
+ ): SessionEntry | undefined {
445
+ return pathEntries.find((entry) => (pathCounts.get(entry.id) ?? 0) === 1) ?? pathEntries[0];
446
+ }
447
+
448
+ function buildConversationSpans(
449
+ childrenByParent: Map<string | null, SessionEntry[]>,
450
+ activePathIds: Set<string>,
451
+ ): ConversationSpan[] {
452
+ return (childrenByParent.get(null) ?? []).flatMap((root) =>
453
+ getConversationRootStarts(root, childrenByParent).flatMap((start) =>
454
+ buildConversationSpansFrom(start, childrenByParent, activePathIds),
455
+ ),
456
+ );
457
+ }
458
+
459
+ function getConversationRootStarts(
460
+ root: SessionEntry,
461
+ childrenByParent: Map<string | null, SessionEntry[]>,
462
+ ): SessionEntry[] {
463
+ if (isConversationEntry(root)) {
464
+ return [root];
465
+ }
466
+
467
+ const children = childrenByParent.get(root.id) ?? [];
468
+ if (children.length === 0) {
469
+ return [];
470
+ }
471
+
472
+ return children.flatMap((child) => getConversationRootStarts(child, childrenByParent));
473
+ }
474
+
475
+ function isConversationEntry(entry: SessionEntry): boolean {
476
+ return (
477
+ entry.type === "message" ||
478
+ entry.type === "custom_message" ||
479
+ entry.type === "branch_summary" ||
480
+ entry.type === "compaction"
481
+ );
482
+ }
483
+
484
+ function buildConversationSpansFrom(
485
+ startEntry: SessionEntry,
486
+ childrenByParent: Map<string | null, SessionEntry[]>,
487
+ activePathIds: Set<string>,
488
+ ): ConversationSpan[] {
489
+ const entries = collectSpanEntries(startEntry, childrenByParent);
490
+ const endEntry = entries[entries.length - 1] ?? startEntry;
491
+ const branchesTo = getSpanBranchesTo(endEntry, childrenByParent);
492
+ const span: ConversationSpan = {
493
+ startsAtEntryId: startEntry.id,
494
+ endsAtEntryId: endEntry.id,
495
+ branchesTo: branchesTo.map((entry) => entry.id),
496
+ isActive: entries.some((entry) => activePathIds.has(entry.id)),
497
+ firstUserMessage: summarizeUserMessage(findFirstUserMessage(entries)),
498
+ entryCount: entries.length,
499
+ lastActivityAt: endEntry.timestamp,
500
+ branchSummary:
501
+ startEntry.type === "branch_summary" ? summarizeSummaryEntry(startEntry) : undefined,
502
+ compaction: startEntry.type === "compaction" ? summarizeSummaryEntry(startEntry) : undefined,
503
+ };
504
+
505
+ return [
506
+ span,
507
+ ...branchesTo.flatMap((entry) =>
508
+ buildConversationSpansFrom(entry, childrenByParent, activePathIds),
509
+ ),
510
+ ];
511
+ }
512
+
513
+ function collectSpanEntries(
514
+ startEntry: SessionEntry,
515
+ childrenByParent: Map<string | null, SessionEntry[]>,
516
+ ): SessionEntry[] {
517
+ const entries = [startEntry];
518
+ let current = startEntry;
519
+
520
+ while (true) {
521
+ const children = childrenByParent.get(current.id) ?? [];
522
+ if (children.length !== 1) {
523
+ return entries;
524
+ }
525
+
526
+ const child = children[0];
527
+ if (!child || isSpanStart(child)) {
528
+ return entries;
529
+ }
530
+
531
+ entries.push(child);
532
+ current = child;
533
+ }
534
+ }
535
+
536
+ function getSpanBranchesTo(
537
+ endEntry: SessionEntry,
538
+ childrenByParent: Map<string | null, SessionEntry[]>,
539
+ ): SessionEntry[] {
540
+ const children = childrenByParent.get(endEntry.id) ?? [];
541
+ if (children.length !== 1) {
542
+ return children;
543
+ }
544
+
545
+ const onlyChild = children[0];
546
+ return onlyChild && isSpanStart(onlyChild) ? [onlyChild] : [];
547
+ }
548
+
549
+ function isSpanStart(entry: SessionEntry): boolean {
550
+ return entry.type === "branch_summary" || entry.type === "compaction";
551
+ }
552
+
553
+ function resolvePathTargetEntryId(
554
+ data: SessionNavigationData,
555
+ anchor: SessionEntry,
556
+ pathTargetEntryId: string | undefined,
557
+ ): string {
558
+ if (pathTargetEntryId) {
559
+ if (!data.entryById.has(pathTargetEntryId)) {
560
+ throw new Error(`Path target entry not found: ${pathTargetEntryId}`);
561
+ }
562
+ return pathTargetEntryId;
563
+ }
564
+
565
+ const containingSpan = data.spanByEntryId.get(anchor.id)?.span;
566
+ if (containingSpan) {
567
+ return containingSpan.endsAtEntryId;
568
+ }
569
+
570
+ const containingBranches = data.entryBranchesById.get(anchor.id) ?? [];
571
+ const activeBranch = containingBranches.find((branch) => branch.isActive);
572
+ return activeBranch?.branchLeafId ?? containingBranches[0]?.branchLeafId ?? anchor.id;
573
+ }
574
+
575
+ interface ReadRange {
576
+ startIndex: number;
577
+ endIndex: number;
578
+ }
579
+
580
+ function resolveReadRange(params: {
581
+ anchorIndex: number;
582
+ pathTargetIndex: number;
583
+ before?: number | undefined;
584
+ after?: number | undefined;
585
+ }): ReadRange {
586
+ if (params.before === undefined && params.after === undefined) {
587
+ return {
588
+ startIndex: params.anchorIndex,
589
+ endIndex: params.pathTargetIndex,
590
+ };
591
+ }
592
+
593
+ const before = normalizeContextCount(params.before);
594
+ const after = normalizeContextCount(params.after);
595
+ return {
596
+ startIndex: Math.max(0, params.anchorIndex - before),
597
+ endIndex: Math.max(params.anchorIndex, params.anchorIndex + after),
598
+ };
599
+ }
600
+
601
+ function assertReadStaysWithinSpan(
602
+ data: SessionNavigationData,
603
+ pathEntries: SessionEntry[],
604
+ readRange: ReadRange,
605
+ explicitPathTargetEntryId: string | undefined,
606
+ ): void {
607
+ if (explicitPathTargetEntryId) {
608
+ return;
609
+ }
610
+
611
+ const anchor = pathEntries[readRange.startIndex];
612
+ const location = anchor ? data.spanByEntryId.get(anchor.id) : undefined;
613
+ if (!anchor || !location) {
614
+ return;
615
+ }
616
+
617
+ if (readRange.startIndex < location.startIndex || readRange.endIndex > location.endIndex) {
618
+ throw new Error(
619
+ `session_read context crosses span boundary from ${anchor.id}. Set pathTarget to ${location.span.endsAtEntryId} or another descendant entry id to read across spans.`,
620
+ );
621
+ }
622
+ }
623
+
624
+ function resolvePathEntries(data: SessionNavigationData, entryId: string): SessionEntry[] {
625
+ return data.branchPaths.get(entryId) ?? getPathToEntry(data.entryById, entryId);
626
+ }
627
+
628
+ function getPathToEntry(entryById: Map<string, SessionEntry>, entryId: string): SessionEntry[] {
629
+ const pathEntries: SessionEntry[] = [];
630
+ let current = entryById.get(entryId);
631
+ while (current) {
632
+ pathEntries.push(current);
633
+ current = current.parentId ? entryById.get(current.parentId) : undefined;
634
+ }
635
+ return pathEntries.reverse();
636
+ }
637
+
638
+ function normalizeContextCount(value: number | undefined): number {
639
+ if (value === undefined || !Number.isFinite(value)) {
640
+ return 0;
641
+ }
642
+ return Math.max(0, Math.min(100, Math.trunc(value)));
643
+ }
644
+
645
+ function renderReadPage(
646
+ entries: SessionEntry[],
647
+ pathTargetEntryId: string,
648
+ entrySizes: Map<string, number>,
649
+ body: SessionReadBodyMode,
650
+ ): { entries: SessionReadEntry[] } {
651
+ const renderedEntries: SessionReadEntry[] = [];
652
+ let renderedSize = 0;
653
+
654
+ for (const entry of entries) {
655
+ const renderedEntry = renderReadEntry(entry, pathTargetEntryId, entrySizes, body);
656
+ const nextSize = renderedSize + renderedEntry.content.length;
657
+ if (renderedEntries.length > 0 && nextSize > MAX_SESSION_READ_RESULT_CHARS) {
658
+ break;
659
+ }
660
+
661
+ renderedEntries.push(renderedEntry);
662
+ renderedSize = nextSize;
663
+ }
664
+
665
+ return { entries: renderedEntries };
666
+ }
667
+
668
+ function renderReadEntry(
669
+ entry: SessionEntry,
670
+ pathTargetEntryId: string,
671
+ entrySizes: Map<string, number>,
672
+ body: SessionReadBodyMode,
673
+ ): SessionReadEntry {
674
+ const rendered = renderEntryContent(entry, body);
675
+ const truncated = truncateBlock(rendered, MAX_ENTRY_RENDER_CHARS);
676
+ return {
677
+ entryId: entry.id,
678
+ type: entry.type,
679
+ timestamp: entry.timestamp,
680
+ pathTargetEntryId,
681
+ body,
682
+ size: entrySizes.get(entry.id) ?? measureEntrySize(entry),
683
+ content: truncated.text,
684
+ truncated: truncated.truncated,
685
+ };
686
+ }
687
+
688
+ function summarizeUserMessage(entry: SessionEntry | undefined): SessionOutlineEntry | undefined {
689
+ if (entry?.type !== "message" || entry.message.role !== "user") {
690
+ return undefined;
691
+ }
692
+
693
+ return {
694
+ entryId: entry.id,
695
+ type: "user",
696
+ timestamp: entry.timestamp,
697
+ text: contentToText(entry.message.content),
698
+ };
699
+ }
700
+
701
+ function summarizeSummaryEntry(entry: SessionEntry): SessionOutlineEntry | undefined {
702
+ if (entry.type !== "branch_summary" && entry.type !== "compaction") {
703
+ return undefined;
704
+ }
705
+
706
+ return {
707
+ entryId: entry.id,
708
+ type: entry.type,
709
+ timestamp: entry.timestamp,
710
+ text: String(entry.summary ?? ""),
711
+ };
712
+ }
713
+
714
+ function findFirstUserMessage(entries: SessionEntry[]): SessionMessageEntry | undefined {
715
+ return entries.find(
716
+ (entry): entry is SessionMessageEntry =>
717
+ entry.type === "message" &&
718
+ entry.message.role === "user" &&
719
+ "content" in entry.message &&
720
+ contentToText(entry.message.content).length > 0,
721
+ );
722
+ }
723
+
724
+ function renderEntryContent(entry: SessionEntry, body: SessionReadBodyMode = "full"): string {
725
+ if (entry.type === "message") {
726
+ return renderMessageEntry(entry, body);
727
+ }
728
+ if (entry.type === "custom_message") {
729
+ return `[Custom]: ${String(entry.content ?? "")}`;
730
+ }
731
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
732
+ return String(entry.summary ?? "");
733
+ }
734
+ if (entry.type === "session_info") {
735
+ return String(entry.name ?? "");
736
+ }
737
+ return JSON.stringify(entry, null, 2);
738
+ }
739
+
740
+ function renderMessageEntry(entry: SessionMessageEntry, body: SessionReadBodyMode): string {
741
+ const { message } = entry;
742
+ switch (message.role) {
743
+ case "user": {
744
+ const text = contentToText(message.content);
745
+ return text ? `[User]: ${text}` : "[User]";
746
+ }
747
+ case "assistant":
748
+ return renderAssistantMessage(message, body);
749
+ case "toolResult":
750
+ return renderToolResultMessage(message, body);
751
+ case "custom":
752
+ return `[Custom]: ${contentToText(message.content)}`;
753
+ case "bashExecution":
754
+ return `[Bash]: ${message.command}\n${message.output ?? ""}`;
755
+ default:
756
+ return `[${message.role}]`;
757
+ }
758
+ }
759
+
760
+ function renderAssistantMessage(message: AssistantMessage, body: SessionReadBodyMode): string {
761
+ const content = Array.isArray(message.content) ? message.content : [];
762
+ const textParts: string[] = [];
763
+ let hasThinkingBlock = false;
764
+ const thinkingParts: string[] = [];
765
+ const toolCalls: string[] = [];
766
+
767
+ for (const block of content) {
768
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
769
+ textParts.push(block.text);
770
+ } else if (isRecord(block) && block.type === "thinking") {
771
+ hasThinkingBlock = true;
772
+ if (typeof block.thinking === "string" && block.thinking.trim().length > 0) {
773
+ thinkingParts.push(block.thinking);
774
+ }
775
+ } else if (isRecord(block) && block.type === "toolCall") {
776
+ toolCalls.push(renderToolCall(block, body));
777
+ }
778
+ }
779
+
780
+ const parts: string[] = [];
781
+ if (hasThinkingBlock) {
782
+ parts.push(
783
+ body === "preview" || thinkingParts.length === 0
784
+ ? "[Assistant thinking]: thinking…"
785
+ : `[Assistant thinking]: ${thinkingParts.join("\n")}`,
786
+ );
787
+ }
788
+ if (textParts.length > 0) {
789
+ parts.push(`[Assistant]: ${textParts.join("\n")}`);
790
+ }
791
+ if (toolCalls.length > 0) {
792
+ parts.push(`[Assistant tool calls]:\n${toolCalls.map((call) => `- ${call}`).join("\n")}`);
793
+ }
794
+ return parts.join("\n\n");
795
+ }
796
+
797
+ function renderToolResultMessage(message: ToolResultMessage, body: SessionReadBodyMode): string {
798
+ const toolName = message.toolName ?? "unknown";
799
+ const content = contentToText(message.content);
800
+ if (body === "preview") {
801
+ return [
802
+ `[Tool result]`,
803
+ `tool: ${toolName}`,
804
+ `size: ${content.length}`,
805
+ "preview:",
806
+ truncateInline(content, MAX_TOOL_RESULT_PREVIEW_CHARS),
807
+ ].join("\n");
808
+ }
809
+
810
+ return [
811
+ `[Tool result]`,
812
+ `tool: ${toolName}`,
813
+ `size: ${content.length}`,
814
+ "content:",
815
+ content,
816
+ ].join("\n");
817
+ }
818
+
819
+ function renderToolCall(block: Record<string, unknown>, body: SessionReadBodyMode): string {
820
+ const name = typeof block.name === "string" ? block.name : "unknown";
821
+ const args = isRecord(block.arguments) ? block.arguments : {};
822
+ if (body === "full") {
823
+ return `${name}(${safeJsonStringify(args)})`;
824
+ }
825
+
826
+ if (name === "edit") {
827
+ return formatEditToolCall(args);
828
+ }
829
+ if (name === "write") {
830
+ return formatWriteToolCall(args);
831
+ }
832
+ if (name === "bash") {
833
+ return formatBashToolCall(args);
834
+ }
835
+
836
+ return `${name}(${truncateInline(safeJsonStringify(args), MAX_TOOL_CALL_ARGUMENT_PREVIEW_CHARS)})`;
837
+ }
838
+
839
+ function formatEditToolCall(args: Record<string, unknown>): string {
840
+ const edits = Array.isArray(args.edits) ? args.edits.filter(isRecord) : [];
841
+ const oldChars = edits.reduce((total, edit) => total + stringLength(edit.oldText), 0);
842
+ const newChars = edits.reduce((total, edit) => total + stringLength(edit.newText), 0);
843
+ return `edit(path=${safeJsonStringify(args.path)}, edits=${edits.length}, oldChars=${oldChars}, newChars=${newChars})`;
844
+ }
845
+
846
+ function formatWriteToolCall(args: Record<string, unknown>): string {
847
+ return `write(path=${safeJsonStringify(args.path)}, contentChars=${stringLength(args.content)})`;
848
+ }
849
+
850
+ function formatBashToolCall(args: Record<string, unknown>): string {
851
+ const command =
852
+ typeof args.command === "string"
853
+ ? truncateInline(args.command, MAX_TOOL_CALL_ARGUMENT_PREVIEW_CHARS)
854
+ : "";
855
+ const timeout = typeof args.timeout === "number" ? `, timeout=${args.timeout}` : "";
856
+ return `bash(command=${safeJsonStringify(command)}${timeout})`;
857
+ }
858
+
859
+ function safeJsonStringify(value: unknown): string {
860
+ try {
861
+ return JSON.stringify(value);
862
+ } catch {
863
+ return String(value);
864
+ }
865
+ }
866
+
867
+ function stringLength(value: unknown): number {
868
+ return typeof value === "string" ? value.length : 0;
869
+ }
870
+
871
+ function measureEntrySize(entry: SessionEntry): number {
872
+ return renderEntryContent(entry, "full").length;
873
+ }
874
+
875
+ function indentBlock(value: string, prefix: string): string {
876
+ return value
877
+ .split(/\r?\n/)
878
+ .map((line) => `${prefix}${line}`)
879
+ .join("\n");
880
+ }