papilot 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +58 -30
  2. package/README.zh-CN.md +58 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +8 -0
  5. package/bin/installation.mjs +54 -0
  6. package/bin/papilot.mjs +6 -2
  7. package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
  8. package/dist/assets/index-Cc-awdNn.css +1 -0
  9. package/dist/assets/index-D_VX51no.js +265 -0
  10. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  11. package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
  12. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  13. package/dist/index.html +2 -2
  14. package/examples/papilot-paper/main.tex +1 -1
  15. package/examples/papilot-paper/sections/02-using.tex +3 -1
  16. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  17. package/package.json +15 -7
  18. package/scripts/service.ts +1 -0
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/note-groups.ts +30 -0
  23. package/src/notes.ts +34 -10
  24. package/src/server/acp.ts +354 -0
  25. package/src/server/agents.ts +237 -47
  26. package/src/server/ai-review-routes.ts +72 -0
  27. package/src/server/ai-review.ts +38 -0
  28. package/src/server/annotation-placement.ts +99 -0
  29. package/src/server/app.ts +216 -21
  30. package/src/server/attachments.ts +128 -0
  31. package/src/server/build-problems.ts +329 -0
  32. package/src/server/changes.ts +28 -2
  33. package/src/server/codex.ts +2 -1
  34. package/src/server/cursor.ts +153 -0
  35. package/src/server/diagnostics.ts +45 -0
  36. package/src/server/git-overview.ts +69 -0
  37. package/src/server/imports.ts +205 -0
  38. package/src/server/latex-installation.ts +43 -0
  39. package/src/server/main.ts +8 -42
  40. package/src/server/manuscript.ts +212 -12
  41. package/src/server/note-results.ts +65 -0
  42. package/src/server/paths.ts +1 -1
  43. package/src/server/references.ts +70 -0
  44. package/src/server/review.ts +6 -5
  45. package/src/server/skill-library.ts +113 -0
  46. package/src/server/skill-routes.ts +19 -0
  47. package/src/server/skills.ts +29 -19
  48. package/src/server/start.ts +51 -0
  49. package/src/server/store.ts +44 -4
  50. package/src/server/update-install.ts +124 -0
  51. package/src/server/update-worker.ts +78 -0
  52. package/src/server/updates.ts +152 -0
  53. package/src/server/workspace.ts +450 -60
  54. package/src/shared.ts +120 -10
  55. package/src/skill-icons.ts +18 -0
  56. package/src/skill-metadata.ts +45 -0
  57. package/src/task-caption.ts +20 -0
  58. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  59. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  60. package/vendor/latex-review/dist/web/index.html +2 -2
  61. package/vendor/latex-review/src/core/bib.ts +692 -0
  62. package/vendor/latex-review/src/core/build.ts +5 -1
  63. package/vendor/latex-review/src/core/changes.ts +39 -0
  64. package/vendor/latex-review/src/core/delivery.ts +109 -0
  65. package/vendor/latex-review/src/core/review.ts +12 -4
  66. package/vendor/latex-review/src/core/store.ts +3 -0
  67. package/vendor/latex-review/src/core/tasks.ts +5 -2
  68. package/vendor/latex-review/src/core/verify.ts +784 -0
  69. package/vendor/latex-review/src/core/view.ts +204 -0
  70. package/vendor/latex-review/src/render/round.ts +11 -0
  71. package/vendor/latex-review/src/server.ts +81 -1
  72. package/vendor/latex-review/src/types.ts +141 -0
  73. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  74. package/vendor/latex-review/src/web/i18n.ts +1 -1
  75. package/vendor/latex-review/src/web/overview.tsx +1 -5
  76. package/vendor/latex-review/src/web/references.tsx +499 -0
  77. package/vendor/latex-review/src/web/review.tsx +381 -230
  78. package/vendor/latex-review/src/web/shortcuts.ts +53 -0
  79. package/vendor/latex-review/src/web/style.css +295 -0
  80. package/vendor/latex-review/src/web/zh-CN.ts +95 -26
  81. package/dist/assets/index-BNGqA0_L.css +0 -1
  82. package/dist/assets/index-Bt9UXsGX.js +0 -222
  83. package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
  84. package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
  85. package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
@@ -1,3 +1,4 @@
1
+ import type { ReviewSnapshot } from './ai-review.ts';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { EventEmitter } from 'node:events';
@@ -7,15 +8,17 @@ import path from 'node:path';
7
8
  import { promisify } from 'node:util';
8
9
  import { pathToFileURL } from 'node:url';
9
10
  import { ENGINE } from './paths.ts';
