@axplusb/kepler 2.0.7 → 2.2.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.
@@ -50,6 +50,42 @@ function normalizeMessageContent(content) {
50
50
  }
51
51
  }
52
52
 
53
+ function truncateText(text, max = 1200) {
54
+ const compact = String(text || '').replace(/\s+/g, ' ').trim();
55
+ if (compact.length <= max) return compact;
56
+ return compact.slice(0, max - 3) + '...';
57
+ }
58
+
59
+ function stringifyInput(input) {
60
+ try {
61
+ return JSON.stringify(input || {});
62
+ } catch {
63
+ return String(input || {});
64
+ }
65
+ }
66
+
67
+ function entryText(entry) {
68
+ if (typeof entry.content === 'string') return entry.content;
69
+ if (!Array.isArray(entry.content)) return '';
70
+ return entry.content
71
+ .filter(block => block.type === 'text')
72
+ .map(block => block.text || '')
73
+ .filter(Boolean)
74
+ .join('\n');
75
+ }
76
+
77
+ function entryToolUses(entry) {
78
+ return Array.isArray(entry.content)
79
+ ? entry.content.filter(block => block.type === 'tool_use')
80
+ : [];
81
+ }
82
+
83
+ function entryToolResults(entry) {
84
+ return Array.isArray(entry.content)
85
+ ? entry.content.filter(block => block.type === 'tool_result')
86
+ : [];
87
+ }
88
+
53
89
  /**
54
90
  * List all session JSONL files across all projects.
55
91
  * Returns [{slug, sessionId, filePath, mtime}] sorted by mtime desc.
@@ -82,11 +118,66 @@ function findSessionFile(sessionId) {
82
118
  return listSessionFiles().find((entry) => entry.sessionId === sessionId) || null;
83
119
  }
84
120
 
121
+ function looksLikeProjectRoot(dirPath) {
122
+ return [
123
+ '.git',
124
+ 'package.json',
125
+ 'pyproject.toml',
126
+ 'setup.py',
127
+ 'go.mod',
128
+ 'Cargo.toml',
129
+ ].some(name => fs.existsSync(path.join(dirPath, name)));
130
+ }
131
+
132
+ function inferProjectRoot(rawPath) {
133
+ if (!rawPath || typeof rawPath !== 'string' || !path.isAbsolute(rawPath)) return '';
134
+ let current = rawPath;
135
+ try {
136
+ if (fs.existsSync(current) && fs.statSync(current).isFile()) {
137
+ current = path.dirname(current);
138
+ }
139
+ } catch {
140
+ current = path.dirname(current);
141
+ }
142
+ while (current && current !== path.dirname(current)) {
143
+ if (fs.existsSync(current) && looksLikeProjectRoot(current)) return current;
144
+ current = path.dirname(current);
145
+ }
146
+ return fs.existsSync(rawPath) && fs.statSync(rawPath).isDirectory() ? rawPath : '';
147
+ }
148
+
149
+ function collectAbsoluteStrings(value, out = []) {
150
+ if (typeof value === 'string') {
151
+ if (path.isAbsolute(value)) out.push(value);
152
+ return out;
153
+ }
154
+ if (Array.isArray(value)) {
155
+ for (const item of value) collectAbsoluteStrings(item, out);
156
+ return out;
157
+ }
158
+ if (value && typeof value === 'object') {
159
+ for (const item of Object.values(value)) collectAbsoluteStrings(item, out);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function collectAbsolutePathsFromText(text) {
165
+ const out = [];
166
+ const pattern = /\/(?:[^/\s'"`]+(?:[ /][^/\s'"`]+)*)/g;
167
+ for (const match of String(text || '').matchAll(pattern)) {
168
+ const raw = match[0].replace(/[),.;:]+$/, '');
169
+ if (raw && path.isAbsolute(raw)) out.push(raw);
170
+ }
171
+ return out;
172
+ }
173
+
85
174
  /**
86
175
  * Parse a session JSONL file and extract metadata.
87
176
  * Reads line-by-line (streaming) to handle large files.
88
177
  */
