-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdev-server.mjs
More file actions
1901 lines (1775 loc) · 80.7 KB
/
dev-server.mjs
File metadata and controls
1901 lines (1775 loc) · 80.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
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
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Zero-dep dev server. Serves static files + /env.js generated from .env / .env.local.
import { createServer } from 'node:http';
import { readFile, writeFile, readdir, unlink, stat, mkdir, access } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT) || 5173;
// Per-workdir active SDK run aborters. Regenerate kicks off a fresh
// chapter_summary for the same dir; we abort the prior run first so
// its in-flight subagents stop writing files into the freshly-wiped
// directory (which would otherwise pollute the new run with stale
// plan.json / sections / index.html).
const _activeRuns = new Map();
const DATA_DIR = process.env.PAPERCHAT_DATA || join(homedir(), '.paperchat');
const PAPERS_DIR = join(DATA_DIR, 'papers');
const THREADS_DIR = join(DATA_DIR, 'threads');
const MESSAGES_DIR = join(DATA_DIR, 'messages');
await mkdir(PAPERS_DIR, { recursive: true });
await mkdir(THREADS_DIR, { recursive: true });
await mkdir(MESSAGES_DIR, { recursive: true });
// Whitelist — anything here is readable by any script on the page. Add deliberately.
const EXPOSED_KEYS = ['OPENROUTER_API_KEY'];
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
function parseEnv(raw) {
const out = {};
for (const line of raw.split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
if (!m) continue;
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
out[m[1]] = v;
}
return out;
}
async function readEnvFiles() {
const merged = {};
for (const name of ['.env', '.env.local']) {
try {
const raw = await readFile(join(ROOT, name), 'utf8');
Object.assign(merged, parseEnv(raw));
} catch {}
}
return merged;
}
function stripHtml(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<\/(p|div|li|h[1-6]|tr|br|section|article)\s*>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
const MAX_FETCH_BYTES = 200_000;
const MAX_RETURN_CHARS = 80_000;
async function proxyFetch(target) {
let parsed;
try { parsed = new URL(target); }
catch { throw Object.assign(new Error('invalid url'), { status: 400 }); }
if (!/^https?:$/.test(parsed.protocol)) {
throw Object.assign(new Error('only http(s) URLs allowed'), { status: 400 });
}
// Block private hosts to keep the proxy from being abused for SSRF.
const host = parsed.hostname.toLowerCase();
if (
host === 'localhost' ||
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
host === '0.0.0.0'
) {
throw Object.assign(new Error('private/loopback hosts blocked'), { status: 403 });
}
const r = await fetch(target, {
redirect: 'follow',
headers: { 'user-agent': 'paperchat/0.1 (+local dev)' },
});
const ct = (r.headers.get('content-type') || '').toLowerCase();
// Cap incoming bytes.
const reader = r.body.getReader();
const dec = new TextDecoder();
let raw = '';
let bytes = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
bytes += value.length;
if (bytes > MAX_FETCH_BYTES) {
raw += dec.decode(value.slice(0, MAX_FETCH_BYTES - (bytes - value.length)));
try { reader.cancel(); } catch {}
raw += '\n\n[truncated at ' + MAX_FETCH_BYTES + ' bytes]';
break;
}
raw += dec.decode(value, { stream: true });
}
raw += dec.decode();
let text;
let viaJina = false;
if (ct.includes('html') || ct.includes('xml+xhtml')) {
text = stripHtml(raw);
// If the page appears JS-rendered (lots of <script>, almost no
// extracted body text) retry via r.jina.ai, which loads the URL in
// a real browser and returns clean markdown. Free, no auth.
const looksEmpty = text.length < 500;
const isSpa = /<script[\s>]/i.test(raw) && raw.length > 2000;
if (looksEmpty && isSpa) {
try {
const jr = await fetch(`https://r.jina.ai/${target}`, {
headers: { 'user-agent': 'paperchat/0.1', accept: 'text/plain' },
signal: AbortSignal.timeout(20000),
});
if (jr.ok) {
const jt = await jr.text();
// Only accept the rendered version if it's clearly more useful
// than the raw HTML strip — defends against jina returning a
// captcha/error page that's also short.
if (jt && jt.length > Math.max(text.length + 200, 600)) {
text = jt;
viaJina = true;
}
}
} catch {
// network/timeout — keep the original stripped text
}
}
} else if (ct.includes('pdf')) {
text = `[content-type: ${ct}] PDF fetched from URL is not extracted server-side. ` +
`For arXiv papers, prefer the /abs/ or /html/ URL instead of /pdf/.`;
} else if (
ct.startsWith('text/') ||
ct.includes('json') ||
ct.includes('xml') ||
ct.includes('javascript')
) {
text = raw;
} else {
text = `[unsupported content-type: ${ct}]`;
}
if (text.length > MAX_RETURN_CHARS) {
text = text.slice(0, MAX_RETURN_CHARS) + `\n\n[truncated at ${MAX_RETURN_CHARS} chars]`;
}
return { status: r.status, finalUrl: r.url, contentType: ct, text, viaJina };
}
// On macOS, the local `claude` CLI stores its API key in the login keychain
// under service name "Claude Code". We can read it as a free fallback so the
// @code path uses the same billing account as the user's local Claude Code
// session, without them having to copy the key into .env.
import { spawn } from 'node:child_process';
async function readMacKeychain(service) {
if (process.platform !== 'darwin') return '';
return await new Promise((resolve) => {
const p = spawn('security', ['find-generic-password', '-s', service, '-w'], { stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
p.stdout.on('data', (d) => { out += d.toString(); });
p.on('close', (code) => resolve(code === 0 ? out.trim() : ''));
p.on('error', () => resolve(''));
});
}
// Run a CLI command, resolve with { code, out, err }. Used to shell out to
// pdftocairo from the chapter-summary endpoint.
function runCmd(cmd, args, { timeoutMs = 120_000 } = {}) {
return new Promise((resolve) => {
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = '';
let done = false;
const finish = (code) => { if (done) return; done = true; resolve({ code, out, err }); };
const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} finish(-1); }, timeoutMs);
p.stdout.on('data', d => { out += d.toString(); });
p.stderr.on('data', d => { err += d.toString(); });
p.on('close', code => { clearTimeout(t); finish(code ?? -1); });
p.on('error', e => { clearTimeout(t); err += '\n' + e.message; finish(-1); });
});
}
// Recursively copy a directory tree. Used to stage _lib/ into the agent
// workdir so the agent can link/reference (and ultimately inline) the
// CSS/JS primitives + autogo reference.
async function copyDir(src, dst) {
const { copyFile, readdir, mkdir, stat } = await import('node:fs/promises');
await mkdir(dst, { recursive: true });
for (const name of await readdir(src)) {
const s = join(src, name);
const d = join(dst, name);
const st = await stat(s);
if (st.isDirectory()) await copyDir(s, d);
else await copyFile(s, d);
}
}
// POST /api/chapter_summary
// Body: {
// paperId, chapterId, paperName, chapterTitle, startPage, endPage,
// pdfBase64, plannerModel?, writerModel?
// }
// Stages a workdir under cc-workdir/chapter-<paperId>-<chapterId>/, rasterizes
// the chapter pages via pdftocairo, copies _lib/ in, then launches the
// Claude Agent SDK to produce an interactive HTML chapter site. SSE-streams
// progress events back.
// The per-message `usage` we emit during streaming only covers the
// parent agent's own turns — subagent token usage is NOT included.
// The SDK's final `result` message has the authoritative aggregate
// (parent + every subagent, broken out per model) in `modelUsage`.
// Flatten + forward so the client can show real totals (incl. all the
// HTML the section-writers actually wrote).
function emitFinalUsage(send, msg) {
const mu = msg.modelUsage || {};
const totals = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
for (const v of Object.values(mu)) {
totals.input += v.inputTokens || 0;
totals.output += v.outputTokens || 0;
totals.cacheCreation += v.cacheCreationInputTokens || 0;
totals.cacheRead += v.cacheReadInputTokens || 0;
}
send({ type: 'usage_total', modelUsage: mu, totals, totalCostUsd: msg.total_cost_usd });
}
async function handleChapterSummary(req, res) {
// Load BOTH Anthropic and OpenRouter credentials up front; the right
// one is selected later based on plannerModel slug. Slugs with `/`
// (e.g. "moonshotai/kimi-k2.6") route through OpenRouter via Vercel
// AI SDK; plain "claude-*" slugs route through Claude Agent SDK.
const env = await readEnvFiles();
let anthropicKey = await readMacKeychain('Claude Code');
if (!anthropicKey) anthropicKey = env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
const openrouterKey = env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || '';
let payload;
try { payload = await readJsonBody(req, 200 * 1024 * 1024); }
catch (e) { res.writeHead(e.status || 400); return res.end('invalid JSON: ' + e.message); }
const {
paperId, chapterId, paperName = 'Untitled', chapterTitle = 'Chapter',
startPage, endPage, pdfBase64,
plannerModel = 'claude-opus-4-7',
writerModel = 'claude-sonnet-4-6',
} = payload || {};
if (!paperId || !chapterId || !pdfBase64
|| !Number.isInteger(startPage) || !Number.isInteger(endPage) || endPage < startPage) {
res.writeHead(400);
return res.end('paperId, chapterId, startPage, endPage, pdfBase64 required');
}
// Pick provider based on the planner model slug. OpenRouter slugs
// always contain "/" (e.g. "moonshotai/kimi-k2.6"); Anthropic slugs
// are bare ("claude-opus-4-7"). Each provider needs its own key.
const useOpenRouter = String(plannerModel).includes('/');
const apiKey = useOpenRouter ? openrouterKey : anthropicKey;
if (!apiKey) {
res.writeHead(400);
return res.end(useOpenRouter
? `No OpenRouter credentials. Set OPENROUTER_API_KEY in .env (selected model: ${plannerModel}).`
: `No Anthropic credentials. Sign in to \`claude\` CLI or set ANTHROPIC_API_KEY (selected model: ${plannerModel}).`);
}
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive',
});
// Stage workdir.
// Fresh kickoff = blow away any prior workdir for this (paper, chapter)
// so the agent doesn't re-edit a stale index.html. The resume endpoint
// is the only path that preserves prior state.
const safeId = (s) => String(s).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48);
const dirName = `chapter-${safeId(paperId)}-${safeId(chapterId)}`;
const workdir = join(ROOT, 'cc-workdir', dirName);
// Cancel any prior run for the same dir BEFORE the wipe — otherwise
// its in-flight subagents will write files back into the freshly-
// recreated workdir and the new agent will see stale plan.json /
// sections / index.html.
const prev = _activeRuns.get(dirName);
if (prev) {
try { prev.abort(); } catch {}
_activeRuns.delete(dirName);
// Brief beat to let the SDK's spawned processes wind down before
// we wipe — open file handles can otherwise re-create files.
await new Promise(r => setTimeout(r, 200));
}
const { rm } = await import('node:fs/promises');
await rm(workdir, { recursive: true, force: true });
await mkdir(workdir, { recursive: true });
// Persist every SSE event to <workdir>/trace.jsonl so the user can
// inspect the run later (thinking, tool calls, streamed text, errors,
// final result). One JSON object per line.
//
// The trace is the source of truth. If the SSE client disconnects (page
// reload, panel closed) we keep the agent + trace writes alive — a
// separate /api/chapter_runs/replay endpoint can attach to the trace
// and replay+tail it. Hence the clientGone flag below.
const { createWriteStream } = await import('node:fs');
const traceStream = createWriteStream(join(workdir, 'trace.jsonl'), { flags: 'w' });
const traceStart = Date.now();
let clientGone = false;
res.on('close', () => { clientGone = true; });
const send = (obj) => {
const line = JSON.stringify({ t: Date.now() - traceStart, ...obj }) + '\n';
traceStream.write(line);
if (!clientGone) {
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); }
catch { clientGone = true; }
}
};
// Persist the run's parameters so /api/chapter_runs/resume can re-invoke
// the agent with the right models + chapter context after a crash.
await writeFile(join(workdir, 'params.json'), JSON.stringify({
paperId, chapterId, paperName, chapterTitle,
startPage, endPage, plannerModel, writerModel,
}, null, 2));
send({ type: 'stage', message: `Workdir: ${workdir}` });
send({ type: 'stage', message: `Trace: ${join(workdir, 'trace.jsonl')}` });
// Wrap the setup phase so an internal error (pdftocairo crash, magick
// failure, etc.) becomes a streamed `error` event instead of crashing
// the dev-server process. Without this, the unhandled exception
// escapes to the top-level handler which tries writeHead(500) on an
// already-streaming SSE response → ERR_HTTP_HEADERS_SENT → server dies.
// Hoisted so they survive the try-block scope and are available to the
// agent launch below.
let systemPrompt, userPrompt;
try {
// 1) Save the PDF
const pdfPath = join(workdir, 'chapter.pdf');
await writeFile(pdfPath, Buffer.from(pdfBase64, 'base64'));
send({ type: 'stage', message: `Wrote chapter.pdf (${(pdfBase64.length * 0.75 / 1024 / 1024).toFixed(2)} MB)` });
// 2) Rasterize chapter pages with pdftocairo at 200 DPI. Output named
// page-<N>.png with absolute PDF page number. pdftocairo doesn't
// print per-page progress, but it writes each page as a separate
// .png as it goes — so we poll the output dir and surface (i/N)
// progress while the command is running.
const pagesDir = join(workdir, 'pages');
await mkdir(pagesDir, { recursive: true });
const totalPages = endPage - startPage + 1;
send({ type: 'stage', message: `Rasterizing pages ${startPage}-${endPage}… (0/${totalPages})` });
const t0 = Date.now();
const rasterPromise = runCmd('pdftocairo', [
'-png', '-r', '200',
'-f', String(startPage), '-l', String(endPage),
pdfPath, join(pagesDir, 'page'),
], { timeoutMs: 300_000 });
let lastCount = -1;
const poller = setInterval(async () => {
try {
const n = (await readdir(pagesDir)).filter(f => f.endsWith('.png')).length;
if (n !== lastCount) {
lastCount = n;
send({ type: 'stage', message: `Rasterizing pages ${startPage}-${endPage}… (${n}/${totalPages})` });
}
} catch {}
}, 250);
const pc = await rasterPromise;
clearInterval(poller);
if (pc.code !== 0) {
send({ type: 'error', message: `pdftocairo exited ${pc.code}: ${pc.err.slice(0, 500)}` });
return res.end();
}
const pageFiles = (await readdir(pagesDir)).filter(n => n.endsWith('.png')).sort();
send({ type: 'stage', message: `Rasterized ${pageFiles.length} pages in ${Date.now() - t0}ms` });
// 2b) Compose batches of N pages into labeled JPEG grids — gives the
// agent compact multi-page views instead of N independent images.
// Each composite tiles its pages vertically, labeled with the
// absolute PDF page number. Saved at moderate resolution so a single
// image stays under Anthropic's 5MB/8000px limits.
const compositesDir = join(workdir, 'composites');
await mkdir(compositesDir, { recursive: true });
const BATCH = 4;
const composites = [];
// pageFiles are sorted lexicographically; map back to absolute page numbers
// by stripping the page-<NNN>.png pattern.
const pageFileNum = (n) => parseInt(n.match(/page-(\d+)\.png/)?.[1] || '0', 10);
const sortedPages = [...pageFiles].sort((a, b) => pageFileNum(a) - pageFileNum(b));
for (let i = 0; i < sortedPages.length; i += BATCH) {
const slice = sortedPages.slice(i, i + BATCH);
const firstPn = pageFileNum(slice[0]);
const lastPn = pageFileNum(slice[slice.length - 1]);
const outName = `pages-${String(firstPn).padStart(3, '0')}-${String(lastPn).padStart(3, '0')}.jpg`;
const outPath = join(compositesDir, outName);
// magick montage: tile 1×N, label each panel with its page number,
// moderate panel size so the whole composite stays under ~6000px tall.
const inputs = slice.map(n => `label:Page ${pageFileNum(n)}\n${join(pagesDir, n)}`);
// Use the array form so panel labels work; -label prefixes the next file.
const args = [];
for (const n of slice) {
args.push('-label', `Page ${pageFileNum(n)}`, join(pagesDir, n));
}
args.push('-tile', `1x${slice.length}`, '-geometry', '1100x>+8+8',
'-background', '#faf8f3', '-fill', '#1f1d1a',
'-pointsize', '20',
// macOS ImageMagick can't resolve 'Helvetica' by name without
// a fontconfig setup; point straight at the system font.
'-font', '/System/Library/Fonts/Helvetica.ttc',
'-quality', '85',
outPath);
const r = await runCmd('magick', ['montage', ...args], { timeoutMs: 60_000 });
if (r.code !== 0) {
send({ type: 'stage', message: `magick montage exited ${r.code}: ${r.err.slice(0, 200)}` });
continue;
}
composites.push({ name: outName, firstPn, lastPn, count: slice.length });
}
send({ type: 'stage', message: `Built ${composites.length} composite(s) of up to ${BATCH} pages each → composites/` });
// 3) Extract every embedded raster figure with pdfimages. Each file is
// named img-<page>-<idx>.<ext>. The agent can either reference these
// directly with <img> tags or recreate the figure as SVG — its call.
const figuresDir = join(workdir, 'figures');
await mkdir(figuresDir, { recursive: true });
const pi = await runCmd('pdfimages', [
'-all', '-p', '-f', String(startPage), '-l', String(endPage),
pdfPath, join(figuresDir, 'img'),
], { timeoutMs: 120_000 });
let figFiles = (await readdir(figuresDir)).filter(n => /\.(png|jpg|jpeg|tif|tiff|jb2)$/i.test(n)).sort();
if (pi.code !== 0) {
send({ type: 'stage', message: `pdfimages exited ${pi.code} (continuing): ${pi.err.slice(0, 200)}` });
} else {
send({ type: 'stage', message: `Extracted ${figFiles.length} embedded image(s) → figures/` });
}
// Filter out tiny extracts (under 100px on any axis). pdfimages
// produces a lot of these — single ligatures, hairline rules,
// ornaments, sliver crops of larger graphics — and they're never
// useful as figures. Removing them shrinks the agent's decision
// space and saves it from Read-and-discard cycles.
const MIN_DIM = 100;
let dropped = 0;
const kept = [];
for (const fname of figFiles) {
const abs = join(figuresDir, fname);
try {
const r = await runCmd('magick', ['identify', '-format', '%w %h', abs], { timeoutMs: 5_000 });
const [w, h] = (r.out || '').trim().split(/\s+/).map(Number);
if (Number.isFinite(w) && Number.isFinite(h) && (w < MIN_DIM || h < MIN_DIM)) {
try { await unlink(abs); dropped++; } catch {}
continue;
}
kept.push(fname);
} catch {
kept.push(fname); // if identify fails, keep — fail open, don't lose a real figure
}
}
if (dropped > 0) {
figFiles = kept;
send({ type: 'stage', message: `Filtered ${dropped} tiny figure(s) <${MIN_DIM}px → kept ${figFiles.length}` });
}
// 3b) Extract the chapter's full text via pdftotext. We embed this
// verbatim in the system prompt — saves the agent from running
// pdftotext itself (especially helpful for text-only models that
// can't see composites), and the cached system prompt prefix
// amortizes the text across every subagent call.
let chapterText = '';
const pt = await runCmd('pdftotext', [
'-layout', '-f', String(startPage), '-l', String(endPage),
pdfPath, '-',
], { timeoutMs: 60_000 });
if (pt.code === 0 && pt.out) {
chapterText = pt.out;
await writeFile(join(workdir, 'chapter.txt'), chapterText, 'utf8');
send({ type: 'stage', message: `Extracted ${chapterText.length.toLocaleString()} chars of chapter text → chapter.txt (will be inlined in system prompt)` });
} else {
send({ type: 'stage', message: `pdftotext exited ${pt.code} (continuing without inlined text)` });
}
// 4) Copy _lib/ into the workdir so the agent can read (and link to) it.
await copyDir(join(ROOT, '_lib'), join(workdir, '_lib'));
send({ type: 'stage', message: 'Copied _lib/ (pc.css, pc.js, pc-math.js, template.html, ref/autogo.html)' });
// 5) Drop a tiny crop helper so the agent can extract figures from
// the rasterized pages with one short command.
const cropScript = `#!/bin/bash
# crop PAGE x y W H out.png — crop a region of pages/page-PAGE.png
# PAGE is the absolute PDF page number; x/y are pixel coords measured
# from the top-left of the rasterized page (200 DPI, ≈2.78 px/pt).
# Use this to extract vector figures the PDF doesn't embed as bitmaps.
set -euo pipefail
PAGE="$1"; X="$2"; Y="$3"; W="$4"; H="$5"; OUT="$6"
src="pages/page-$(printf '%03d' "$PAGE").png"
[ -f "$src" ] || { echo "no such page raster: $src" >&2; exit 1; }
mkdir -p "$(dirname "$OUT")"
magick "$src" -crop "\${W}x\${H}+\${X}+\${Y}" +repage "$OUT"
echo "wrote $OUT ($(magick identify -format '%wx%h' "$OUT"))"
`;
await writeFile(join(workdir, 'crop'), cropScript, { mode: 0o755 });
send({ type: 'stage', message: 'Wrote crop helper (./crop PAGE x y W H out.png)' });
// Build the system prompt. For the skeleton run, this is a minimal one
// pass — once it's working end-to-end the 4-pass plan replaces it.
systemPrompt = buildChapterAgentPrompt({
paperName, chapterTitle, startPage, endPage,
pageFiles, figFiles, composites, plannerModel, writerModel,
chapterText,
});
// Initial user prompt — tell the agent what to read first.
const compList = composites.map(c =>
c.count === 1
? ` composites/${c.name} (page ${c.firstPn})`
: ` composites/${c.name} (pages ${c.firstPn}–${c.lastPn})`
).join('\n');
userPrompt = `Generate the chapter site for "${chapterTitle}" now.
Read these composite page-grid images first to understand the chapter — each one is up to 4 pages stacked vertically, labeled by page number:
${compList}
Then write index.html using the _lib/pc.css vocabulary and the autogo aesthetic. Recreate figures inline as SVG/Canvas where it makes sense, or embed extracted bitmaps from figures/ — your call per figure.`;
} catch (setupErr) {
// From my outer try wrapping the setup phase (rasterize / composite
// / pdfimages / copy _lib / drop crop / save params). Any failure
// here streams a clean error event and ends the response without
// crashing the dev-server.
console.error('chapter_summary setup failed:', setupErr?.stack || setupErr);
send({ type: 'error', message: 'Setup failed: ' + (setupErr?.message || setupErr) });
try { traceStream.end(); } catch {}
if (!clientGone) { try { res.end(); } catch {} }
return;
}
let query;
try {
({ query } = await import('@anthropic-ai/claude-agent-sdk'));
} catch (e) {
send({ type: 'error', message: 'SDK not installed: ' + e.message });
return res.end();
}
// Watch the workdir's .phase file — the agent writes phase names to
// it at transitions ("planning", "skeleton", "dispatching", "writing",
// "polishing") via `echo <phase> > .phase`. Each change becomes a
// `phase` SSE event so the UI can switch step indicators without
// relying on text/thinking heuristics. fs.watch in the dir picks up
// create/rename/modify events on the file.
const { watch } = await import('node:fs');
const phasePath = join(workdir, '.phase');
let lastPhase = '';
const phaseWatcher = watch(workdir, async (evType, filename) => {
if (filename !== '.phase') return;
try {
const v = (await readFile(phasePath, 'utf8')).trim();
if (v && v !== lastPhase) {
lastPhase = v;
send({ type: 'phase', name: v });
}
} catch {}
});
const prevKey = process.env.ANTHROPIC_API_KEY;
if (!useOpenRouter) process.env.ANTHROPIC_API_KEY = apiKey;
// Register our abort controller for this dirName so a subsequent
// regenerate can cancel us cleanly.
const abortController = new AbortController();
_activeRuns.set(dirName, abortController);
try {
send({ type: 'stage', message: `Launching agent (${useOpenRouter ? 'OpenRouter' : 'Anthropic'} → ${plannerModel})…` });
// Compose the section-writer's system prompt by inlining the brief
// + design.md + components.html. This is the cache-sharing win:
// - The full ~25 KB prefix is identical across every Task
// invocation in this chapter, so Anthropic's prompt cache
// reuses it after the first subagent's first call.
// - Subagents never need to Read design.md or components.html
// — the content is already in their system prompt, saving
// ~3 tool calls per subagent + faster startup.
// The parent agent still reads the on-disk versions during plan
// mode; the inlined copies don't conflict.
let subagentBrief = '', designMd = '', componentsHtml = '';
try { subagentBrief = await readFile(join(workdir, '_lib/ref/subagent-brief.md'), 'utf8'); } catch {}
try { designMd = await readFile(join(workdir, '_lib/ref/design.md'), 'utf8'); } catch {}
try { componentsHtml = await readFile(join(workdir, '_lib/ref/components.html'), 'utf8'); } catch {}
const composedBrief = [
subagentBrief || 'Write a paperchat chapter section as an HTML fragment to sections/<id>.html.',
designMd && '\n\n---\n\n# design.md (already loaded — DO NOT Read it, the content is here)\n\n' + designMd,
componentsHtml && '\n\n---\n\n# components.html (already loaded — DO NOT Read it, the content is here)\n\n' + componentsHtml,
].filter(Boolean).join('');
// ROUTE: OpenRouter models use Vercel AI SDK; Anthropic uses Claude
// Agent SDK. The setup phase above (rasterize/compose/figures/_lib
// copy/crop/params/composedBrief) is shared; only the agent loop
// differs.
if (useOpenRouter) {
const { runChapterAgentOpenRouter } = await import('./chapter-summary-openrouter.mjs');
await runChapterAgentOpenRouter({
workdir, systemPrompt, userPrompt, plannerModel, writerModel,
composedSectionWriterPrompt: composedBrief,
apiKey, send,
abortSignal: abortController.signal,
});
return; // skip the Anthropic path below
}
const iter = query({
prompt: userPrompt,
options: {
systemPrompt,
abortController,
cwd: workdir,
permissionMode: 'bypassPermissions',
includePartialMessages: true,
settingSources: [],
skills: [],
// The TOP-LEVEL agent is the planner — runs as plannerModel
// (Opus by default). Subagents (section-writer) run as
// writerModel (Sonnet); see agents.section-writer.model below.
model: plannerModel,
// The planner's job is to FAN OUT, not to author content. Cap
// thinking so it doesn't draft full HTML in its head — we saw
// 6+ min of pure thinking and 30K thinking-chars (≈10K tokens)
// drafting all 10 sections inline before any tool call. Budget
// here is calibrated to allow real planning (pick sections,
// pair pages to concepts, choose components) but not enough to
// fit drafts of 10 SVGs.
effort: 'medium',
thinking: { type: 'enabled', budgetTokens: 8000 },
// Define the section-writer subagent so the main agent can dispatch
// N of these in parallel via the Task tool. Each runs as Sonnet
// with the brief as its system prompt — sharing the global rules.
agents: {
'section-writer': {
description: 'Writes one HTML fragment to sections/<id>.html for one concept page of a paperchat chapter site. Use one of these per planned section, dispatched in parallel.',
prompt: composedBrief,
tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
model: writerModel,
// Subagents do the actual authoring — let them think
// deeply about each section's prose, figure choice, and
// SVG geometry. AgentDefinition's effort does NOT inherit
// from the parent (per SDK type def), so set it here.
effort: 'high',
},
},
},
});
// With includePartialMessages: true, the SDK re-yields the same
// assistant message many times as it streams. Dedupe so we don't
// forward (and double-count) the same tool_use / usage / thinking.
const seenToolUseIds = new Set();
const seenUsageMsgIds = new Set();
const seenThinkingMsgIds = new Set();
for await (const msg of iter) {
const t = msg?.type;
if (t === 'stream_event') {
const ev = msg.event;
if (ev?.type === 'content_block_delta') {
// Visible assistant text
if (ev.delta?.type === 'text_delta' && ev.delta.text) {
send({ type: 'text', content: ev.delta.text });
}
// Extended-thinking reasoning. Forward so the trace captures it
// and the UI panel can display a dimmed "thinking…" stream.
else if (ev.delta?.type === 'thinking_delta' && ev.delta.thinking) {
send({ type: 'thinking', content: ev.delta.thinking });
}
}
continue;
}
if (t === 'assistant') {
// Streaming yields the same assistant message multiple times
// (partial → complete). Dedupe tool_use by id; for usage, emit
// the LATEST observation per msgId (later partials have the
// final output_tokens count) so the client can replace earlier
// partials with the complete usage when summing totals.
const msgId = msg.message?.id;
for (const b of msg.message?.content || []) {
if (b.type === 'tool_use' && b.id && !seenToolUseIds.has(b.id)) {
seenToolUseIds.add(b.id);
send({ type: 'tool_use', id: b.id, name: b.name, args: b.input || {} });
} else if (b.type === 'thinking' && !seenThinkingMsgIds.has(msgId)) {
send({ type: 'thinking_complete', content: b.thinking || '' });
}
}
if (msgId) seenThinkingMsgIds.add(msgId);
const u = msg.message?.usage;
if (u && msgId) {
// Emit msgId so client can keep one canonical usage per turn
// and replace partial reports with the complete one.
send({
type: 'usage',
msgId,
input: u.input_tokens || 0,
output: u.output_tokens || 0,
cacheCreation: u.cache_creation_input_tokens || 0,
cacheRead: u.cache_read_input_tokens || 0,
});
}
} else if (t === 'user') {
for (const b of msg.message?.content || []) {
if (b.type === 'tool_result') {
const content = Array.isArray(b.content)
? b.content.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('\n')
: String(b.content ?? '');
send({ type: 'tool_result', id: b.tool_use_id, ok: !b.is_error, result: content });
}
}
} else if (t === 'result') {
emitFinalUsage(send, msg);
if (msg.subtype && msg.subtype.startsWith('error_')) {
send({ type: 'error', message: (msg.errors && msg.errors.join('\n')) || msg.subtype });
} else {
send({ type: 'done', stopReason: msg.stop_reason, totalCostUsd: msg.total_cost_usd, workdir });
}
} else if (t === 'system') {
// Init / model info — small payload for trace.
send({ type: 'system', subtype: msg.subtype, model: msg.model });
}
}
} catch (err) {
send({ type: 'error', message: err.message || String(err) });
} finally {
if (prevKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = prevKey;
try { phaseWatcher.close(); } catch {}
try { traceStream.end(); } catch {}
if (!clientGone) { try { res.end(); } catch {} }
// Only clear the active-run slot if it's still OUR controller.
// A subsequent regenerate may have already replaced it.
if (_activeRuns.get(dirName) === abortController) {
_activeRuns.delete(dirName);
}
}
}
// GET /api/chapter_runs/active?paperId=X
// Lists chapter-summary runs whose trace.jsonl exists but doesn't end
// with a terminal event (done / error). Used on page load to reattach
// the progress panel to in-flight runs that survived a reload.
async function handleListActiveRuns(req, res, url) {
const paperId = url.searchParams.get('paperId');
if (!paperId) { res.writeHead(400); return res.end('paperId required'); }
const safe = String(paperId).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48);
const prefix = `chapter-${safe}-`;
const ccDir = join(ROOT, 'cc-workdir');
let entries;
try { entries = await readdir(ccDir); }
catch (e) { if (e.code === 'ENOENT') return sendJson(res, 200, []); throw e; }
const out = [];
// Runs whose trace.jsonl hasn't been written for this long are
// considered "stale" — the agent process died (server restart, crash).
// We still return them with status='stale' so the client can resume
// them via /api/chapter_runs/resume; only terminal runs are filtered.
const STALE_THRESHOLD_MS = 90_000;
const nowMs = Date.now();
for (const name of entries) {
if (!name.startsWith(prefix)) continue;
const tracePath = join(ccDir, name, 'trace.jsonl');
let st;
try { st = await stat(tracePath); } catch { continue; }
if (!st.isFile()) continue;
let buf;
try { buf = await readFile(tracePath, 'utf8'); } catch { continue; }
const lines = buf.split('\n').filter(Boolean);
if (!lines.length) continue;
let lastEv;
try { lastEv = JSON.parse(lines[lines.length - 1]); } catch {}
const isTerminal = lastEv?.type === 'done' || lastEv?.type === 'error';
if (isTerminal) continue;
const stale = (nowMs - st.mtimeMs) > STALE_THRESHOLD_MS;
const chapterId = name.slice(prefix.length);
// Also surface the plannerModel from params.json so the client
// can decide whether to attempt a stale-resume (Claude path
// only; OpenRouter chapters skip the resume POST since the
// Claude SDK can't continue an OpenRouter session).
let plannerModel = '';
try {
const params = JSON.parse(await readFile(join(ccDir, name, 'params.json'), 'utf8'));
plannerModel = params.plannerModel || '';
} catch {}
out.push({
chapterId,
dirName: name,
lineCount: lines.length,
lastEventAtMs: lastEv?.t || 0,
traceMtime: st.mtimeMs,
status: stale ? 'stale' : 'live',
plannerModel,
});
}
sendJson(res, 200, out);
}
// POST /api/chapter_runs/resume?dir=<dirName>
// Re-invokes the Claude Agent SDK against an existing chapter workdir,
// using `continue: true` so the SDK resumes the prior conversation in
// that directory. Appends to the existing trace.jsonl. Streams new
// events as SSE just like /api/chapter_summary. Used when a previous
// run was interrupted (server restart, network drop) and the trace
// shows no terminal event.
async function handleResumeRun(req, res, url) {
const dirName = url.searchParams.get('dir') || '';
if (!/^chapter-[a-zA-Z0-9._-]+$/.test(dirName)) {
res.writeHead(400); return res.end('invalid dir');
}
const workdir = join(ROOT, 'cc-workdir', dirName);
let params;
try { params = JSON.parse(await readFile(join(workdir, 'params.json'), 'utf8')); }
catch { res.writeHead(404); return res.end('no params.json — run was started before resume support'); }
let apiKey = await readMacKeychain('Claude Code');
if (!apiKey) {
const env = await readEnvFiles();
apiKey = env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
}
if (!apiKey) { res.writeHead(400); return res.end('No Anthropic credentials'); }
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive',
});
const { createWriteStream } = await import('node:fs');
// Append to the existing trace so the full history is preserved.
const traceStream = createWriteStream(join(workdir, 'trace.jsonl'), { flags: 'a' });
const traceStart = Date.now();
let clientGone = false;
res.on('close', () => { clientGone = true; });
const send = (obj) => {
const line = JSON.stringify({ t: Date.now() - traceStart, ...obj }) + '\n';
traceStream.write(line);
if (!clientGone) {
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); }
catch { clientGone = true; }
}
};
// Route based on the model recorded in params.json. OpenRouter
// chapters (planner slug contains "/") cannot resume via Claude
// Agent SDK; the model isn't an Anthropic model. For now we just
// tell the user the resume isn't supported on OpenRouter (continue:
// true is a Claude-SDK-specific session feature). The chapter's
// workdir already has plan.json and partial sections from the
// original run; the user can regenerate to pick up where they left
// off, but that's a fresh run, not a continue.
const isORChapter = String(params.plannerModel || '').includes('/');
if (isORChapter) {
send({ type: 'stage', message: 'Resume not supported for OpenRouter chapters yet. Click Regenerate to restart from scratch (existing artifacts will be wiped).' });
send({ type: 'error', message: 'OpenRouter chapter resume not implemented' });
try { traceStream.end(); } catch {}
if (!clientGone) try { res.end(); } catch {}
return;
}
send({ type: 'stage', message: `Resuming agent in ${dirName} (continue: true)…` });
let query;
try { ({ query } = await import('@anthropic-ai/claude-agent-sdk')); }
catch (e) {
send({ type: 'error', message: 'SDK not installed: ' + e.message });
try { traceStream.end(); } catch {}
if (!clientGone) try { res.end(); } catch {}
return;
}
const prevKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = apiKey;
try {
const iter = query({
prompt:
'You are resuming a chapter-site build. ' +
'STEP 1 (mandatory): assess current state. ' +
' - Does `index.html` exist at workdir root? ' +
' - Does `plan.json` exist? ' +
' - For every page listed in `plan.json`, does `sections/<id>.html` exist? ' +
' - Read the final 50 lines of `index.html` to confirm it is the multi-file ' +
' skeleton (uses `[data-section-loader]` placeholders) and NOT a monolithic ' +
' inlined file. ' +
'IF every check passes — the chapter is COMPLETE. Run `echo done > .phase` ' +
'and immediately exit (no further tool calls, no writes). Do NOT rebuild anything. ' +
'IF anything is missing or wrong — continue from where the prior run left off. ' +
'Use the same multi-file architecture: skeleton index.html + sections/<id>.html ' +
'fragments dispatched via Task subagents. Do NOT consolidate into a monolithic file.',
options: {
cwd: workdir,
permissionMode: 'bypassPermissions',
includePartialMessages: true,
settingSources: [],
skills: [],
// Resume the planner — Opus by default, mirroring the fresh-
// run config above. Section-writer subagents (which we don't
// re-register here; the SDK's continue:true preserves the
// original session's agent definitions) keep running as
// writerModel as before.
model: params.plannerModel || 'claude-opus-4-7',
// Same anti-overthinking guardrails as the fresh-run config —
// resumes were observed spending minutes in pure thinking,
// drafting content in the model's head instead of dispatching.
// Mirrors the fresh-run budget (8K, medium) — enough room to
// plan, not enough room to draft a whole site inline.
effort: 'medium',
thinking: { type: 'enabled', budgetTokens: 8000 },
continue: true,
},
});
for await (const msg of iter) {
const t = msg?.type;
if (t === 'stream_event') {
const ev = msg.event;
if (ev?.type === 'content_block_delta') {
if (ev.delta?.type === 'text_delta' && ev.delta.text) send({ type: 'text', content: ev.delta.text });
else if (ev.delta?.type === 'thinking_delta' && ev.delta.thinking) send({ type: 'thinking', content: ev.delta.thinking });
}
continue;
}
if (t === 'assistant') {
for (const b of msg.message?.content || []) {
if (b.type === 'tool_use') send({ type: 'tool_use', id: b.id, name: b.name, args: b.input || {} });
else if (b.type === 'thinking') send({ type: 'thinking_complete', content: b.thinking || '' });
}
const u = msg.message?.usage;
if (u) send({
type: 'usage',
input: u.input_tokens || 0,
output: u.output_tokens || 0,
cacheCreation: u.cache_creation_input_tokens || 0,
cacheRead: u.cache_read_input_tokens || 0,
});
} else if (t === 'user') {
for (const b of msg.message?.content || []) {
if (b.type === 'tool_result') {
const content = Array.isArray(b.content)
? b.content.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('\n')
: String(b.content ?? '');
send({ type: 'tool_result', id: b.tool_use_id, ok: !b.is_error, result: content });
}
}
} else if (t === 'result') {
emitFinalUsage(send, msg);
if (msg.subtype && msg.subtype.startsWith('error_')) {
send({ type: 'error', message: (msg.errors && msg.errors.join('\n')) || msg.subtype });
} else {
send({ type: 'done', stopReason: msg.stop_reason, totalCostUsd: msg.total_cost_usd, workdir });
}
}
}
} catch (err) {
send({ type: 'error', message: err.message || String(err) });
} finally {
if (prevKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = prevKey;
try { traceStream.end(); } catch {}
if (!clientGone) { try { res.end(); } catch {} }
}
}
// GET /api/chapter_runs/replay?dir=<dirName>
// SSE-streams the entire trace.jsonl of a chapter run, then continues to
// tail-poll the file every 500 ms for newly-appended lines. Closes when a
// terminal event (done / error) is observed. Lets the UI reattach to a
// run whose original connection was lost.
async function handleReplayRun(req, res, url) {
const dirName = url.searchParams.get('dir') || '';
if (!/^chapter-[a-zA-Z0-9._-]+$/.test(dirName)) {
res.writeHead(400); return res.end('invalid dir');
}
const tracePath = join(ROOT, 'cc-workdir', dirName, 'trace.jsonl');
let st;
try { st = await stat(tracePath); }
catch { res.writeHead(404); return res.end('no trace'); }
if (!st.isFile()) { res.writeHead(404); return res.end('no trace'); }
res.writeHead(200, {
'content-type': 'text/event-stream',