10
- import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
11
+ import type { Annotation, BuildProblem, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
11
12
  import { notesInstructions } from '../notes.ts';
12
13
  import { ignorePattern } from '../commit.ts';
14
+ import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
15
+ import { collectProblems, countProblems, locateProblems, scrubLog, sortProblems, type StoredProblem } from './build-problems.ts';
13
16
 
14
17
  type Rect = [number, number, number, number];
15
18
 
16
19
  const exec = promisify(execFile);
17
20
  async function git(root: string, args: string[], env: NodeJS.ProcessEnv = {}) {
18
- return (await exec('git', args, { cwd: root, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024 })).stdout;
21
+ return (await exec('git', args, { cwd: root, timeout: 30_000, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024 })).stdout;
19
22
  }
20
23
 
21
24
  // Porcelain v2 keeps paths with spaces intact under -z and reports branch,
@@ -53,7 +56,7 @@ export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'h
53
56
 
54
57
  export async function gitStatus(root: string): Promise<GitStatus> {
55
58
  const [status, numstat, head, remotes] = await Promise.all([
56
- git(root, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all']),
59
+ git(root, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { GIT_OPTIONAL_LOCKS: '0' }),
57
60
  git(root, ['diff', '--numstat', '-z', 'HEAD']).catch(() => ''),
58
61
  git(root, ['log', '-1', '--format=%h%x00%s%x00%ct']).catch(() => ''),
59
62
  git(root, ['remote']).catch(() => ''),
@@ -339,44 +342,241 @@ export async function pdfWords(pdf: string, page: number) {
339
342
  } finally { document.close(); }
340
343
  }
341
344
 
342
- interface Build { tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string }
345
+ // `problems` come from the last finished build. They are placed on the PDF
346
+ // named `problemsKey`; a failed build has no PDF of its own to place them on.
347
+ interface Build {
348
+ tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string;
349
+ problems?: StoredProblem[]; problemsKey?: string; log?: string; logFolder?: string;
350
+ }
351
+
352
+
343
353
 
344
354
  export class Manuscripts extends EventEmitter {
345
355
  private builds = new Map<string, Build>();
356
+ private oldPdfs = new Map<string, string | null>();
357
+ private placements = new Map<string, (Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'>) | null>();
358
+ // Placed problems per problem list; a rebuild in progress shares its predecessor's list.
359
+ private located = new WeakMap<StoredProblem[], Promise<BuildProblem[]>>();
360
+ get busyForUpdate() { return [...this.builds.values()].some(build => build.status === 'building'); }
361
+ busyForProject(id: string) { return this.builds.get(id)?.status === 'building'; }
362
+ forgetProject(id: string) { this.builds.delete(id); }
346
363
 
347
364
  async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
348
365
  const [status, tree] = await Promise.all([gitStatus(root), workingTree(root)]);
349
366
  let build = this.builds.get(projectId);
350
- if (!build || (build.status !== 'building' && (build.tree !== tree || (rebuild && build.status === 'failed')))) build = this.start(projectId, root, tree, build);
367
+ if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
351
368
  const { status: state, key, pages, builtAt, error } = build;
352
- return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready' } };
369
+ return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems) } };
353
370
  }
354
371
 
355
372
  // Keep the previous pages visible while a newer working tree compiles.
356
- private start(projectId: string, root: string, tree: string, previous?: Build) {
357
- const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, pages: previous?.pages, builtAt: previous?.builtAt };
373
+ private start(projectId: string, root: string, tree: string, previous?: Build, force = false) {
374
+ if (previous?.key && previous.pdf) this.oldPdfs.set(`${root}:${previous.key}`, previous.pdf);
375
+ const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt, problems: previous?.problems, problemsKey: previous?.problemsKey, log: previous?.log, logFolder: previous?.logFolder };
358
376
  this.builds.set(projectId, build);
359
377
  void (async () => {
378
+ let project: { source_dir?: string; doc_dir?: string; state: string } | undefined;
360
379
  try {
361
380
  const { Builder, Repo, loadProject, mupdf } = await engine();
362
- const project = loadProject(root), result = await new Builder(project, new Repo(root)).build(tree, ['full']);
381
+ project = loadProject(root);
382
+ const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force });
363
383
  const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
364
384
  const pages: [number, number][] = [];
365
385
  for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
366
386
  document.destroy?.();
367
387
  const key = createHash('sha256').update(result.key + result.pdf).digest('hex').slice(0, 16);
368
388
  if (this.builds.get(projectId) !== build) return;
369
- Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project.source_dir || '', pages, builtAt: Date.now(), error: undefined });
389
+ const log = String(result.pdf).replace(/\.pdf$/, '.log');
390
+ let problems: StoredProblem[] = [];
391
+ try { problems = collectProblems(fs.readFileSync(log), { cwd: result.cwd, root: result.root, sourceDir: project!.source_dir || '', outDir: path.dirname(result.pdf) }); } catch { /* A build without a readable log reports no problems. */ }
392
+ Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now(), error: undefined, problems, problemsKey: key, log: fs.existsSync(log) ? log : undefined, logFolder: path.dirname(result.root) });
393
+ // Place the problems now, so the list is ready when someone opens it.
394
+ if (problems.length) void this.problems(projectId).catch(() => undefined);
370
395
  } catch (error) {
371
396
  if (this.builds.get(projectId) !== build) return;
372
- const detail = error as { message?: string; details?: string[] };
373
- Object.assign(build, { status: 'failed', error: [detail.message, ...(detail.details || [])].filter(Boolean).join('\n') || 'The manuscript could not be compiled.' });
397
+ const detail = error as { message?: string; details?: string[]; log?: string };
398
+ const message = [detail.message, ...(detail.details || [])].filter(Boolean).join('\n') || 'The manuscript could not be compiled.';
399
+ // The engine keeps latexmk's transcript and the TeX log of a failed
400
+ // build; its sources are gone, so files resolve in the working tree.
401
+ let problems: StoredProblem[] = [];
402
+ if (detail.log && project) try {
403
+ const kept = fs.readFileSync(detail.log), marker = Buffer.from('\n\n--- TeX log ---\n'), at = kept.indexOf(marker);
404
+ // TeX stopped in its first pass, before references resolve, so only
405
+ // the errors of a failed run mean anything.
406
+ if (at >= 0) problems = collectProblems(kept.subarray(at + marker.length), { cwd: path.join(root, project.doc_dir || '.'), root: path.join(root, project.source_dir || '.'), sourceDir: project.source_dir || '' })
407
+ .filter(problem => problem.severity === 'error').map(({ source: _source, ...problem }) => problem);
408
+ } catch { /* Fall back to the compiler's own message below. */ }
409
+ // BibTeX, Biber, latexmk and helper programs fail outside the TeX log;
410
+ // whatever the compiler said that the parsed errors do not cover stays.
411
+ const plain = (text: string) => text.replace(/\s+/g, ' ').trim();
412
+ const unexplained = (detail.details || []).filter(line => line.trim() && !/==> Fatal error occurred|^Emergency stop/.test(line) && !problems.some(problem => plain(line).includes(plain(problem.message).replace(/^[^:]+:\d+: /, ''))));
413
+ const logFolder = detail.log && project ? path.join(project.state, 'cache/builds', path.basename(detail.log, '.log')) : undefined;
414
+ if (!problems.length || unexplained.length) problems.push({ kind: 'error', severity: 'error', message: scrubLog([...(problems.length ? [] : [detail.message || 'The manuscript could not be compiled.']), ...unexplained].join('\n'), logFolder) });
415
+ // The previous PDF stays on screen, and so do its warnings and places.
416
+ const shown = build.problemsKey && build.problemsKey === build.key ? (build.problems || []).filter(problem => problem.severity !== 'error') : [];
417
+ Object.assign(build, { status: 'failed', error: message, problems: [...problems, ...shown], problemsKey: shown.length ? build.problemsKey : undefined, log: detail.log && fs.existsSync(detail.log) ? detail.log : undefined, logFolder });
374
418
  }