89
178
  async function parseSessionMeta(filePath) {
179
+ // PRD-068 §5.14.11: adds endStatus / contextTokens / costUsd / partial for
180
+ // the /resume picker columns and the context-length driven mode decision.
90
181
  const meta = {
91
182
  sessionId: null,
92
183
  project: null,
@@ -102,19 +193,34 @@ async function parseSessionMeta(filePath) {
102
193
  startTime: null,
103
194
  endTime: null,
104
195
  gitBranch: null,
196
+ // ── PRD-068 §5.14 derived fields ───────────────────────────────────
197
+ endStatus: 'unknown', // 'completed' | 'interrupted' | 'errored' | 'unknown'
198
+ contextTokens: 0, // projected transcript size when serialized
199
+ costUsd: 0, // sum of per-turn provider costs recorded in transcript
200
+ partial: false, // true if some lines failed to parse
201
+ fileBytes: 0, // raw file size (byte-based ctx fallback if no usage totals)
202
+ resumeSummary: null, // latest resume_summary marker, if the session has been checkpointed
105
203
  };
106
204
 
107
205
  const toolCounts = {};
108
206
  const modelSet = new Set();
109
207
 
208
+ // endStatus tracking
209
+ let lastMessageRole = null;
210
+ let hadError = false;
211
+ const pendingToolCalls = new Set(); // tool_use_ids awaiting a tool_result
212
+
110
213
  try {
214
+ try { meta.fileBytes = fs.statSync(filePath).size; } catch {}
215
+
111
216
  const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
112
217
  const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
113
218
 
114
219
  for await (const line of rl) {
115
220
  if (!line.trim()) continue;
116
221
  let obj;
117
- try { obj = JSON.parse(line); } catch { continue; }
222
+ try { obj = JSON.parse(line); }
223
+ catch { meta.partial = true; continue; }
118
224
 
119
225
  if (obj.sessionId && !meta.sessionId) meta.sessionId = obj.sessionId;
120
226
  if (obj.cwd && !meta.project) meta.project = obj.cwd;
@@ -126,8 +232,29 @@ async function parseSessionMeta(filePath) {
126
232
  if (!meta.endTime || ts > meta.endTime) meta.endTime = ts;
127
233
  }
128
234
 
235
+ // Backend kepler_event payloads may carry cost / error markers.
236
+ if (obj.type === 'kepler_event' && obj.event) {
237
+ const ev = obj.event;
238
+ if (ev.type === 'complete' && typeof ev.cost_usd === 'number') meta.costUsd += ev.cost_usd;
239
+ if (ev.type === 'session_info' && typeof ev.total_cost_usd === 'number') meta.costUsd = ev.total_cost_usd;
240
+ if (ev.type === 'error' || ev.error === true) hadError = true;
241
+ if (ev.type === 'resume_summary' && typeof ev.data?.summary === 'string') {
242
+ meta.resumeSummary = {
243
+ sourceMessageCount: Number(ev.data.source_message_count) || 0,
244
+ previousSourceMessageCount: Number(ev.data.previous_source_message_count) || 0,
245
+ fullMessageCount: Number(ev.data.full_message_count) || 0,
246
+ summaryChars: ev.data.summary.length,
247
+ summarySource: ev.data.summary_source || '',
248
+ mode: ev.data.mode || '',
249
+ modeLabel: ev.data.mode_label || '',
250
+ timestamp: obj.timestamp || null,
251
+ };
252
+ }
253
+ }
254
+
129
255
  if (obj.type === 'user') {
130
256
  meta.userMessages++;
257
+ lastMessageRole = 'user';
131
258
  // Capture first user prompt (string content only)
132
259
  if (!meta.firstPrompt) {
133
260
  const content = obj.message?.content;
@@ -135,10 +262,21 @@ async function parseSessionMeta(filePath) {
135
262
  meta.firstPrompt = content.slice(0, 100);
136
263
  }
137
264
  }
265
+ // A user turn may carry tool_results — clear matched pending calls
266
+ const content = obj.message?.content;
267
+ if (Array.isArray(content)) {
268
+ for (const block of content) {
269
+ if (block.type === 'tool_result' && block.tool_use_id) {
270
+ pendingToolCalls.delete(block.tool_use_id);
271
+ if (block.is_error) hadError = true;
272
+ }
273
+ }
274
+ }
138
275
  }
139
276
 
140
277
  if (obj.type === 'assistant') {
141
278
  meta.assistantMessages++;
279
+ lastMessageRole = 'assistant';
142
280
  const usage = obj.message?.usage;
143
281
  if (usage) {
144
282
  meta.inputTokens += usage.input_tokens || 0;
@@ -149,24 +287,41 @@ async function parseSessionMeta(filePath) {
149
287
  const model = obj.message?.model;
150
288
  if (model) modelSet.add(model);
151
289
 
152
- // Count tool_use blocks
290
+ // Count tool_use blocks — and track them as pending until we see the result
153
291
  const content = obj.message?.content;
154
292
  if (Array.isArray(content)) {
155
293
  for (const block of content) {
156
294
  if (block.type === 'tool_use' && block.name) {
157
295
  toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
296
+ if (block.id) pendingToolCalls.add(block.id);
158
297
  }
159
298
  }
160
299
  }
161
300
  }
162
301
  }
163
- } catch { /* file read error — return partial meta */ }
302
+ } catch { meta.partial = true; }
164
303
 
165
304
  meta.toolCalls = Object.entries(toolCounts)
166
305
  .map(([name, count]) => ({ name, count }))
167
306
  .sort((a, b) => b.count - a.count);
168
307
  meta.models = [...modelSet];
169
308
 
309
+ // Projected context size — prefer recorded usage totals if available, else
310
+ // fall back to the byte-based estimate (~4 chars/token for English).
311
+ const usageTotal = meta.inputTokens + meta.outputTokens + meta.cacheReadTokens;
312
+ meta.contextTokens = usageTotal > 0 ? usageTotal : Math.round(meta.fileBytes / 4);
313
+
314
+ // Derive endStatus from the tail of the transcript.
315
+ if (hadError) {
316
+ meta.endStatus = 'errored';
317
+ } else if (pendingToolCalls.size > 0 || lastMessageRole === 'user') {
318
+ meta.endStatus = 'interrupted';
319
+ } else if (lastMessageRole === 'assistant') {
320
+ meta.endStatus = 'completed';
321
+ } else {
322
+ meta.endStatus = 'unknown';
323
+ }
324
+
170
325
  return meta;
171
326
  }
172
327
 
@@ -182,6 +337,7 @@ export async function getRecentSessions(n = 10) {
182
337
  sessions.push({
183
338
  ...meta,
184
339
  slug: f.slug,
340
+ filePath: f.filePath,
185
341
  mtime: f.mtime,
186
342
  });
187
343
  }
@@ -192,13 +348,22 @@ export async function getRecentSessions(n = 10) {
192
348
  * Return normalized entries for a single session transcript.
193
349
  * @param {string} sessionId
194
350
  */
195
- export async function getSessionDetail(sessionId) {
196
- const file = findSessionFile(sessionId);
351
+ export async function getSessionDetail(sessionId, options = {}) {
352
+ const file = options.filePath
353
+ ? {
354
+ sessionId,
355
+ slug: path.basename(path.dirname(options.filePath)),
356
+ filePath: options.filePath,
357
+ mtime: fs.statSync(options.filePath).mtimeMs,
358
+ }
359
+ : findSessionFile(sessionId);
197
360
  if (!file) return null;
198
361
 
199
362
  const entries = [];
363
+ const replayEvents = [];
200
364
  const fileStream = fs.createReadStream(file.filePath, { encoding: 'utf-8' });
201
365
  const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
366
+ let order = 0;
202
367
 
203
368
  for await (const line of rl) {
204
369
  if (!line.trim()) continue;
@@ -208,9 +373,20 @@ export async function getSessionDetail(sessionId) {
208
373
  } catch {
209
374
  continue;
210
375
  }
376
+ const entryOrder = order++;
377
+
378
+ if (obj.type === 'kepler_event' && obj.event?.type) {
379
+ replayEvents.push({
380
+ order: entryOrder,
381
+ timestamp: obj.timestamp || null,
382
+ event: obj.event,
383
+ });
384
+ continue;
385
+ }
211
386
 
212
387
  const message = obj.message || {};
213
388
  entries.push({
389
+ order: entryOrder,
214
390
  type: obj.type || null,
215
391
  timestamp: obj.timestamp || null,
216
392
  cwd: obj.cwd || null,
@@ -231,9 +407,314 @@ export async function getSessionDetail(sessionId) {
231
407
  mtime: file.mtime,
232
408
  meta,
233
409
  entries,
410
+ replayEvents,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Convert a rich transcript into display history and backend continuity.
416
+ * Display history is intentionally richer than backend history: it includes
417
+ * tool calls/results so /history can reconstruct prior work.
418
+ *
419
+ * @param {object} detail - result from getSessionDetail()
420
+ * @param {'compact'|'full'} mode
421
+ */
422
+ export function buildResumeHistory(detail, mode = 'compact') {
423
+ if (!detail) {
424
+ return {
425
+ displayHistory: [],
426
+ agentHistory: [],
427
+ summary: '',
428
+ stats: { userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0 },
429
+ };
430
+ }
431
+
432
+ const displayHistory = [];
433
+ const fullAgentHistory = [];
434
+ const userPrompts = [];
435
+ const assistantTexts = [];
436
+ const toolCounts = new Map();
437
+ const importantResults = [];
438
+ let toolCalls = 0;
439
+ let toolResults = 0;
440
+
441
+ for (const entry of detail.entries || []) {
442
+ if (entry.role === 'user' && typeof entry.content === 'string') {
443
+ const content = entry.content;
444
+ displayHistory.push({ role: 'user', content, timestamp: entry.timestamp, order: entry.order });
445
+ fullAgentHistory.push({ role: 'user', content });
446
+ userPrompts.push(content);
447
+ continue;
448
+ }
449
+
450
+ if (entry.role === 'assistant') {
451
+ const text = entryText(entry);
452
+ const tools = entryToolUses(entry);
453
+ for (const tool of tools) {
454
+ toolCalls++;
455
+ toolCounts.set(tool.name, (toolCounts.get(tool.name) || 0) + 1);
456
+ const line = `${tool.name} ${stringifyInput(tool.input)}`;
457
+ displayHistory.push({
458
+ role: 'tool',
459
+ content: line,
460
+ timestamp: entry.timestamp,
461
+ order: entry.order,
462
+ tool: tool.name,
463
+ kind: 'call',
464
+ });
465
+ }
466
+ if (text) {
467
+ displayHistory.push({ role: 'assistant', content: text, timestamp: entry.timestamp, order: entry.order });
468
+ assistantTexts.push(text);
469
+ }
470
+
471
+ const fullContent = [
472
+ text,
473
+ ...tools.map(tool => `[tool_call] ${tool.name} ${stringifyInput(tool.input)}`),
474
+ ].filter(Boolean).join('\n\n');
475
+ if (fullContent) fullAgentHistory.push({ role: 'assistant', content: fullContent });
476
+ continue;
477
+ }
478
+
479
+ if (entry.role === 'user' && Array.isArray(entry.content)) {
480
+ const results = entryToolResults(entry);
481
+ for (const result of results) {
482
+ toolResults++;
483
+ const content = truncateText(result.content, 1200);
484
+ const label = `[tool_result] ${result.tool_use_id || 'tool'}${result.is_error ? ' (error)' : ''}: ${content}`;
485
+ displayHistory.push({
486
+ role: 'tool',
487
+ content: label,
488
+ timestamp: entry.timestamp,
489
+ order: entry.order,
490
+ tool: result.tool_use_id || 'tool',
491
+ kind: 'result',
492
+ });
493
+ fullAgentHistory.push({ role: 'user', content: label });
494
+ if (importantResults.length < 12 && content) importantResults.push(label);
495
+ }
496
+ }
497
+ }
498
+
499
+ const toolSummary = [...toolCounts.entries()]
500
+ .sort((a, b) => b[1] - a[1])
501
+ .map(([name, count]) => `${name} x${count}`)
502
+ .join(', ') || 'none recorded';
503
+ const latestUser = userPrompts[userPrompts.length - 1] || detail.meta?.firstPrompt || '';
504
+ const latestAssistant = assistantTexts[assistantTexts.length - 1] || '';
505
+ const projectRoots = getTranscriptProjectRoots(detail);
506
+ const summaryLines = [
507
+ 'Session continuity summary from the resumed local transcript.',
508
+ `Session: ${detail.sessionId}`,
509
+ detail.meta?.project ? `Project: ${detail.meta.project}` : '',
510
+ projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
511
+ detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
512
+ detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
513
+ `Prior user requests (${userPrompts.length}):`,
514
+ ...userPrompts.slice(-8).map(text => `- ${truncateText(text, 300)}`),
515
+ `Assistant progress notes (${assistantTexts.length}):`,
516
+ ...assistantTexts.slice(-6).map(text => `- ${truncateText(text, 400)}`),
517
+ `Tools used: ${toolSummary}`,
518
+ importantResults.length ? 'Important recent tool results:' : '',
519
+ ...importantResults.slice(-8).map(text => `- ${truncateText(text, 500)}`),
520
+ latestUser ? `Most recent user request: ${truncateText(latestUser, 500)}` : '',
521
+ latestAssistant ? `Most recent assistant response: ${truncateText(latestAssistant, 500)}` : '',
522
+ ].filter(Boolean);
523
+ const summary = summaryLines.join('\n');
524
+ const metadata = buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary });
525
+ const originalRequest = userPrompts[0] || detail.meta?.firstPrompt || '';
526
+ const summaryCheckpoint = latestResumeSummaryCheckpoint(detail);
527
+ const priorSummary = String(summaryCheckpoint?.data?.summary || '').trim();
528
+ const summaryCheckpointMessageCount = clampMessageCount(
529
+ summaryCheckpoint?.data?.source_message_count,
530
+ fullAgentHistory.length
531
+ );
532
+
533
+ // PRD-068 §5.14.4: resume mode picker.
534
+ // 'full' — every turn sent verbatim (unchanged)
535
+ // 'tail-N' — recap block as system prime + last N conversation messages so the
536
+ // agent has real recent conversation to reference.
537
+ // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
538
+ let agentHistory;
539
+ let sourceMessages = fullAgentHistory;
540
+ let summaryMessageIndex = -1;
541
+ let activeSummary = summary;
542
+ let summaryCoveredMessageCount = 0;
543
+ if (mode === 'full') {
544
+ agentHistory = fullAgentHistory;
545
+ summaryCoveredMessageCount = fullAgentHistory.length;
546
+ } else if (mode === 'recap+tail' || /^tail-\d+$/.test(String(mode || ''))) {
547
+ const tailTurns = tailTurnsForMode(mode, detail);
548
+ const { tail, startIndex } = tailHistorySliceByRecentMessages(fullAgentHistory, tailTurns);
549
+ const deltaStart = Math.min(summaryCheckpointMessageCount, startIndex);
550
+ sourceMessages = fullAgentHistory.slice(deltaStart, startIndex);
551
+ const deltaSummary = sourceMessages.length
552
+ ? summaryForMessages(sourceMessages, {
553
+ fallback: summary,
554
+ label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
555
+ })
556
+ : '';
557
+ activeSummary = combineResumeSummaries(priorSummary, deltaSummary)
558
+ || summaryForMessages(fullAgentHistory.slice(0, startIndex), {
559
+ fallback: summary,
560
+ label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
561
+ });
562
+ summaryCoveredMessageCount = Math.min(
563
+ fullAgentHistory.length,
564
+ Math.max(summaryCheckpointMessageCount, startIndex)
565
+ );
566
+ agentHistory = [
567
+ { role: 'user', content: metadata },
568
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
569
+ { role: 'user', content: activeSummary },
570
+ ...tail,
571
+ ];
572
+ summaryMessageIndex = 2;
573
+ } else {
574
+ // 'summary' (was 'compact' — renamed per PRD-068 §5.14.4)
575
+ sourceMessages = fullAgentHistory.slice(summaryCheckpointMessageCount);
576
+ const deltaSummary = priorSummary && sourceMessages.length
577
+ ? summaryForMessages(sourceMessages, {
578
+ fallback: summary,
579
+ label: 'New turns after the previous resume summary',
580
+ })
581
+ : '';
582
+ activeSummary = combineResumeSummaries(priorSummary, deltaSummary) || summary;
583
+ summaryCoveredMessageCount = fullAgentHistory.length;
584
+ agentHistory = [
585
+ { role: 'user', content: metadata },
586
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
587
+ { role: 'user', content: activeSummary },
588
+ ];
589
+ summaryMessageIndex = 2;
590
+ }
591
+
592
+ return {
593
+ displayHistory,
594
+ agentHistory,
595
+ sourceMessages,
596
+ summaryMessageIndex,
597
+ summary: activeSummary,
598
+ priorSummary,
599
+ summaryCheckpointMessageCount,
600
+ summaryCoveredMessageCount,
601
+ fullMessageCount: fullAgentHistory.length,
602
+ mode,
603
+ stats: {
604
+ userMessages: userPrompts.length,
605
+ assistantMessages: assistantTexts.length,
606
+ toolCalls,
607
+ toolResults,
608
+ },
234
609
  };
235
610
  }
236
611
 
612
+ export function combineResumeSummaries(priorSummary, deltaSummary) {
613
+ const prior = String(priorSummary || '').trim();
614
+ const delta = String(deltaSummary || '').trim();
615
+ if (prior && delta) {
616
+ return [
617
+ prior,
618
+ '',
619
+ 'New activity since previous summary:',
620
+ delta,
621
+ ].join('\n');
622
+ }
623
+ return prior || delta || '';
624
+ }
625
+
626
+ function buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary }) {
627
+ return [
628
+ 'Resume metadata.',
629
+ `Session: ${detail.sessionId}`,
630
+ detail.meta?.project ? `Project: ${detail.meta.project}` : '',
631
+ projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
632
+ detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
633
+ detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
634
+ `Total user turns: ${userPrompts.length}`,
635
+ `Assistant messages: ${assistantTexts.length}`,
636
+ `Tool calls/results: ${toolCalls}/${toolResults}`,
637
+ `Tools used: ${toolSummary}`,
638
+ ].filter(Boolean).join('\n');
639
+ }
640
+
641
+ function summaryForMessages(messages, { fallback, label }) {
642
+ const list = Array.isArray(messages) ? messages : [];
643
+ if (!list.length) {
644
+ return `${label}:\nNo earlier turns before the retained tail.`;
645
+ }
646
+ const lines = [label + ':'];
647
+ for (const msg of list.slice(-24)) {
648
+ const role = msg.role || 'message';
649
+ lines.push(`- ${role}: ${truncateText(msg.content, 450)}`);
650
+ }
651
+ const text = lines.join('\n');
652
+ return text.trim() || fallback;
653
+ }
654
+
655
+ function tailTurnsForMode(mode, detail) {
656
+ const match = String(mode || '').match(/^tail-(\d+)$/);
657
+ if (match) return Math.max(1, Number(match[1]) || 1);
658
+ return Number.isFinite(Number(detail?.recapTailTurns))
659
+ ? Math.max(1, Number(detail.recapTailTurns))
660
+ : 8;
661
+ }
662
+
663
+ function tailHistorySliceByRecentMessages(history, turns) {
664
+ const list = Array.isArray(history) ? history : [];
665
+ const count = Math.max(1, Number(turns) || 1);
666
+ const start = Math.max(0, list.length - count);
667
+ return { tail: list.slice(start), startIndex: start };
668
+ }
669
+
670
+ function latestResumeSummaryCheckpoint(detail) {
671
+ const events = Array.isArray(detail?.replayEvents) ? detail.replayEvents : [];
672
+ let latest = null;
673
+ for (const item of events) {
674
+ const event = item?.event;
675
+ const data = event?.data || {};
676
+ if (event?.type !== 'resume_summary' || typeof data.summary !== 'string') continue;
677
+ if (!latest || Number(item.order ?? -1) >= Number(latest.order ?? -1)) {
678
+ latest = { ...item, data };
679
+ }
680
+ }
681
+ return latest;
682
+ }
683
+
684
+ function clampMessageCount(value, max) {
685
+ const n = Number(value);
686
+ if (!Number.isFinite(n)) return 0;
687
+ return Math.max(0, Math.min(Math.floor(n), Math.max(0, Number(max) || 0)));
688
+ }
689
+
690
+ export function getTranscriptProjectRoots(detail) {
691
+ const roots = new Set();
692
+ if (detail?.meta?.project && fs.existsSync(detail.meta.project)) {
693
+ roots.add(detail.meta.project);
694
+ }
695
+
696
+ for (const entry of detail?.entries || []) {
697
+ if (typeof entry.content === 'string') {
698
+ for (const candidate of collectAbsolutePathsFromText(entry.content)) {
699
+ const root = inferProjectRoot(candidate);
700
+ if (root) roots.add(root);
701
+ }
702
+ }
703
+ for (const tool of entryToolUses(entry)) {
704
+ if (tool.name === 'get_project_overview') {
705
+ const root = inferProjectRoot(tool.input?.path || tool.input?.root || tool.input?.cwd);
706
+ if (root) roots.add(root);
707
+ }
708
+ for (const candidate of collectAbsoluteStrings(tool.input)) {
709
+ const root = inferProjectRoot(candidate);
710
+ if (root) roots.add(root);
711
+ }
712
+ }
713
+ }
714
+
715
+ return [...roots];
716
+ }
717
+
237
718
  /**
238
719
  * Aggregate stats across sessions within a date range.
239
720
  * @param {number} days — look back this many days (0 = all time)