@noesis-brain/mcp-server 2.1.3 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/identity.d.ts +33 -0
- package/dist/agent/identity.d.ts.map +1 -0
- package/dist/agent/identity.js +61 -0
- package/dist/agent/identity.js.map +1 -0
- package/dist/agent/runner.d.ts +194 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +465 -50
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tokenState.d.ts +81 -0
- package/dist/agent/tokenState.d.ts.map +1 -0
- package/dist/agent/tokenState.js +173 -0
- package/dist/agent/tokenState.js.map +1 -0
- package/dist/agent/webGuard.d.ts +48 -0
- package/dist/agent/webGuard.d.ts.map +1 -0
- package/dist/agent/webGuard.js +173 -0
- package/dist/agent/webGuard.js.map +1 -0
- package/dist/api/NoesisClient.d.ts +13 -1
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +18 -3
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/tools/claudeSessions.d.ts +72 -0
- package/dist/tools/claudeSessions.d.ts.map +1 -0
- package/dist/tools/claudeSessions.js +304 -0
- package/dist/tools/claudeSessions.js.map +1 -0
- package/dist/tools/index.d.ts +1 -6
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +188 -55
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sessionIndex.d.ts +66 -0
- package/dist/tools/sessionIndex.d.ts.map +1 -0
- package/dist/tools/sessionIndex.js +241 -0
- package/dist/tools/sessionIndex.js.map +1 -0
- package/package.json +1 -1
- package/scripts/noesis-capture-watcher.mjs +722 -107
- package/scripts/start-agent.sh +331 -0
- package/skill-templates/noesis-refine-relations.md +163 -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
|
-
|
|
110
|
+
// Also escapes `&` (so a literal `<` 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, '&').replace(/</g, '<').replace(/>/g, '>').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
|
-
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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
|
|
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)
|
|
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
|
|
326
|
-
if (Array.isArray(c)) return
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
461
|
+
case 'Read': return `Read ${codeSpan(input.file_path)}`;
|
|
392
462
|
case 'Edit': case 'Write': case 'MultiEdit':
|
|
393
|
-
return `${name}
|
|
394
|
-
case 'NotebookEdit': return `NotebookEdit
|
|
395
|
-
case 'Bash': return `Bash:
|
|
396
|
-
case 'PowerShell': return `PowerShell:
|
|
397
|
-
case 'Glob': return `Glob
|
|
398
|
-
case 'Grep': return `Grep
|
|
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}:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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, '<').replace(/>/g, '>');
|
|
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 `</details>` to the reader.
|
|
715
|
+
function escapeClosingDetails(text) {
|
|
716
|
+
return mapOutsideFences(text, (line) => mapOutsideCodeSpans(line, (s) => s.replace(/<\/details>/gi, '</details>')));
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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 (
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
973
|
+
continue;
|
|
509
974
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
|