375
419
  this.emit('change', projectId);
376
420
  })();
377
421
  return build;
378
422
  }
379
423
 
424
+ // Old annotations keep their saved content. Only their proven PDF position
425
+ // changes; ambiguous or missing content stays on the earlier version.
426
+ async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
427
+ const build = this.builds.get(projectId);
428
+ if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
429
+ const { PdfDocument, loadProject } = await engine();
430
+ const current = new PdfDocument(build.pdf), target = new AnnotationPages(current);
431
+ const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
432
+ try {
433
+ const output: Annotation[] = [];
434
+ for (const note of notes) {
435
+ if (note.key === build.key) { output.push(note); continue; }
436
+ const cacheKey = JSON.stringify([projectId, build.key, note.key, note.page, note.rect, note.rects, note.quote]);
437
+ if (!this.placements.has(cacheKey)) {
438
+ let placement: Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'> | null = null;
439
+ try {
440
+ const pdf = await this.cachedPdf(root, note.key);
441
+ if (pdf) {
442
+ if (!old.has(pdf)) { const document = new PdfDocument(pdf); old.set(pdf, { document, pages: new AnnotationPages(document) }); }
443
+ const match = placeAnnotation(note, old.get(pdf)!.pages, target);
444
+ if (match) {
445
+ const { source } = await this.locate(projectId, build.key, match.page, match.rect, match.rects, note.quote);
446
+ placement = { ...match, key: build.key, source };
447
+ }
448
+ }
449
+ } catch { /* Keep the annotation intact when its position cannot be verified. */ }
450
+ this.placements.set(cacheKey, placement);
451
+ }
452
+ const placement = this.placements.get(cacheKey);
453
+ output.push(placement ? { ...note, ...placement } : note);
454
+ }
455
+ if (this.placements.size > 2000) this.placements.clear();
456
+ if (this.oldPdfs.size > 500) this.oldPdfs.clear();
457
+ return this.builds.get(projectId)?.key === build.key ? output : notes;
458
+ } finally { current.close(); for (const entry of old.values()) entry.document.close(); }
459
+ }
460
+
461
+ // Every problem of the last finished build, placed on its PDF when there is
462
+ // one. Placing runs SyncTeX per problem, once per build.
463
+ async problems(projectId: string): Promise<ManuscriptProblems> {
464
+ const build = this.builds.get(projectId);
465
+ if (!build?.problems) return { status: build?.status || 'building', problems: [], log: Boolean(build?.log) };
466
+ const { problems, problemsKey: key, pdf } = build;
467
+ if (!key || key !== build.key || !pdf) return { status: build.status, problems: sortProblems(problems.map(({ source: _source, occurrence: _occurrence, ...problem }) => problem)), log: Boolean(build.log) };
468
+ let placed = this.located.get(problems);
469
+ if (!placed) {
470
+ placed = (async () => {
471
+ const { PdfDocument } = await engine();
472
+ const document = new PdfDocument(pdf), pages = new Map<number, ReturnType<typeof document.page>>();
473
+ try {
474
+ return sortProblems(await locateProblems(problems, pdf, page => {
475
+ if (!Number.isInteger(page) || page < 0 || page >= document.length) return null;
476
+ if (!pages.has(page)) pages.set(page, document.page(page));
477
+ return pages.get(page)!;
478
+ }));
479
+ } finally { for (const page of pages.values()) page.close(); document.close(); }
480
+ })();
481
+ placed.catch(() => this.located.delete(problems));
482
+ this.located.set(problems, placed);
483
+ }
484
+ return { status: build.status, key, problems: await placed, log: Boolean(build.log) };
485
+ }
486
+
487
+ log(projectId: string) {
488
+ const build = this.builds.get(projectId);
489
+ if (!build?.log || !fs.existsSync(build.log)) throw Object.assign(new Error('There is no compilation log yet.'), { statusCode: 404 });
490
+ return scrubLog(fs.readFileSync(build.log, 'utf8'), build.logFolder);
491
+ }
492
+
493
+ // Compile the task's frozen source tree, then retain its PDF outside the build cache.
494
+ async captureReview(root: string, worktree: string, taskId: string): Promise<ReviewSnapshot> {
495
+ const { Builder, Repo, loadProject } = await engine();
496
+ const tree = await workingTree(worktree);
497
+ const result = await new Builder(loadProject(root), new Repo(root)).build(tree, ['full']);
498
+ const pdf = await this.preserveReviewPdf(root, taskId, result.pdf);
499
+ const key = createHash('sha256').update(fs.readFileSync(pdf)).digest('hex').slice(0, 16);
500
+ return { ...await this.snapshotPdf(pdf, key), pdf };
501
+ }
502
+
503
+ private async reviewPdfPath(root: string, taskId: string) {
504
+ const { loadProject } = await engine();
505
+ return path.join(loadProject(root).state, 'ai-reviews', createHash('sha256').update(taskId).digest('hex'), 'review.pdf');
506
+ }
507
+
508
+ async savedReviewPdf(root: string, taskId: string): Promise<string | null> {
509
+ const pdf = await this.reviewPdfPath(root, taskId);
510
+ return fs.existsSync(pdf) ? pdf : null;
511
+ }
512
+
513
+ async preserveReviewPdf(root: string, taskId: string, source: string) {
514
+ const pdf = await this.reviewPdfPath(root, taskId), folder = path.dirname(pdf);
515
+ fs.mkdirSync(folder, { recursive: true });
516
+ // A retry always uses the original task copy, even after its build cache is pruned.
517
+ if (!fs.existsSync(pdf)) { const temporary = path.join(folder, 'review.tmp'); fs.copyFileSync(source, temporary); fs.renameSync(temporary, pdf); }
518
+ return pdf;
519
+ }
520
+
521
+ async cachedPdf(root: string, key: string): Promise<string | null> {
522
+ const lookup = `${root}:${key}`;
523
+ if (this.oldPdfs.has(lookup)) return this.oldPdfs.get(lookup)!;
524
+ const { loadProject } = await engine();
525
+ const cache = path.join(loadProject(root).state, 'cache/builds');
526
+ let found: string | null = null;
527
+ if (fs.existsSync(cache)) for (const entry of fs.readdirSync(cache, { withFileTypes: true })) {
528
+ if (!entry.isDirectory() || entry.name.endsWith('.lock')) continue;
529
+ try {
530
+ const folder = path.join(cache, entry.name), saved = JSON.parse(fs.readFileSync(path.join(folder, 'result.json'), 'utf8'));
531
+ if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) continue;
532
+ if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === key && fs.existsSync(saved.pdf)) { found = saved.pdf; break; }
533
+ } catch { /* Incomplete builds have no usable PDF. */ }
534
+ }
535
+ this.oldPdfs.set(lookup, found); return found;
536
+ }
537
+
538
+ async placeReviewNote(projectId: string, root: string, note: Annotation, pdf: string): Promise<Annotation> {
539
+ const state = await this.state(projectId, root);
540
+ if (!state.build.current || !state.build.key) throw new Error('Wait for the current manuscript PDF to finish compiling, then copy the suggestion again.');
541
+ const key = state.build.key, { PdfDocument } = await engine();
542
+ const original = new PdfDocument(pdf), current = new PdfDocument(this.pdf(projectId, key));
543
+ try {
544
+ const match = placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
545
+ if (!match) throw new Error('This passage could not be located reliably in the current manuscript. The suggestion remains in the review task.');
546
+ const { source } = await this.locate(projectId, key, match.page, match.rect, match.rects, note.quote);
547
+ const latest = await this.state(projectId, root);
548
+ if (!latest.build.current || latest.build.key !== key) throw new Error('The manuscript changed while copying this suggestion. Try again.');
549
+ return { ...note, ...match, key, source };
550
+ } finally { original.close(); current.close(); }
551
+ }
552
+
553
+ async reviewSnapshot(projectId: string, key: string): Promise<ReviewSnapshot> {
554
+ return this.snapshotPdf(this.pdf(projectId, key), key);
555
+ }
556
+
557
+ private async snapshotPdf(pdf: string, key: string): Promise<ReviewSnapshot> {
558
+ const { PdfDocument } = await engine();
559
+ const document = new PdfDocument(pdf), lines: ReviewSnapshot['lines'] = [];
560
+ let length = 0;
561
+ try {
562
+ for (let index = 0; index < document.length; index++) {
563
+ const page = document.page(index), [left, top, right, bottom] = page.bounds;
564
+ for (const [number, line] of page.lines.entries()) {
565
+ const text = line.chars.map((char: { text: string }) => char.text).join('').trim();
566
+ if (!text) continue;
567
+ length += text.length;
568
+ if (length > 180000) throw new Error('This PDF is too long for one AI review (180,000 characters maximum).');
569
+ const [x0, y0, x1, y1] = line.rect;
570
+ const rect = [(x0 - left) / (right - left), (y0 - top) / (bottom - top), (x1 - left) / (right - left), (y1 - top) / (bottom - top)].map(value => Math.max(0, Math.min(1, value))) as Rect;
571
+ if (rect[2] <= rect[0] || rect[3] <= rect[1]) continue;
572
+ lines.push({ id: `p${index + 1}-l${number + 1}`, page: index, text, rect });
573
+ }
574
+ }
575
+ if (!lines.length) throw new Error('This PDF has no selectable text to review.');
576
+ return { key, lines };
577
+ } finally { document.close(); }
578
+ }
579
+
380
580
  pdf(projectId: string, key: string) {
381
581
  const build = this.builds.get(projectId);
382
582
  if (!build?.pdf || build.key !== key) throw Object.assign(new Error('This manuscript preview is out of date. Refresh it and try again.'), { statusCode: 404 });
@@ -0,0 +1,65 @@
1
+ import type { NoteChange, NoteResult, NoteSummary, Run, Task, TimelineItem } from '../shared.ts';
2
+ import { noteAnchor, readNotesInstructions } from '../notes.ts';
3
+ import { taskRoute, type ReviewClient } from './review.ts';
4
+
5
+ // Only the fields needed to trace notes; the review engine owns the full format.
6
+ export interface NoteRound {
7
+ round: number;
8
+ units: { id: string; file: string; old_start: number; old_end: number; old_text: string; new_text: string }[];
9
+ sections: { blocks: NoteBlock[] }[];
10
+ loose: NoteBlock[];
11
+ }
12
+ interface NoteBlock { kind: string; units?: string[]; before?: { text?: { text: string }[] }[]; identical?: boolean }
13
+ const normalize = (text: string) => text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
14
+
15
+ export function matchNoteChanges(note: NoteSummary, detail: NoteRound, decisions: Record<string, string>): NoteChange[] {
16
+ const anchor = noteAnchor(note), quote = normalize(note.quote);
17
+ const units = new Map(detail.units.map(unit => [unit.id, unit]));
18
+ const blocks = [...detail.sections.flatMap(section => section.blocks.filter(block => block.kind === 'change')), ...detail.loose];
19
+ const matches = blocks.flatMap<NoteChange>((block, stop) => {
20
+ const known = (block.units || []).map(id => units.get(id)).filter(unit => Boolean(unit) && unit!.old_text !== unit!.new_text) as NoteRound['units'];
21
+ // Manuscript positions refer to the source before this revision. Review
22
+ // selections refer to an older proposal, so prefer their actual PDF text.
23
+ const source = anchor.round === undefined ? known.filter(unit => anchor.sources.some(place => place.file === unit.file && Math.max(place.lines[0], unit.old_start) <= Math.min(place.lines[1], unit.old_end))) : [];
24
+ const sameFiles = anchor.sources.length ? known.filter(unit => anchor.sources.some(place => place.file === unit.file)) : known;
25
+ const textMatches = quote.length >= 4 && sameFiles.length && (
26
+ normalize((block.before || []).flatMap(strip => (strip.text || []).map(line => line.text)).join(' ')).includes(quote)
27
+ || sameFiles.some(unit => normalize(unit.old_text).includes(quote))
28
+ );
29
+ // Region notes can lack text; their location is the remaining evidence.
30
+ const region = !quote && anchor.round !== undefined ? sameFiles.filter(unit => anchor.sources.some(place => place.file === unit.file && Math.max(place.lines[0], unit.old_start) <= Math.min(place.lines[1], unit.old_end))) : [];
31
+ const matched = source.length ? source : textMatches ? sameFiles : region;
32
+ if (!matched.length) return [];
33
+ const states = new Set(matched.map(unit => decisions[unit.id] || 'pending'));
34
+ const state = states.size === 1 ? [...states][0] : 'mixed';
35
+ return [{ round: detail.round, stop, units: matched.map(unit => unit.id), state: (['accepted', 'rejected', 'commented', 'mixed'].includes(state) ? state : 'pending') as NoteChange['state'], match: source.length || region.length ? 'source' : 'quote' }];
36
+ });
37
+ // A short word needs a unique location. This also covers titles and macros:
38
+ // SyncTeX may point at their use while the changed definition lives elsewhere.
39
+ return quote.length < 8 && matches.filter(match => match.match === 'quote').length > 1 ? matches.filter(match => match.match === 'source') : matches;
40
+ }
41
+
42
+ export async function taskNoteResults(task: Task, items: TimelineItem[], runs: Run[], client: ReviewClient): Promise<NoteResult[]> {
43
+ const byId = new Map(runs.map(run => [run.id, run]));
44
+ const results: NoteResult[] = items.filter(item => item.type === 'user').flatMap(item => (item.meta?.notes || readNotesInstructions(item.text) || []).map((note, index) => {
45
+ const run = byId.get(item.id), origin = noteAnchor(note).round || 0;
46
+ // Older runs predate recorded round IDs. Only their finished review is a
47
+ // fallback; a queued follow-up must never point at an earlier proposal.
48
+ const legacy = (!run || run.status === 'completed') && !['running', 'queued', 'building', 'waiting_input'].includes(task.status) && (task.review?.round || 0) > origin;
49
+ const round = run?.reviewRound !== undefined ? run.reviewRound || null : legacy ? task.review?.round || null : null;
50
+ return { key: `${item.id}:${index}`, itemId: item.id, index, note, round, state: round ? 'unmatched' : run?.reviewRound === null ? 'unmatched' : 'waiting', changes: [] } as NoteResult;
51
+ }));
52
+ const rounds = [...new Set(results.flatMap(result => result.round ? [result.round] : []))];
53
+ await Promise.all(rounds.map(async round => {
54
+ const [detail, state] = await Promise.all([
55
+ client.get<NoteRound>(`${taskRoute(task.name)}/rounds/${round}`),
56
+ client.get<{ decisions: Record<string, string> }>(`${taskRoute(task.name)}/state?round=${round}`),
57
+ ]);
58
+ if (detail.round !== round || !Array.isArray(detail.units) || !Array.isArray(detail.sections) || !Array.isArray(detail.loose) || !state.decisions) throw new Error('The review could not be loaded. Try again.');
59
+ for (const result of results.filter(result => result.round === round)) {
60
+ result.changes = matchNoteChanges(result.note, detail, state.decisions);
61
+ result.state = result.changes.length ? 'matched' : 'unmatched';
62
+ }
63
+ }));
64
+ return results;
65
+ }
@@ -12,4 +12,4 @@ export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
12
12
  // port its proxy (such as Tailscale Funnel) forwards to.
13
13
  export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
14
14
  export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
15
- export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
15
+ export const CODEX = process.env.PAPILOT_CODEX_BIN || [path.join(ROOT, 'node_modules/.bin/codex'), path.join(DATA, 'agents/node_modules/.bin/codex')].find(file => fs.existsSync(file)) || 'codex';
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { DATA, ENGINE } from './paths.ts';
5
+ import type { BibliographyReport, ReferenceCheck } from '../shared.ts';
6
+
7
+ // The paper's bibliography as it is on disk: every entry, where it is cited,
8
+ // what is wrong with it without looking anything up, and, when asked, what
9
+ // Crossref, DBLP and arXiv know of it. The review engine does the reading
10
+ // and the checking; this is where Papilot asks it for a whole paper.
11
+
12
+ type Input = { id: string; type: string; fields: Record<string, string> };
13
+ interface Checker { lookup(inputs: Input[], start?: boolean, retry?: boolean): { checks: Record<string, ReferenceCheck>; pending: number } }
14
+ interface Confirmations { of(inputs: Input[]): Record<string, number>; set(input: Input, on: boolean): void }
15
+ interface Engine {
16
+ bibliographyReport(doc: { read(file: string): string | null; main: string; outline: unknown[] }): Omit<BibliographyReport, 'checks' | 'pending' | 'confirmed'>;
17
+ buildOutline(read: (file: string) => string | null, main: string): { nodes: unknown[] };
18
+ loadProject(root: string): { main: string };
19
+ checkInputs(references: unknown): Input[];
20
+ entryInputs(entries: unknown[]): Input[];
21
+ ReferenceChecker: new (file: string) => Checker;
22
+ Confirmations: new (file: string) => Confirmations;
23
+ Repo: new (root: string) => { gitDir(): string };
24
+ }
25
+ let loading: Promise<Engine> | undefined;
26
+ export function engine(): Promise<Engine> {
27
+ const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
28
+ loading ??= Promise.all(['src/core/bib.ts', 'src/core/outline.ts', 'src/core/config.ts', 'src/core/verify.ts', 'src/core/git.ts'].map(load))
29
+ .then(modules => Object.assign({}, ...modules) as Engine);
30
+ return loading;
31
+ }
32
+ // One checker for Papilot's process, so that the panel and the review of
33
+ // uncommitted changes share their results and their file.
34
+ let shared: Checker | undefined;
35
+ export async function checker(file = path.join(DATA, 'references.json')) {
36
+ const { ReferenceChecker } = await engine();
37
+ return shared ??= new ReferenceChecker(file);
38
+ }
39
+ // Confirmations live beside the review engine's state in the repository, so
40
+ // a task's review, the uncommitted changes and this panel agree.
41
+ export async function confirmations(root: string) {
42
+ const { Confirmations, Repo } = await engine();
43
+ return new Confirmations(path.join(new Repo(root).gitDir(), 'latex-review', 'references-confirmed.json'));
44
+ }
45
+
46
+ // A file of the paper, read only from inside its folder.
47
+ function reader(root: string) {
48
+ const base = fs.realpathSync(root);
49
+ return (file: string) => {
50
+ const target = path.resolve(base, file);
51
+ if (target !== base && !target.startsWith(base + path.sep)) return null;
52
+ try { return fs.statSync(target).isFile() ? fs.readFileSync(target, 'utf8') : null; } catch { return null; }
53
+ };
54
+ }
55
+ async function report(root: string) {
56
+ const mods = await engine(), read = reader(root), { main } = mods.loadProject(root);
57
+ const result = mods.bibliographyReport({ read, main, outline: mods.buildOutline(read, main).nodes });
58
+ return { result, inputs: mods.entryInputs(result.entries) };
59
+ }
60
+
61
+ export async function bibliography(root: string, check: boolean, retry: boolean): Promise<BibliographyReport> {
62
+ const { result, inputs } = await report(root);
63
+ return { ...result, ...(await checker()).lookup(inputs, check, retry), confirmed: (await confirmations(root)).of(inputs) };
64
+ }
65
+ export async function confirmEntry(root: string, id: string, on: boolean) {
66
+ const { inputs } = await report(root), input = inputs.find(item => item.id === id);
67
+ if (!input) throw Object.assign(new Error('This entry is no longer in the bibliography. Refresh the panel.'), { statusCode: 404 });
68
+ (await confirmations(root)).set(input, on);
69
+ return bibliography(root, false, false);
70
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { ENGINE } from './paths.ts';
4
4
  import type { Task } from '../shared.ts';
5
5
 
6
- export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown): Promise<T> }
6
+ export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown, timeout?: number): Promise<T> }
7
7
  let modulePromise: Promise<{ connect: (root: string) => Promise<ReviewClient> }> | null = null;
