@noesis-brain/mcp-server 2.1.5 → 2.5.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 (49) hide show
  1. package/README.md +10 -1
  2. package/dist/agent/identity.d.ts +33 -0
  3. package/dist/agent/identity.d.ts.map +1 -0
  4. package/dist/agent/identity.js +61 -0
  5. package/dist/agent/identity.js.map +1 -0
  6. package/dist/agent/runner.d.ts +103 -6
  7. package/dist/agent/runner.d.ts.map +1 -1
  8. package/dist/agent/runner.js +375 -64
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/tokenState.d.ts +81 -0
  11. package/dist/agent/tokenState.d.ts.map +1 -0
  12. package/dist/agent/tokenState.js +173 -0
  13. package/dist/agent/tokenState.js.map +1 -0
  14. package/dist/agent/webGuard.d.ts +48 -0
  15. package/dist/agent/webGuard.d.ts.map +1 -0
  16. package/dist/agent/webGuard.js +173 -0
  17. package/dist/agent/webGuard.js.map +1 -0
  18. package/dist/api/NoesisClient.d.ts +19 -1
  19. package/dist/api/NoesisClient.d.ts.map +1 -1
  20. package/dist/api/NoesisClient.js +55 -3
  21. package/dist/api/NoesisClient.js.map +1 -1
  22. package/dist/cli/setup.js +1 -1
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/machineLabel.d.ts +16 -0
  25. package/dist/machineLabel.d.ts.map +1 -0
  26. package/dist/machineLabel.js +43 -0
  27. package/dist/machineLabel.js.map +1 -0
  28. package/dist/tools/backlog.d.ts +21 -0
  29. package/dist/tools/backlog.d.ts.map +1 -1
  30. package/dist/tools/backlog.js +123 -1
  31. package/dist/tools/backlog.js.map +1 -1
  32. package/dist/tools/claudeSessions.d.ts +83 -0
  33. package/dist/tools/claudeSessions.d.ts.map +1 -0
  34. package/dist/tools/claudeSessions.js +330 -0
  35. package/dist/tools/claudeSessions.js.map +1 -0
  36. package/dist/tools/index.d.ts +1 -6
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +166 -37
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/sessionIndex.d.ts +66 -0
  41. package/dist/tools/sessionIndex.d.ts.map +1 -0
  42. package/dist/tools/sessionIndex.js +241 -0
  43. package/dist/tools/sessionIndex.js.map +1 -0
  44. package/package.json +1 -1
  45. package/scripts/noesis-capture-watcher.mjs +722 -107
  46. package/scripts/start-agent.sh +51 -12
  47. package/skill-templates/noesis-refine-relations.md +163 -0
  48. package/skill-templates/noesis-ticket.md +79 -0
  49. package/templates/claude-md-block.md +7 -0
@@ -107,7 +107,9 @@ const stripImageRefs = (s) =>
107
107
  // Neutralize bare HTML tags so literal "<details>", "<summary>", "<system-reminder>"
108
108
  // etc. in transcript data don't get interpreted as HTML by the Markdown renderer.
109
109
  // Only for PLAIN-TEXT data fields (never inside code spans / fenced blocks).
110
- const esc = (s) => String(s == null ? '' : s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
110
+ // Also escapes `&` (so a literal `&lt;` stays literal) and `\` (so a Windows path
111
+ // like `tools\<script>` or `C:\_build` keeps its backslashes).
112
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\\/g, '\\\\');
111
113
 
112
114
  function pad2(n) { return String(n).padStart(2, '0'); }