8
8
  export function reviewClient(root: string): Promise<ReviewClient> {
9
9
  modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
@@ -34,14 +34,15 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
34
34
  else if (task.status === 'building') decisionLock = 'Wait for the PDF build before accepting, rejecting, or undoing changes. You can still add comments.';
35
35
  else if (['running', 'waiting_input', 'queued'].includes(task.status)) decisionLock = 'Pause this task in Papilot before accepting, rejecting, or undoing changes. You can still add comments.';
36
36
  else if (task.pendingReview) decisionLock = 'Wait for the current review action before accepting, rejecting, or undoing changes. You can still add comments.';
37
+ else if (task.delivery?.pending) decisionLock = 'Retry the pending delivery before changing review decisions.';
37
38
  // Changes the task has committed are in the manuscript too.
38
- return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed ? 1 : 0) } } : {}) };
39
+ return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed || task.delivery?.pending ? 1 : 0), ...(task.deferred ? { deferred: true, destination: task.delivery?.pending ? undefined : task.delivery?.mode } : {}) } } : {}) };
39
40
  }
40
41
 
41
42
  export function allowedReviewRoute(route: string, method: string): boolean {
42
43
  if (route.includes('..') || route.includes('\\')) return false;
43
- if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d+)?))?)?$/.test(route);
44
- return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note)$/.test(route);
44
+ if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d+|\/references)?))?)?$/.test(route);
45
+ return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note|rounds\/\d+\/references\/confirm)$/.test(route);
45
46
  }