113
115
  function localStamp(d) {
@@ -141,11 +143,19 @@ function findById(id) {
141
143
  }
142
144
  // Map the current working directory to its Claude Code project dir and return
143
145
  // the most-recently-modified transcript's session id (i.e. THIS session). Mirrors
144
- // Claude Code's cwd->project-dir encoding (":" "\" "/" "_" all become "-"). Used
145
- // by --print-self so the /noesis-capture skill can pass --self and never capture
146
- // its own controller session.
146
+ const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
147
+ // Claude Code's cwd->project-dir encoding: EVERY character outside [A-Za-z0-9-] becomes
148
+ // "-". The old form listed only [:\/_], which silently mis-encoded any path containing a
149
+ // dot or a space (e.g. ~/.noesis-agent). Used by --print-self so the /noesis-capture skill
150
+ // can pass --self and never capture its own controller session.
151
+ //
152
+ // CLAUDE_CODE_SESSION_ID wins when present: Claude Code injects it into every MCP server
153
+ // and hook it spawns, and it is exact. The directory scan is a fallback, and it is a GUESS --
154
+ // "newest transcript" can pick a concurrent session running in another repo.
147
155
  function selfSessionId() {
148
- const name = process.cwd().replace(/[:\\/_]/g, '-');
156
+ const fromEnv = (process.env.CLAUDE_CODE_SESSION_ID || '').trim();
157
+ if (SESSION_ID_RE.test(fromEnv)) return fromEnv;
158
+ const name = process.cwd().replace(/[^A-Za-z0-9-]/g, '-');
149
159
  let d = path.join(PROJECTS, name);
150
160
  if (!fs.existsSync(d)) {
151
161
  const fc = name[0] || '';
@@ -159,14 +169,18 @@ function selfSessionId() {
159
169
  const newest = files.reduce((a, b) => (statMtime(a) >= statMtime(b) ? a : b));
160
170
  return path.basename(newest).replace(/\.jsonl$/i, '');
161
171
  }
162
- // All top-level session transcripts (depth 1) — excludes subagent/workflow files.
172
+ // All top-level session transcripts (depth 1). TWO filters, both load-bearing: depth 1
173
+ // skips the newer <session-id>/subagents/ layout, and the UUID test drops the LEGACY
174
+ // agent-<hex>.jsonl subagent files, which sit as SIBLINGS of real sessions (~1,500 on a
175
+ // working machine) and were previously all returned as sessions with a bogus sessionId
176
+ // like "agent-a0065ea" — which is what made resolveByName's fallback scan mostly waste.
163
177
  function topLevelTranscripts() {
164
178
  const out = [];
165
179
  for (const dir of projectDirs()) {
166
180
  let ents;
167
181
  try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
168
182
  for (const e of ents) {
169
- if (e.isFile() && e.name.endsWith('.jsonl')) {
183
+ if (e.isFile() && e.name.endsWith('.jsonl') && SESSION_ID_RE.test(e.name.slice(0, -6))) {
170
184
  const p = path.join(dir, e.name);
171
185
  out.push({ path: p, sessionId: e.name.slice(0, -6), mtime: statMtime(p) });
172
186
  }
@@ -270,6 +284,24 @@ function resolve(sessionArg) {
270
284
  }
271
285
 
272
286
  // ---------------------------------------------------------------- conversion
287
+ //
288
+ // Note layout. The Noesis Contents panel nests its outline strictly by `#` level
289
+ // (md-manager src/frontend/src/components/TableOfContents.tsx, extractHeaders), so
290
+ // the heading levels below ARE the note's structure:
291
+ //
292
+ // # <ai-title> — Capture directly under the frontmatter, so Noesis strips it
293
+ // ## N. <human message> one per message the human wrote: the first TOC level
294
+ // ### Working process round 1 of the reply: narration + tool steps
295
+ // ### Answer round 1's closing text
296
+ // ### Follow-up K — <trigger> a later round of the same request (a background task
297
+ // #### Working process finished, a scheduled wakeup fired, the usage limit
298
+ // #### Answer reset, …)
299
+ //
300
+ // Only human messages open a section. Everything else in the transcript — skill
301
+ // bodies, caveats, local commands, queue bookkeeping, task notifications, wakeups,
302
+ // rewound branches — is folded into a round or dropped. Claude's own headings are
303
+ // flattened (narration, plans) or shifted below their Answer heading, so they can
304
+ // never outrank the outline above.
273
305
 
274
306
  function buildResultMap(entries) {
275
307
  const map = new Map();
@@ -284,6 +316,19 @@ function buildResultMap(entries) {
284
316
  return map;
285
317
  }
286
318
 
319
+ function buildToolNameMap(entries) {
320
+ const map = new Map();
321
+ for (const e of entries) {
322
+ const content = e.message?.content;
323
+ if (e.type === 'assistant' && Array.isArray(content)) {
324
+ for (const b of content) {
325
+ if (b && b.type === 'tool_use' && b.id) map.set(b.id, b.name);
326
+ }
327
+ }
328
+ }
329
+ return map;
330
+ }
331
+
287
332
  function extractMeta(entries, sessionId) {
288
333
  let title = null, cwd = null, gitBranch = null, firstTs = null;
289
334
  for (const e of entries) {
@@ -322,21 +367,11 @@ function frontmatter(meta) {
322
367
 
323
368
  function resultText(res) {
324
369
  const c = res?.content;
325
- if (typeof c === 'string') return stripNulBytes(stripCmdNoise(stripAnsi(c)));
326
- if (Array.isArray(c)) return stripNulBytes(stripCmdNoise(stripAnsi(c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' '))));
370
+ if (typeof c === 'string') return stripCmdNoise(stripAnsi(stripNulBytes(c)));
371
+ if (Array.isArray(c)) return stripCmdNoise(stripAnsi(stripNulBytes(c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' '))));
327
372
  return '';
328
373
  }
329
374
 
330
- function firstWords(s, n) {
331
- const words = oneLine(s).replace(/[#>*`_~|]/g, '').split(' ').filter(Boolean);
332
- const head = words.slice(0, n).join(' ');
333
- return words.length > n ? `${head}…` : head;
334
- }
335
-
336
- function blockquote(text) {
337
- return text.split('\n').map((l) => (l.length ? `> ${l}` : '>')).join('\n');
338
- }
339
-
340
375
  // Returns the exact CommonMark closing fence needed to balance `text` if it ends
341
376
  // inside an open fenced code block (``` or ~~~), or '' if none is open. Mirrors
342
377
  // the fence-parity rules in frontend/src/utils/codeFences.ts.
@@ -377,25 +412,60 @@ function truncateSafely(text, maxLen) {
377
412
  return closer ? `${cut}\n${closer}` : cut;
378
413
  }
379
414
 
415
+ function closeOpenFence(text) {
416
+ const closer = unterminatedFenceCloser(text);
417
+ return closer ? `${text}\n${closer}` : text;
418
+ }
419
+
420
+ // Markdown syntax is stripped so a heading reads as plain text; `<` and `>` are
421
+ // kept (as a pair) and escaped by esc() where the words are used.
422
+ function firstWords(s, n) {
423
+ const words = oneLine(s).replace(/[#*`_~|]/g, '').split(' ').filter(Boolean);
424
+ const head = words.slice(0, n).join(' ');
425
+ return words.length > n ? `${head}…` : head;
426
+ }
427
+
428
+ function blockquote(text) {
429
+ return text.split('\n').map((l) => (l.length ? `> ${l}` : '>')).join('\n');
430
+ }
431
+
432
+ // Transcript text that no human wrote: harness reminders, background-task
433
+ // notifications, slash-command plumbing (a prompt command's <command-name> /
434
+ // <command-args> is parsed before this check), local-command caveats and output,
435
+ // shell-mode output, memory-mode input and MCP resource pushes.
436
+ const INJECTION_PREFIXES = [
437
+ '<system-reminder', '[SYSTEM NOTIFICATION', '<task-notification', '<command-', '<local-command-',
438
+ '<bash-stdout', '<bash-stderr', '<user-memory-input', '<mcp-resource-update', '<mcp-polling-update',
439
+ 'Caveat:',
440
+ ];
380
441
  function isSystemInjection(text) {
381
- const t = text.trimStart();
382
- return t.startsWith('<system-reminder') || t.startsWith('[SYSTEM NOTIFICATION') ||
383
- t.startsWith('<task-notification') || t.startsWith('<command-') ||
384
- t.startsWith('<local-command-stdout') || t.startsWith('Caveat:');
442
+ const t = String(text == null ? '' : text).trimStart();
443
+ return INJECTION_PREFIXES.some((p) => t.startsWith(p));
444
+ }
445
+
446
+ // Inline code that survives backticks inside it — PowerShell's escape character, or
447
+ // a shell `$(…)` written with backticks: the fence is one backtick longer than the
448
+ // longest run inside, padded with a space when the text starts or ends with one.
449
+ function codeSpan(text) {
450
+ const s = String(text == null ? '' : text);
451
+ if (!s) return '';
452
+ const fence = '`'.repeat(Math.max(0, ...(s.match(/`+/g) || []).map((run) => run.length)) + 1);
453
+ const pad = /^`|`$/.test(s) || /^ [\s\S]* $/.test(s) ? ' ' : '';
454
+ return `${fence}${pad}${s}${pad}${fence}`;
385
455
  }
386
456
 
387
457
  function summarizeToolUse(name, input) {
388
458
  input = input || {};
389
459
  const t = (s, n = 160) => oneLine(s).slice(0, n);
390
460
  switch (name) {
391
- case 'Read': return `Read \`${input.file_path || ''}\``;
461
+ case 'Read': return `Read ${codeSpan(input.file_path)}`;
392
462
  case 'Edit': case 'Write': case 'MultiEdit':
393
- return `${name} \`${input.file_path || ''}\``;
394
- case 'NotebookEdit': return `NotebookEdit \`${input.notebook_path || ''}\``;
395
- case 'Bash': return `Bash: \`${t(input.command || '', 160)}\``;
396
- case 'PowerShell': return `PowerShell: \`${t(input.command || '', 160)}\``;
397
- case 'Glob': return `Glob \`${input.pattern || ''}\``;
398
- case 'Grep': return `Grep \`${input.pattern || ''}\``;
463
+ return `${name} ${codeSpan(input.file_path)}`;
464
+ case 'NotebookEdit': return `NotebookEdit ${codeSpan(input.notebook_path)}`;
465
+ case 'Bash': return `Bash: ${codeSpan(t(input.command || '', 160))}`;
466
+ case 'PowerShell': return `PowerShell: ${codeSpan(t(input.command || '', 160))}`;
467
+ case 'Glob': return `Glob ${codeSpan(input.pattern)}`;
468
+ case 'Grep': return `Grep ${codeSpan(input.pattern)}`;
399
469
  case 'Task': case 'Agent':
400
470
  return `Agent (${input.subagent_type || 'agent'}): ${t(input.description || '')}`;
401
471
  case 'WebFetch': return `WebFetch ${input.url || ''}`;
@@ -405,7 +475,7 @@ function summarizeToolUse(name, input) {
405
475
  const keys = Object.keys(input);
406
476
  if (!keys.length) return name;
407
477
  // Wrap raw JSON in a code span so any `<` inside is HTML-safe and reads as code.
408
- return `${name}: \`${t(JSON.stringify(input), 120)}\``;
478
+ return `${name}: ${codeSpan(t(JSON.stringify(input), 120))}`;
409
479
  }
410
480
  }
411
481
  }
@@ -427,12 +497,37 @@ function parseAnswers(res) {
427
497
  return map;
428
498
  }
429
499
 
430
- function renderDecisionBlock(toolUse, resultMap) {
500
+ // A tool result the human produced by declining the call. `feedback` is what they
501
+ // typed at the permission prompt — their own words, so it opens a new section.
502
+ const REJECTION_PREFIX = "The user doesn't want to proceed with this tool use";
503
+ function parseRejection(res) {
504
+ if (!res || !res.is_error) return null;
505
+ const txt = resultText(res);
506
+ if (!txt.startsWith(REJECTION_PREFIX)) return null;
507
+ const at = txt.indexOf('the user said:');
508
+ if (at < 0) return { feedback: null };
509
+ const feedback = txt.slice(at + 'the user said:'.length)
510
+ .replace(/\n+Note: The user's next message may contain a correction or preference\.[\s\S]*$/, '')
511
+ .trim();
512
+ return { feedback: feedback && feedback !== 'Denied by user' ? feedback : null };
513
+ }
514
+
515
+ function rejectedToolLabel(name) {
516
+ if (name === 'ExitPlanMode') return 'plan';
517
+ if (name === 'AskUserQuestion') return 'question';
518
+ if (name === 'Edit' || name === 'Write' || name === 'MultiEdit' || name === 'NotebookEdit') return 'edit';
519
+ if (name === 'Bash' || name === 'PowerShell') return 'command';
520
+ return name ? `${name} call` : 'tool call';
521
+ }
522
+
523
+ function renderDecisionBlock(toolUse, resultMap, level) {
431
524
  const qs = Array.isArray(toolUse.input?.questions) ? toolUse.input.questions : [];
432
- const answers = parseAnswers(resultMap.get(toolUse.id));
525
+ const res = resultMap.get(toolUse.id);
526
+ const rejection = parseRejection(res);
527
+ const answers = parseAnswers(res);
433
528
  const lines = [];
434
529
  for (const q of qs) {
435
- lines.push(`#### Decision — ${esc(oneLine(q.header)) || 'Question'}`);
530
+ lines.push(`${'#'.repeat(level)} Decision — ${esc(oneLine(q.header)) || 'Question'}`);
436
531
  lines.push(`> ${esc(oneLine(q.question))}`);
437
532
  const ans = answers.get(q.question);
438
533
  const opts = Array.isArray(q.options) ? q.options : [];
@@ -443,9 +538,12 @@ function renderDecisionBlock(toolUse, resultMap) {
443
538
  const desc = opt.description ? ` — ${esc(oneLine(opt.description))}` : '';
444
539
  lines.push(`- ${label}${desc}${chosen ? ' **(chosen)**' : ''}`);
445
540
  }
541
+ lines.push(''); // else the Chosen line continues the last option's list item
446
542
  if (ans != null) {
447
543
  const custom = !opts.some(isChosen);
448
544
  lines.push(`**Chosen:** ${esc(ans)}${custom ? ' _(custom)_' : ''}`);
545
+ } else if (rejection) {
546
+ lines.push(`**Chosen:** _(declined${rejection.feedback ? ' — see the next message' : ''})_`);
449
547
  } else {
450
548
  lines.push(`**Chosen:** _(pending)_`);
451
549
  }
@@ -454,98 +552,615 @@ function renderDecisionBlock(toolUse, resultMap) {
454
552
  return lines.join('\n');
455
553
  }
456
554
 
457
- function renderPlanBlock(toolUse, resultMap) {
555
+ // Calls fn(line, prev) for every line OUTSIDE fenced code (``` / ~~~, the rules of
556
+ // unterminatedFenceCloser) and returns the rebuilt text; fenced lines pass through
557
+ // untouched. fn may return an array to replace one line with several.
558
+ function mapOutsideFences(text, fn) {
559
+ const out = [];
560
+ let openChar = '';
561
+ let openLen = 0;
562
+ let prev = '';
563
+ for (const line of String(text).split('\n')) {
564
+ const m = /^(`{3,}|~{3,})(.*)$/.exec(line.trim());
565
+ let fenced = openChar !== '';
566
+ if (m && !openChar) {
567
+ openChar = m[1][0];
568
+ openLen = m[1].length;
569
+ fenced = true;
570
+ } else if (m && m[1][0] === openChar && m[1].length >= openLen && m[2].trim() === '') {
571
+ openChar = '';
572
+ openLen = 0;
573
+ }
574
+ if (fenced) {
575
+ out.push(line);
576
+ } else {
577
+ const r = fn(line, prev);
578
+ if (Array.isArray(r)) out.push(...r);
579
+ else out.push(r);
580
+ }
581
+ prev = line;
582
+ }
583
+ return out.join('\n');
584
+ }
585
+
586
+ const ATX_HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
587
+
588
+ // Narration and plan bodies: headings become bold text, so the outline keeps only
589
+ // the sections this renderer creates. Each stays its own paragraph — Claude writes
590
+ // text right under a heading, and without the blank lines that text would run into
591
+ // the bold line.
592
+ function flattenHeadings(text) {
593
+ return mapOutsideFences(text, (line) => {
594
+ const m = ATX_HEADING_RE.exec(line);
595
+ return m ? ['', `**${m[2].replace(/\*\*/g, '')}**`, ''] : line;
596
+ });
597
+ }
598
+
599
+ function minHeadingLevel(text) {
600
+ let min = 7;
601
+ mapOutsideFences(text, (line) => {
602
+ const m = ATX_HEADING_RE.exec(line);
603
+ if (m) min = Math.min(min, m[1].length);
604
+ return line;
605
+ });
606
+ return min;
607
+ }
608
+
609
+ // Answer text keeps its own structure, one level below its Answer heading.
610
+ function shiftHeadings(text, delta) {
611
+ if (!delta) return text;
612
+ return mapOutsideFences(text, (line) => {
613
+ const m = ATX_HEADING_RE.exec(line);
614
+ return m ? `${'#'.repeat(Math.min(6, Math.max(1, m[1].length + delta)))} ${m[2]}` : line;
615
+ });
616
+ }
617
+
618
+ // Claude's markdown, made safe for the outline:
619
+ // - a `---` / `***` / `___` rule becomes <hr>: the Contents panel lists bare rules as
620
+ // divider rows, and `---` right under a line of text is a setext H2;
621
+ // - a `===` underline is detached from the text above it (a setext H1);
622
+ // - a line opening with <details>, <summary> or <hN> is escaped: the panel reads those
623
+ // as collapsibles and headings, and an unbalanced one re-levels everything after it.
624
+ const RULE_RE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})[ \t]*$/;
625
+ const SETEXT_H1_RE = /^ {0,3}=+[ \t]*$/;
626
+ const OUTLINE_TAG_RE = /^<\/?(?:details|summary|h[1-6])\b/i;
627
+ function neutralizeOutline(text) {
628
+ return mapOutsideFences(text, (line, prev) => {
629
+ if (RULE_RE.test(line)) return ['', '<hr>', ''];
630
+ if (SETEXT_H1_RE.test(line) && prev.trim()) return ['', line];
631
+ if (OUTLINE_TAG_RE.test(line.trim())) return line.replace(/</g, '&lt;').replace(/>/g, '&gt;');
632
+ return line;
633
+ });
634
+ }
635
+
636
+ // The owner's English-coaching block (their global CLAUDE.md), which Claude prints
637
+ // at the start or end of a reply: a ─── divider, a quote of the message, a `Better:`
638
+ // rewrite and up to two bullets. It coaches the human message, so it is lifted to
639
+ // sit under that message, minus the divider and the repeated quote. Parsed line by
640
+ // line: the first line that does not fit ends the block, and the text around it
641
+ // stays as it was. A block with nothing to refine (a parenthetical placeholder) is dropped.
642
+ const REFINEMENT_DIVIDER_RE = /^─{10,}[ \t]+English refinement[ \t]*$/;
643
+ function extractRefinements(text) {
644
+ const lines = String(text).split('\n');
645
+ const keep = [];
646
+ const blocks = [];
647
+ let openChar = '';
648
+ let openLen = 0;
649
+ for (let i = 0; i < lines.length; i++) {
650
+ const t = lines[i].trim();
651
+ const f = /^(`{3,}|~{3,})(.*)$/.exec(t);
652
+ if (f && !openChar) {
653
+ openChar = f[1][0];
654
+ openLen = f[1].length;
655
+ } else if (f && f[1][0] === openChar && f[1].length >= openLen && f[2].trim() === '') {
656
+ openChar = '';
657
+ openLen = 0;
658
+ }
659
+ if (f || openChar || !REFINEMENT_DIVIDER_RE.test(t)) {
660
+ keep.push(lines[i]);
661
+ continue;
662
+ }
663
+ let j = i + 1;
664
+ while (j < lines.length && !lines[j].trim()) j++;
665
+ while (j < lines.length && lines[j].trimStart().startsWith('>')) j++;
666
+ while (j < lines.length && !lines[j].trim()) j++;
667
+ const next = j < lines.length ? lines[j].trim() : '';
668
+ if (next.startsWith('`Better:')) {
669
+ const block = { better: next, bullets: [] };
670
+ let k = j + 1;
671
+ while (k < lines.length && !lines[k].trim()) k++;
672
+ while (k < lines.length && block.bullets.length < 2 && /^- ".*" -> "/.test(lines[k].trim())) {
673
+ block.bullets.push(lines[k].trim());
674
+ k++;
675
+ }
676
+ blocks.push(block);
677
+ i = (block.bullets.length ? k : j + 1) - 1;
678
+ } else if (/^[*_]?\(.*\)[*_]?$/.test(next)) {
679
+ i = j; // a placeholder: *(No new user message this turn…)*, (slash command — no prose to refine)
680
+ } else {
681
+ keep.push(lines[i]);
682
+ }
683
+ }
684
+ return { text: keep.join('\n').replace(/\n{3,}/g, '\n\n').trim(), blocks };
685
+ }
686
+
687
+ // Applies fn to the parts of one line that are NOT inline code spans (a backtick
688
+ // run closed by a run of the same length); the spans themselves pass through.
689
+ function mapOutsideCodeSpans(line, fn) {
690
+ let out = '';
691
+ let i = 0;
692
+ while (i < line.length) {
693
+ const open = line.indexOf('`', i);
694
+ if (open < 0) { out += fn(line.slice(i)); break; }
695
+ out += fn(line.slice(i, open));
696
+ let run = 1;
697
+ while (line[open + run] === '`') run++;
698
+ let close = -1;
699
+ for (let k = line.indexOf('`', open + run); k >= 0; ) {
700
+ let r = 1;
701
+ while (line[k + r] === '`') r++;
702
+ if (r === run) { close = k; break; }
703
+ k = line.indexOf('`', k + r);
704
+ }
705
+ if (close < 0) { out += line.slice(open, open + run); i = open + run; continue; }
706
+ out += line.slice(open, close + run);
707
+ i = close + run;
708
+ }
709
+ return out;
710
+ }
711
+
712
+ // A `</details>` in a plan's prose would close the plan's collapsible early, so it
713
+ // is escaped there. Code spans and fenced code render as text and are left alone —
714
+ // escaping them would show `&lt;/details&gt;` to the reader.
715
+ function escapeClosingDetails(text) {
716
+ return mapOutsideFences(text, (line) => mapOutsideCodeSpans(line, (s) => s.replace(/<\/details>/gi, '&lt;/details&gt;')));
717
+ }
718
+
719
+ // The plan as finally decided: the approved text when the human approved it (it
720
+ // carries any edits they made in the approval dialog), else the plan as submitted.
721
+ function planOutcome(toolUse, resultMap) {
722
+ const submitted = String(toolUse.input?.plan || '');
458
723
  const res = resultMap.get(toolUse.id);
459
- const txt = res ? resultText(res) : '';
460
- const lines = ['- **Plan submitted for approval (ExitPlanMode)**'];
461
- if (txt && txt.trim()) {
462
- const body = txt
463
- .replace(/<\/details>/gi, '&lt;/details&gt;') // never let an inner tag close our wrapper
464
- .replace(/^(#{1,6})\s+(.*)$/gm, '**$2**'); // demote headings so they don't pollute the note outline
465
- const capped = truncateSafely(body, 12000);
466
- lines.push('', '<details>', '<summary>Plan / approval</summary>', '', capped, '', '</details>', '');
467
- } else {
468
- lines.push(' - ↳ _(awaiting approval)_');
724
+ if (!res) return { status: 'awaiting approval', body: submitted };
725
+ if (parseRejection(res)) return { status: 'rejected', body: submitted };
726
+ const txt = resultText(res);
727
+ if (!txt.startsWith('User has approved your plan')) {
728
+ return { status: res.is_error ? 'failed' : 'submitted', body: submitted || txt };
469
729
  }
730
+ const m = /## Approved Plan( \(edited by user\))?:\n([\s\S]*)$/.exec(txt);
731
+ return { status: m && m[1] ? 'approved (edited by the user)' : 'approved', body: m ? m[2] : submitted };
732
+ }
733
+
734
+ // Collapsible as before, but opened by a heading that repeats the summary text:
735
+ // Noesis then lists that heading (not the summary) in the outline at its own
736
+ // level and moves it into the summary bar. Its renderer looks only two lines past
737
+ // <summary> for the heading, hence exactly one blank line between them.
738
+ function renderPlanBlock(toolUse, resultMap, level) {
739
+ const { status, body } = planOutcome(toolUse, resultMap);
740
+ const title = `Plan — ${status}`;
741
+ const clean = escapeClosingDetails(flattenHeadings(neutralizeOutline(lf(body).trim())));
742
+ return [
743
+ '<details>', `<summary>${title}</summary>`, '', `${'#'.repeat(level)} ${title}`, '',
744
+ clean ? truncateSafely(clean, 12000) : '_(no plan text)_', '', '</details>',
745
+ ].join('\n');
746
+ }
747
+
748
+ function renderStep(toolUse, resultMap) {
749
+ const res = resultMap.get(toolUse.id);
750
+ const lines = [`- **${toolUse.name}** — ${summarizeToolUse(toolUse.name, toolUse.input)}`];
751
+ const rejection = parseRejection(res);
752
+ if (rejection) {
753
+ lines.push(` - ↳ rejected by the user${rejection.feedback ? ' — see the next message' : ''}`);
754
+ return lines.join('\n');
755
+ }
756
+ // Echo-result tools: the tool-use line already names the target, so the
757
+ // success result is noise — a cat -n file preview for Read, a "… updated
758
+ // successfully (file state is current …)" confirmation for the writers.
759
+ // Drop it on success; keep it on error (e.g. "File does not exist",
760
+ // "String to replace not found"). Outcome-bearing tools (Bash/PowerShell,
761
+ // Grep, Glob, Web*) keep their result — that result IS the point of the step.
762
+ const echoTool = toolUse.name === 'Read' || toolUse.name === 'Edit' || toolUse.name === 'Write'
763
+ || toolUse.name === 'MultiEdit' || toolUse.name === 'NotebookEdit';
764
+ const r = summarizeToolResult(res);
765
+ if (r && !(echoTool && !res?.is_error)) lines.push(` - ↳ ${r}`);
470
766
  return lines.join('\n');
471
767
  }
472
768
 
473
- function renderUserPrompt(turn, text, entry, queued = false) {
474
- const clean = stripImageRefs(text);
475
- const imageOnly = !clean;
476
- const heading = imageOnly ? '(image)' : (firstWords(clean, 9) || '(empty)');
477
- const ts = tsStamp(entry.timestamp);
478
- const body = imageOnly ? '_(image attachment)_' : truncateSafely(clean, 3000);
479
- const parts = [`### ${turn}. ${heading}`];
480
- const meta = [ts, queued ? '_(queued while working)_' : ''].filter(Boolean).join(' · ');
481
- if (meta) parts.push(`*${meta}*`);
482
- parts.push('', blockquote(body), '');
483
- return parts.join('\n');
769
+ // Text pasted on Windows arrives with CRLF. CommonMark treats a lone CR as a line end,
770
+ // but this renderer's line rules (headings, fences, rules) would miss it — normalize.
771
+ const lf = (s) => String(s == null ? '' : s).replace(/\r\n?/g, '\n');
772
+
773
+ function userText(content) {
774
+ if (typeof content === 'string') return lf(content);
775
+ if (!Array.isArray(content)) return '';
776
+ return lf(content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n\n'));
484
777
  }
485
778
 
486
- function render(entries, meta) {
487
- const resultMap = buildResultMap(entries);
488
- const out = [];
489
- out.push(frontmatter(meta));
490
- out.push(`# ${meta.title}`, '');
491
- out.push(`> Live capture of source session \`${meta.sessionId}\`. Auto-generated — do not edit by hand.`, '');
492
- out.push('## Conversation', '');
779
+ function countImages(content) {
780
+ return Array.isArray(content) ? content.filter((b) => b && b.type === 'image').length : 0;
781
+ }
782
+
783
+ const isInterrupt = (text) => /^\[Request interrupted by user/.test(String(text).trim());
784
+
785
+ function taskSummary(text) {
786
+ const m = /<summary>([\s\S]*?)<\/summary>/.exec(String(text));
787
+ return m ? oneLine(m[1]) : '';
788
+ }
789
+
790
+ // A slash command as typed: <command-name>/X</command-name> … <command-args>A</command-args>.
791
+ function parseCommandPrompt(text) {
792
+ const t = String(text);
793
+ const name = /<command-name>\s*\/?([^<]*?)\s*<\/command-name>/.exec(t);
794
+ if (!name || !name[1]) return null;
795
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t);
796
+ return { name: name[1], args: args ? args[1].trim() : '' };
797
+ }
798
+
799
+ // A command that never reaches Claude (/effort, /model, …) is followed by its own
800
+ // <local-command-stdout|stderr>; a prompt command (a skill, a custom command) is
801
+ // followed by its expanded body (isMeta) and then Claude's reply.
802
+ function isLocalCommand(entries, i) {
803
+ for (let j = i + 1; j < entries.length && j <= i + 25; j++) {
804
+ const e = entries[j];
805
+ if (e.type === 'assistant') return false;
806
+ if (e.type !== 'user' || e.isMeta) continue;
807
+ return userText(e.message?.content).trimStart().startsWith('<local-command-');
808
+ }
809
+ return false;
810
+ }
811
+
812
+ // A user entry that a human typed as a prompt (the shape the rewind check compares).
813
+ function isPromptEntry(e) {
814
+ if (e.type !== 'user' || e.isMeta || e.isSidechain || e.isCompactSummary) return false;
815
+ const content = e.message?.content;
816
+ if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) return false;
817
+ const text = userText(content);
818
+ if (parseCommandPrompt(text)) return true;
819
+ if (isInterrupt(text) || isSystemInjection(text)) return false;
820
+ return !!stripImageRefs(text) || countImages(content) > 0;
821
+ }
493
822
 
494
- let turn = 0;
823
+ // The conversation as it stands: drops subagent (sidechain) entries, and branches
824
+ // the human rewound. Rewinding or editing an earlier message (Esc Esc) leaves the
825
+ // old branch in the file, and the new message is its SIBLING (same parentUuid) — so
826
+ // a prompt with a later sibling prompt is abandoned, with everything under it.
827
+ function pruneEntries(entries) {
828
+ const live = entries.filter((e) => !e.isSidechain);
829
+ const lastByParent = new Map();
830
+ for (const e of live) {
831
+ if (e.uuid && e.parentUuid && isPromptEntry(e)) lastByParent.set(e.parentUuid, e.uuid);
832
+ }
833
+ const abandoned = new Set();
834
+ for (const e of live) {
835
+ if (e.uuid && e.parentUuid && isPromptEntry(e) && lastByParent.get(e.parentUuid) !== e.uuid) abandoned.add(e.uuid);
836
+ }
837
+ if (!abandoned.size) return live;
838
+ const parentOf = new Map();
839
+ for (const e of live) if (e.uuid) parentOf.set(e.uuid, e.parentUuid || null);
840
+ const memo = new Map();
841
+ const isAbandoned = (uuid) => {
842
+ const path = [];
843
+ let u = uuid;
844
+ let result = false;
845
+ while (u && path.length <= parentOf.size) {
846
+ if (memo.has(u)) { result = memo.get(u); break; }
847
+ if (abandoned.has(u)) { result = true; break; }
848
+ path.push(u);
849
+ u = parentOf.get(u);
850
+ }
851
+ for (const p of path) memo.set(p, result);
852
+ return result;
853
+ };
854
+ return live.filter((e) => !e.uuid || !isAbandoned(e.uuid));
855
+ }
856
+
857
+ // Human messages typed while Claude works wait in a queue until Claude can take
858
+ // them. Replays the queue bookkeeping and returns those still waiting.
859
+ function pendingQueuedPrompts(entries) {
860
+ const queue = [];
861
+ for (const e of entries) {
862
+ if (e.type !== 'queue-operation') continue;
863
+ const content = typeof e.content === 'string' ? e.content : '';
864
+ if (e.operation === 'enqueue') queue.push(content);
865
+ else if (e.operation === 'dequeue') queue.shift();
866
+ else if (e.operation === 'remove') {
867
+ const at = queue.indexOf(content);
868
+ if (at >= 0) queue.splice(at, 1);
869
+ else queue.shift();
870
+ }
871
+ }
872
+ return queue.filter((t) => stripImageRefs(t) && !isSystemInjection(t));
873
+ }
874
+
875
+ // Claude Code 2.1+ writes a turn_duration / stop_hook_summary marker when a turn
876
+ // ends; without one, the latest turn is still running. Older CLIs never write them.
877
+ function turnMarkersExpected(entries) {
878
+ let version = '';
495
879
  for (const e of entries) {
880
+ if (e.type === 'system' && (e.subtype === 'turn_duration' || e.subtype === 'stop_hook_summary')) return true;
881
+ if (typeof e.version === 'string' && e.version) version = e.version;
882
+ }
883
+ const m = /^(\d+)\.(\d+)/.exec(version);
884
+ return !!m && (Number(m[1]) > 2 || (Number(m[1]) === 2 && Number(m[2]) >= 1));
885
+ }
886
+
887
+ // One pass over the transcript: sections (one per human message), each split into
888
+ // rounds (one per uninterrupted agent run), each round a list of items.
889
+ function collectSections(entries, resultMap) {
890
+ const toolNames = buildToolNameMap(entries);
891
+ const live = pruneEntries(entries);
892
+ const sections = [];
893
+ let cur = null;
894
+ let round = null;
895
+ let turnEnded = false; // a turn-end marker arrived after Claude's last output
896
+ let trigger = null; // what woke Claude up for the next round
897
+ let wakeupPending = false; // scheduled_task_fire seen; the next user entry is its prompt
898
+ let commandSection = null; // a just-opened command section, until its isMeta body passes
899
+
900
+ const openRound = (label) => {
901
+ round = { trigger: label, items: [], ended: false };
902
+ cur.rounds.push(round);
903
+ };
904
+ const openSection = (prompt) => {
905
+ cur = { prompt, rounds: [], refinement: null };
906
+ sections.push(cur);
907
+ openRound(null);
908
+ turnEnded = false;
909
+ trigger = null;
910
+ wakeupPending = false;
911
+ commandSection = null;
912
+ };
913
+ const ensureSection = () => { if (!cur) openSection(null); };
914
+ const addMarker = (md) => {
915
+ ensureSection();
916
+ round.items.push({ kind: 'marker', md });
917
+ };
918
+ const endTurn = () => {
919
+ if (!cur) return;
920
+ turnEnded = true;
921
+ round.ended = true;
922
+ };
923
+ const addInterrupt = () => {
924
+ ensureSection();
925
+ const last = round.items[round.items.length - 1];
926
+ if (!(last && last.kind === 'step' && last.rejected)) round.items.push({ kind: 'marker', md: '_Interrupted by the user._' });
927
+ endTurn();
928
+ };
929
+
930
+ for (let i = 0; i < live.length; i++) {
931
+ const e = live[i];
496
932
  const content = e.message?.content;
933
+
934
+ if (e.type === 'system') {
935
+ if (e.subtype === 'turn_duration' || e.subtype === 'stop_hook_summary') endTurn();
936
+ else if (e.subtype === 'scheduled_task_fire') wakeupPending = true;
937
+ continue;
938
+ }
939
+
940
+ if (e.type === 'attachment') {
941
+ const a = e.attachment || {};
942
+ if (a.type === 'queued_command') {
943
+ const text = userText(a.prompt);
944
+ const human = a.commandMode === 'prompt' && (!a.origin || a.origin.kind === 'human');
945
+ if (human && (stripImageRefs(text) || countImages(a.prompt)) && !isSystemInjection(text)) {
946
+ // Typed while Claude was working; this is where it actually reached Claude.
947
+ openSection({ text, ts: e.timestamp, images: countImages(a.prompt), meta: ['sent while Claude was working'] });
948
+ } else if (text.trimStart().startsWith('<task-notification')) {
949
+ const s = taskSummary(text);
950
+ addMarker(`_Background task finished${s ? `: ${esc(s)}` : ''}._`);
951
+ }
952
+ } else if (a.type === 'hook_blocking_error' && a.hookEvent === 'Stop' && turnEnded) {
953
+ trigger = trigger || 'a Stop hook kept Claude working';
954
+ }
955
+ continue;
956
+ }
957
+
497
958
  if (e.type === 'user') {
498
- if (typeof content === 'string') {
499
- if (!isSystemInjection(content)) { turn++; out.push(renderUserPrompt(turn, content, e)); }
500
- } else if (Array.isArray(content)) {
501
- const hasToolResult = content.some((b) => b?.type === 'tool_result');
502
- if (!hasToolResult) {
503
- const texts = content
504
- .filter((b) => b?.type === 'text' && b.text && !isSystemInjection(b.text))
505
- .map((b) => b.text);
506
- if (texts.length) { turn++; out.push(renderUserPrompt(turn, texts.join('\n\n'), e)); }
959
+ if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) {
960
+ for (const b of content) {
961
+ if (b && b.type === 'tool_result') {
962
+ const rejection = parseRejection(b);
963
+ if (rejection && rejection.feedback) {
964
+ openSection({
965
+ text: rejection.feedback, ts: e.timestamp, images: 0,
966
+ meta: [`reply to a rejected ${rejectedToolLabel(toolNames.get(b.tool_use_id))}`],
967
+ });
968
+ }
969
+ } else if (b && b.type === 'text' && isInterrupt(b.text)) {
970
+ addInterrupt();
971
+ }
507
972
  }
508
- // tool_result blocks are folded into the assistant tool_use rendering below
973
+ continue;
509
974
  }
510
- } else if (e.type === 'queue-operation' && e.operation === 'enqueue') {
511
- // Messages the user sent while the agent was working are persisted here,
512
- // NOT as `user` entries. Render real ones; skip task-notification plumbing.
513
- const text = typeof e.content === 'string' ? e.content : '';
514
- if (text.trim() && !isSystemInjection(text)) {
515
- turn++;
516
- out.push(renderUserPrompt(turn, text, e, true));
975
+ const raw = userText(content);
976
+ const images = countImages(content);
977
+ if (e.isMeta) {
978
+ if (wakeupPending) {
979
+ trigger = `scheduled wakeup: ${firstWords(stripImageRefs(raw), 6)}`;
980
+ wakeupPending = false;
981
+ } else if (/^Your claude\.ai usage limit has reset/.test(raw.trim())) {
982
+ trigger = 'resumed after the usage limit reset';
983
+ } else if (commandSection && commandSection === cur && images) {
984
+ cur.prompt.images += images; // a prompt command's images ride on its expanded body
985
+ }
986
+ continue;
517
987
  }
518
- } else if (e.type === 'assistant' && Array.isArray(content)) {
519
- let emitted = false;
520
- for (const b of content) {
521
- if (b.type === 'text') {
522
- if (b.text && b.text.trim()) { out.push(b.text.trim(), ''); emitted = true; }
523
- } else if (b.type === 'thinking') {
524
- // omitted (readable depth)
525
- } else if (b.type === 'tool_use') {
526
- if (b.name === 'AskUserQuestion') out.push(renderDecisionBlock(b, resultMap));
527
- else if (b.name === 'ExitPlanMode') out.push(renderPlanBlock(b, resultMap));
528
- else {
529
- out.push(`- **${b.name}** — ${summarizeToolUse(b.name, b.input)}`);
530
- const res = resultMap.get(b.id);
531
- // Echo-result tools: the tool-use line already names the target, so the
532
- // success result is noise — a cat -n file preview for Read, a "… updated
533
- // successfully (file state is current …)" confirmation for the writers.
534
- // Drop it on success; keep it on error (e.g. "File does not exist",
535
- // "String to replace not found"). Outcome-bearing tools (Bash/PowerShell,
536
- // Grep, Glob, Web*) keep their result — that result IS the point of the step.
537
- const echoTool = b.name === 'Read' || b.name === 'Edit' || b.name === 'Write'
538
- || b.name === 'MultiEdit' || b.name === 'NotebookEdit';
539
- const r = summarizeToolResult(res);
540
- if (r && !(echoTool && !res?.is_error)) out.push(` - ↳ ${r}`);
541
- }
542
- emitted = true;
988
+ if (e.isCompactSummary) {
989
+ addMarker('_Context compacted here — Claude continued from a summary of the conversation above._');
990
+ continue;
991
+ }
992
+ if (isInterrupt(raw)) { addInterrupt(); continue; }
993
+ if (raw.trimStart().startsWith('<task-notification')) {
994
+ const s = firstWords(taskSummary(raw), 12);
995
+ trigger = `background task finished${s ? `: ${s}` : ''}`;
996
+ continue;
997
+ }
998
+ if (raw.trimStart().startsWith('<bash-input>')) {
999
+ const cmd = /<bash-input>([\s\S]*?)<\/bash-input>/.exec(raw);
1000
+ addMarker(`_Ran in the shell: \`${oneLine(cmd ? cmd[1] : '').slice(0, 160)}\`_`);
1001
+ continue;
1002
+ }
1003
+ const command = parseCommandPrompt(raw);
1004
+ if (command) {
1005
+ if (isLocalCommand(live, i)) continue;
1006
+ const text = `/${command.name}${command.args ? ` ${command.args}` : ''}`;
1007
+ if (wakeupPending) {
1008
+ trigger = `scheduled wakeup: ${firstWords(text, 6)}`;
1009
+ wakeupPending = false;
1010
+ continue;
1011
+ }
1012
+ openSection({ text, ts: e.timestamp, images, meta: [], isCommand: true });
1013
+ commandSection = cur;
1014
+ continue;
1015
+ }
1016
+ if (typeof content === 'string' && isSystemInjection(content)) continue;
1017
+ const text = lf(typeof content === 'string' ? content
1018
+ : (Array.isArray(content) ? content : [])
1019
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && !isSystemInjection(b.text))
1020
+ .map((b) => b.text).join('\n\n'));
1021
+ if (!stripImageRefs(text) && !images) continue;
1022
+ if (wakeupPending) {
1023
+ trigger = `scheduled wakeup: ${firstWords(stripImageRefs(text), 6)}`;
1024
+ wakeupPending = false;
1025
+ continue;
1026
+ }
1027
+ openSection({ text, ts: e.timestamp, images, meta: [] });
1028
+ continue;
1029
+ }
1030
+
1031
+ if (e.type === 'assistant' && Array.isArray(content)) {
1032
+ if (e.message?.model === '<synthetic>') {
1033
+ // Harness-written: API errors, "You've hit your session limit", "No response requested."
1034
+ const t = oneLine(userText(content));
1035
+ if (!t || t === 'No response requested.') continue;
1036
+ if (trigger) {
1037
+ // Woken up (e.g. by a finished background task) straight into an error:
1038
+ // the wake-up still gets its own round.
1039
+ ensureSection();
1040
+ openRound(trigger);
1041
+ trigger = null;
1042
+ turnEnded = false;
543
1043
  }
1044
+ addMarker(`_${esc(t)}_`);
1045
+ continue;
544
1046
  }
545
- if (emitted) out.push('');
1047
+ const blocks = content.filter((b) => b && ((b.type === 'text' && b.text && b.text.trim()) || b.type === 'tool_use'));
1048
+ if (!blocks.length) continue; // thinking-only entries
1049
+ ensureSection();
1050
+ if (turnEnded || trigger) {
1051
+ if (round.items.length || trigger) openRound(trigger || 'continued');
1052
+ turnEnded = false;
1053
+ trigger = null;
1054
+ }
1055
+ wakeupPending = false;
1056
+ commandSection = null;
1057
+ for (const b of blocks) {
1058
+ if (b.type === 'text') round.items.push({ kind: 'text', text: lf(b.text).trim() });
1059
+ else if (b.name === 'AskUserQuestion') round.items.push({ kind: 'decision', use: b });
1060
+ else if (b.name === 'ExitPlanMode') round.items.push({ kind: 'plan', use: b });
1061
+ else round.items.push({ kind: 'step', use: b, rejected: !!parseRejection(resultMap.get(b.id)) });
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ // A command section that got no reply and is followed by another section was a
1067
+ // local command whose output never showed up — not a prompt.
1068
+ return sections.filter((s, i) => !(s.prompt && s.prompt.isCommand && i < sections.length - 1
1069
+ && s.rounds.every((r) => !r.items.length)));
1070
+ }
1071
+
1072
+ // Moves the section's first English-refinement block under its prompt and strips
1073
+ // the rest; text items left empty are dropped.
1074
+ function liftRefinements(section) {
1075
+ for (const r of section.rounds) {
1076
+ for (const it of r.items) {
1077
+ if (it.kind !== 'text') continue;
1078
+ const { text, blocks } = extractRefinements(it.text);
1079
+ it.text = text;
1080
+ if (!section.refinement && blocks.length) section.refinement = blocks[0];
1081
+ }
1082
+ r.items = r.items.filter((it) => it.kind !== 'text' || it.text);
1083
+ }
1084
+ }
1085
+
1086
+ // A message is quoted as written: a heading, rule or raw tag the human pasted into it
1087
+ // stays text inside the quote instead of becoming a heading or collapsible in the note.
1088
+ // Code blocks are quoted untouched.
1089
+ function plainQuoteText(text) {
1090
+ return mapOutsideFences(neutralizeOutline(text), (line) => line.replace(/^( {0,3})(#{1,6})(?=[ \t]|$)/, '$1\\$2'));
1091
+ }
1092
+
1093
+ function renderPromptHeader(num, section) {
1094
+ const p = section.prompt;
1095
+ const clean = stripImageRefs(p.text);
1096
+ const heading = clean ? (firstWords(clean, 9) || '(empty)') : '(image)';
1097
+ const meta = [tsStamp(p.ts), ...p.meta, p.images ? `${p.images} image${p.images === 1 ? '' : 's'}` : ''].filter(Boolean);
1098
+ const lines = [`## ${num}. ${esc(heading)}`];
1099
+ if (meta.length) lines.push(`*${meta.join(' · ')}*`);
1100
+ lines.push('', blockquote(clean ? plainQuoteText(truncateSafely(clean, 3000)) : '_(image attachment)_'), '');
1101
+ const ref = section.refinement;
1102
+ if (ref) lines.push(ref.better, '', ...ref.bullets, '');
1103
+ return lines.join('\n');
1104
+ }
1105
+
1106
+ // A round splits at its last tool step: what came before is the working process,
1107
+ // the text after it is the answer. A round that is still running has no answer yet.
1108
+ function renderRound(r, base, running, resultMap) {
1109
+ const items = r.items;
1110
+ let lastWork = -1;
1111
+ items.forEach((it, i) => { if (it.kind === 'step' || it.kind === 'decision' || it.kind === 'plan') lastWork = i; });
1112
+ const tail = items.slice(lastWork + 1);
1113
+ const split = !running && tail.some((it) => it.kind === 'text') ? lastWork + 1 : items.length;
1114
+ const heading = '#'.repeat(base);
1115
+ const chunks = [];
1116
+ const work = items.slice(0, split);
1117
+ if (work.length) {
1118
+ chunks.push({ md: `${heading} Working process` });
1119
+ for (const it of work) {
1120
+ if (it.kind === 'text') chunks.push({ md: closeOpenFence(flattenHeadings(neutralizeOutline(it.text))) });
1121
+ else if (it.kind === 'step') chunks.push({ md: renderStep(it.use, resultMap), step: true });
1122
+ else if (it.kind === 'decision') chunks.push({ md: renderDecisionBlock(it.use, resultMap, base + 1) });
1123
+ else if (it.kind === 'plan') chunks.push({ md: renderPlanBlock(it.use, resultMap, base + 1) });
1124
+ else chunks.push({ md: it.md });
546
1125
  }
547
1126
  }
1127
+ const answer = items.slice(split);
1128
+ if (answer.length) {
1129
+ chunks.push({ md: `${heading} Answer` });
1130
+ const min = Math.min(7, ...answer.filter((it) => it.kind === 'text').map((it) => minHeadingLevel(it.text)));
1131
+ const delta = min <= 6 ? base + 1 - min : 0;
1132
+ for (const it of answer) {
1133
+ chunks.push({ md: it.kind === 'text' ? closeOpenFence(shiftHeadings(neutralizeOutline(it.text), delta)) : it.md });
1134
+ }
1135
+ }
1136
+ // Consecutive tool steps stay one tight list; everything else is its own block.
1137
+ return chunks.map((c, i) => (i && c.step && chunks[i - 1].step ? '' : '\n') + c.md).join('\n').trim();
1138
+ }
548
1139
 
1140
+ function render(entries, meta) {
1141
+ const resultMap = buildResultMap(entries);
1142
+ const sections = collectSections(entries, resultMap);
1143
+ const markers = turnMarkersExpected(entries);
1144
+ const out = [];
1145
+ out.push(`${frontmatter(meta)}# ${meta.title}`, '');
1146
+ out.push(`> Live capture of source session \`${meta.sessionId}\`. Auto-generated — do not edit by hand.`, '');
1147
+
1148
+ let num = 0;
1149
+ sections.forEach((s, si) => {
1150
+ liftRefinements(s);
1151
+ out.push(s.prompt ? renderPromptHeader(++num, s) : '## Session start\n');
1152
+ let followUps = 0;
1153
+ s.rounds.forEach((r, ri) => {
1154
+ if (!r.items.length) return;
1155
+ const running = markers && !r.ended && si === sections.length - 1 && ri === s.rounds.length - 1;
1156
+ if (ri > 0) out.push(`### Follow-up ${++followUps} — ${esc(r.trigger || 'continued')}`, '');
1157
+ out.push(renderRound(r, ri > 0 ? 4 : 3, running, resultMap), '');
1158
+ });
1159
+ });
1160
+
1161
+ for (const text of pendingQueuedPrompts(entries)) {
1162
+ out.push(`_Queued, not yet seen by Claude: “${esc(oneLine(stripImageRefs(text)).slice(0, 300))}”_`, '');
1163
+ }
549
1164
  return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
550
1165
  }
551
1166