46
47
  export function rewriteReviewJson(value: any, prefix: string): any {
47
48
  if (Array.isArray(value)) return value.map(v => rewriteReviewJson(v, prefix));
@@ -49,6 +50,6 @@ export function rewriteReviewJson(value: any, prefix: string): any {
49
50
  return typeof value === 'string' && value.startsWith('/files/') ? prefix + value : value;
50
51
  }
51
52
  export function rewriteReviewHtml(html: string, prefix: string): string {
52
- const script = `<script>(()=>{const prefix=${JSON.stringify(prefix)};if(!crypto.randomUUID)crypto.randomUUID=()=>Array.from(crypto.getRandomValues(new Uint8Array(20)),b=>b.toString(16).padStart(2,'0')).join('');const original=window.fetch;window.fetch=(input,init)=>{if(typeof input==='string'&&input.startsWith('/api/'))input=prefix+input;return original.call(window,input,init)};})();</script>`;
53
+ const script = `<script>(()=>{const prefix=${JSON.stringify(prefix)};if(!crypto.randomUUID)crypto.randomUUID=()=>Array.from(crypto.getRandomValues(new Uint8Array(20)),b=>b.toString(16).padStart(2,'0')).join('');const original=window.fetch;window.fetch=(input,init)=>{if(typeof input==='string'&&input.startsWith('/api/'))input=prefix+input;const response=original.call(window,input,init);if(typeof input==='string'&&/\\/rounds\\/\\d+$/.test(input))response.then(r=>{if(r.ok)r.clone().json().then(value=>{if(Number.isSafeInteger(value.round)){document.documentElement.dataset.papilotRound=String(value.round);window.dispatchEvent(new Event('papilot:round'));}}).catch(()=>{});}).catch(()=>{});return response};})();</script>`;
53
54
  return html.replace(/(src|href)="\/(?!\/)/g, `$1="${prefix}/`).replace('<head>', '<head>' + script);
54
55
  }
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import type { Skill } from '../shared.ts';
5
+ import type { LibrarySkill, SkillCatalog, SkillDocument } from '../skill-library.ts';
6
+ import { listedSkill, loadSkills, parseSkill, skillFiles, skillRoots, SKILL_NAME } from './skills.ts';
7
+
8
+ const fail = (message: string, statusCode = 400): never => { throw Object.assign(new Error(message), { statusCode }); };
9
+ const revision = (content: string) => createHash('sha256').update(content).digest('hex');
10
+
11
+ export class SkillLibrary {
12
+ constructor(readonly roots = skillRoots) {}
13
+
14
+ private root(source: unknown, paper?: string): string {
15
+ if (source === 'builtin' || source === 'user') return this.roots[source];
16
+ if (source === 'paper' && paper) return path.join(paper, '.papilot', 'skills');
17
+ return fail('Choose a valid skill location.');
18
+ }
19
+
20
+ private id(id: unknown): asserts id is string {
21
+ if (typeof id !== 'string' || !SKILL_NAME.test(id)) fail('Use 1–48 lowercase letters, numbers or hyphens for the skill ID.');
22
+ }
23
+
24
+ // A paper can contain links checked into Git. Never follow those links when
25
+ // editing, nor replace a linked SKILL.md or a hard link to another file.
26
+ private safe(file: string, root: string, paper?: string) {
27
+ const boundary = path.resolve(paper || root), target = path.resolve(file);
28
+ if (!target.startsWith(boundary + path.sep)) fail('The skill must stay inside its skills folder.');
29
+ let cursor = boundary;
30
+ for (const part of ['', ...path.relative(boundary, target).split(path.sep)]) {
31
+ if (part) cursor = path.join(cursor, part);
32
+ try {
33
+ const stat = fs.lstatSync(cursor);
34
+ if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1)) fail('Linked skill files cannot be edited here.');
35
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
36
+ }
37
+ }
38
+
39
+ private locate(source: unknown, id: unknown, paper?: string) {
40
+ this.id(id);
41
+ const root = this.root(source, paper);
42
+ const matches = skillFiles(root).filter(file => file.id === id && fs.existsSync(file.file));
43
+ if (matches.length > 1) fail('Several files use this skill ID. Give them distinct names before editing.');
44
+ const found = matches[0];
45
+ const file = found?.file || path.join(root, id, 'SKILL.md');
46
+ this.safe(file, root, source === 'paper' ? paper : undefined);
47
+ return { root, file, found };
48
+ }
49
+
50
+ list(paper?: string): SkillCatalog {
51
+ const effective = new Map(loadSkills(paper, this.roots).map(skill => [skill.id, skill.source]));
52
+ const skills: LibrarySkill[] = [];
53
+ const sources: Skill['source'][] = ['user', ...(paper ? ['paper' as const] : []), 'builtin'];
54
+ for (const source of sources) {
55
+ const root = this.root(source, paper);
56
+ const unique = new Map<string, LibrarySkill>();
57
+ for (const { id, file } of skillFiles(root)) {
58
+ try {
59
+ this.safe(file, root, source === 'paper' ? paper : undefined);
60
+ const skill = parseSkill(fs.readFileSync(file, 'utf8'), id, source);
61
+ if (skill) unique.set(id, { ...listedSkill(skill), location: file, active: effective.get(id) === source });
62
+ } catch { /* A broken or linked file is not editable from the library. */ }
63
+ }
64
+ skills.push(...unique.values());
65
+ }
66
+ return { skills, locations: { user: this.roots.user, ...(paper ? { paper: this.root('paper', paper) } : {}) } };
67
+ }
68
+
69
+ read(source: unknown, id: unknown, paper?: string): SkillDocument {
70
+ const { file, found } = this.locate(source, id, paper);
71
+ if (!found) return fail('This skill no longer exists. Refresh the list.', 404);
72
+ const content = fs.readFileSync(file, 'utf8');
73
+ const skill = parseSkill(content, id as string, source as Skill['source']);
74
+ if (!skill) return fail('The skill must contain instructions.');
75
+ return { ...listedSkill(skill), location: file, active: loadSkills(paper, this.roots).find(item => item.id === id)?.source === source, content, revision: revision(content) };
76
+ }
77
+
78
+ save(source: unknown, id: unknown, content: unknown, expected: unknown, paper?: string): SkillDocument {
79
+ if (source === 'builtin') fail('Built-in skills are read-only. Make a custom copy first.', 403);
80
+ if (typeof content !== 'string' || content.length > 20000 || !content.trim() || content.includes('\0')) fail('Skill content must contain 1–20,000 characters.');
81
+ if (!parseSkill(content as string, String(id), source as Skill['source'])) fail('The skill must contain instructions.');
82
+ const { file, found } = this.locate(source, id, paper);
83
+ if (found) {
84
+ if (expected === undefined) fail('A skill with this ID already exists in this location.', 409);
85
+ if (typeof expected !== 'string' || revision(fs.readFileSync(file, 'utf8')) !== expected) fail('This skill changed on disk. Reload it before saving.', 409);
86
+ } else if (expected !== undefined) fail('This skill was removed on disk. Reload the list before saving.', 409);
87
+ fs.mkdirSync(path.dirname(file), { recursive: true });
88
+ if (!found) {
89
+ // Exclusive creation also prevents a retry from silently overwriting a
90
+ // file created by another browser since the catalog was loaded.
91
+ try { fs.writeFileSync(file, content as string, { flag: 'wx', mode: 0o600 }); }
92
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') fail('A skill with this ID already exists in this location.', 409); throw error; }
93
+ } else {
94
+ const temporary = path.join(path.dirname(file), `.skill-${randomUUID()}.tmp`);
95
+ try {
96
+ fs.writeFileSync(temporary, content as string, { flag: 'wx', mode: fs.statSync(file).mode & 0o777 });
97
+ fs.renameSync(temporary, file);
98
+ } finally { fs.rmSync(temporary, { force: true }); }
99
+ }
100
+ return this.read(source, id, paper);
101
+ }
102
+
103
+ remove(source: unknown, id: unknown, expected: unknown, paper?: string) {
104
+ if (source === 'builtin') fail('Built-in skills are read-only.', 403);
105
+ const { file, found } = this.locate(source, id, paper);
106
+ if (!found) return fail('This skill no longer exists. Refresh the list.', 404);
107
+ if (typeof expected !== 'string' || revision(fs.readFileSync(file, 'utf8')) !== expected) fail('This skill changed on disk. Reload it before deleting.', 409);
108
+ fs.unlinkSync(file);
109
+ // Keep supporting scripts and references. Only remove an empty skill folder.
110
+ if (found.folder && fs.readdirSync(found.folder).length === 0) fs.rmdirSync(found.folder);
111
+ return { ok: true };
112
+ }
113
+ }
@@ -0,0 +1,19 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { Store } from './store.ts';
3
+ import { SkillLibrary } from './skill-library.ts';
4
+
5
+ export function registerSkillRoutes(app: FastifyInstance, store: Store, library = new SkillLibrary()) {
6
+ const owner = { config: { access: 'owner' as const } };
7
+ function paper(id: unknown) {
8
+ if (id === undefined) return undefined;
9
+ if (typeof id !== 'string' || !id || id.length > 64) throw new Error('Select a valid paper.');
10
+ return store.project(id).path;
11
+ }
12
+ type Query = { projectId?: string };
13
+ type Params = { source: string; id: string };
14
+ type Body = { projectId?: string; content?: unknown; revision?: unknown };
15
+ app.get<{ Querystring: Query }>('/api/skills', owner, request => library.list(paper(request.query.projectId)));
16
+ app.get<{ Params: Params; Querystring: Query }>('/api/skills/:source/:id', owner, request => library.read(request.params.source, request.params.id, paper(request.query.projectId)));
17
+ app.post<{ Params: Params; Body: Body }>('/api/skills/:source/:id', owner, request => library.save(request.params.source, request.params.id, request.body?.content, request.body?.revision, paper(request.body?.projectId)));
18
+ app.post<{ Params: Params; Body: Body }>('/api/skills/:source/:id/remove', owner, request => library.remove(request.params.source, request.params.id, request.body?.revision, paper(request.body?.projectId)));
19
+ }