papilot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +16 -4
  2. package/README.zh-CN.md +15 -3
  3. package/bin/doctor.mjs +239 -0
  4. package/bin/papilot.mjs +96 -87
  5. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/assets/index-CqhlgnOu.css +1 -0
  65. package/dist/assets/index-Czf1eCno.js +185 -0
  66. package/dist/assets/math-DEcVZfaU.css +1 -0
  67. package/dist/assets/math-Ebyrobf9.js +263 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BhJZr4qw.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/examples/papilot-paper/sections/02-using.tex +3 -3
  72. package/package.json +12 -2
  73. package/scripts/service.ts +8 -2
  74. package/skills/check-math.md +12 -0
  75. package/skills/consistency.md +11 -0
  76. package/skills/latex-warnings.md +12 -0
  77. package/skills/polish.md +13 -0
  78. package/skills/proofread.md +12 -0
  79. package/skills/reviewer-response.md +11 -0
  80. package/skills/shorten.md +12 -0
  81. package/src/conversation.ts +37 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/codex.ts +2 -2
  85. package/src/server/main.ts +21 -7
  86. package/src/server/paths.ts +2 -1
  87. package/src/server/review.ts +1 -1
  88. package/src/server/service-environment.ts +22 -0
  89. package/src/server/skills.ts +57 -0
  90. package/src/server/workspace.ts +43 -19
  91. package/src/shared.ts +10 -2
  92. package/vendor/latex-review/dist/web/assets/index-Db7pq4Cj.css +1 -0
  93. package/vendor/latex-review/dist/web/assets/index-DqkrTw0t.js +19 -0
  94. package/vendor/latex-review/dist/web/index.html +2 -2
  95. package/vendor/latex-review/src/client.ts +10 -3
  96. package/vendor/latex-review/src/core/build.ts +53 -7
  97. package/vendor/latex-review/src/core/outline.ts +6 -3
  98. package/vendor/latex-review/src/core/publish.ts +8 -6
  99. package/vendor/latex-review/src/runtime.ts +14 -0
  100. package/vendor/latex-review/src/server.ts +2 -1
  101. package/vendor/latex-review/src/types.ts +16 -1
  102. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  103. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  104. package/vendor/latex-review/src/web/i18n.ts +48 -0
  105. package/vendor/latex-review/src/web/model.ts +23 -4
  106. package/vendor/latex-review/src/web/overview.tsx +25 -20
  107. package/vendor/latex-review/src/web/review.tsx +387 -143
  108. package/vendor/latex-review/src/web/style.css +106 -0
  109. package/vendor/latex-review/src/web/zh-CN.ts +186 -0
  110. package/dist/assets/index-BhQFCMTx.js +0 -131
  111. package/dist/assets/index-CAV3-87U.css +0 -1
  112. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  113. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +0 -19
package/dist/index.html CHANGED
@@ -1,4 +1,13 @@
1
1
  <!doctype html>
2
- <html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"/><meta name="theme-color" content="#ffffff"/><meta name="description" content="Revise LaTeX papers with an agent and review the PDF changes."/><link rel="icon" type="image/png" href="/favicon.png"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><title>Papilot</title> <script type="module" crossorigin src="/assets/index-BhQFCMTx.js"></script>
3
- <link rel="stylesheet" crossorigin href="/assets/index-CAV3-87U.css">
2
+ <html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"/><meta name="theme-color" content="#ffffff"/><meta name="description" content="Revise LaTeX papers with an agent and review the PDF changes."/><link rel="icon" type="image/png" href="/favicon.png"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><title>Papilot</title><script>
3
+ // Resolve before the first paint, even while the application bundle is loading.
4
+ (() => {
5
+ let theme;
6
+ try { theme = localStorage.getItem('papilot:theme'); } catch {}
7
+ const dark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
8
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
+ document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
+ })();
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-Czf1eCno.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CqhlgnOu.css">
4
13
  </head><body><div id="root"></div></body></html>
@@ -18,17 +18,23 @@
18
18
  \usepackage[colorlinks=true,linkcolor=blue!50!black,citecolor=blue!50!black,urlcolor=blue!50!black]{hyperref}
19
19
  \usepackage[nameinlink]{cleveref}
20
20
 
21
- % thmtools lets cleveref name each kind of statement correctly although they
22
- % share one counter.
23
- \usepackage{thmtools}
24
- \declaretheorem[style=plain, numberwithin=section]{theorem}
25
- \declaretheorem[style=plain, sibling=theorem]{proposition}
26
- \declaretheorem[style=plain, sibling=theorem]{lemma}
27
- \declaretheorem[style=plain, sibling=theorem]{corollary}
28
- \declaretheorem[style=definition, sibling=theorem]{definition}
29
- \declaretheorem[style=definition, sibling=theorem]{example}
30
- \declaretheorem[style=definition, sibling=theorem]{assumption}
31
- \declaretheorem[style=remark, sibling=theorem]{remark}
21
+ % Every kind of statement shares the theorem counter, and cleveref still names
22
+ % each kind correctly. LaTeX from June 2026 does this itself and clashes with
23
+ % thmtools, which older LaTeX needs for it.
24
+ \IfFormatAtLeastTF{2026-06-01}
25
+ {\newtheorem{theorem}{Theorem}[section]
26
+ \NewDocumentCommand\statement{mmm}{\theoremstyle{#1}\newtheorem{#2}[theorem]{#3}}}
27
+ {\usepackage{thmtools}
28
+ \declaretheorem[style=plain, numberwithin=section]{theorem}
29
+ \NewDocumentCommand\statement{mmm}{\declaretheorem[style=#1, sibling=theorem, name=#3]{#2}}}
30
+ \statement{plain}{proposition}{Proposition}
31
+ \statement{plain}{lemma}{Lemma}
32
+ \statement{plain}{corollary}{Corollary}
33
+ \statement{definition}{definition}{Definition}
34
+ \statement{definition}{example}{Example}
35
+ \statement{definition}{assumption}{Assumption}
36
+ \statement{remark}{remark}{Remark}
37
+ \crefname{assumption}{assumption}{assumptions}
32
38
  \numberwithin{equation}{section}
33
39
 
34
40
  \newcommand{\R}{\mathbb{R}}
@@ -13,7 +13,7 @@ installed and started with
13
13
  npm install -g papilot
14
14
  papilot
15
15
  \end{verbatim}
16
- and then opened at \texttt{http://127.0.0.1:4317}. \emph{Add paper} registers a
16
+ and then opened at \texttt{http://127.0.0.1:7274}. \emph{Add paper} registers a
17
17
  \LaTeX{} Git repository on the host; later papers are added and switched from
18
18
  the paper menu at the top of the sidebar. A repository must have at least one
19
19
  commit and compile with \texttt{latexmk}; a custom root document is named in a
@@ -106,7 +106,7 @@ request (\cref{sec:security}):
106
106
  export PAPILOT_PUBLIC_ORIGIN=https://your-mac.your-tailnet.ts.net:8443
107
107
  export PAPILOT_TAILSCALE_LOGIN=your-tailscale-login
108
108
  papilot service install
109
- tailscale serve --bg --https=8443 http://127.0.0.1:4317
109
+ tailscale serve --bg --https=8443 http://127.0.0.1:7274
110
110
  \end{verbatim}
111
111
  A background service starts Papilot after login, restarts it if it exits and
112
112
  keeps the computer from sleeping while it runs. Without Tailscale, an SSH
@@ -120,7 +120,7 @@ tunnel serves the same purpose.
120
120
  \toprule
121
121
  Variable & Purpose \\
122
122
  \midrule
123
- \texttt{PAPILOT\_PORT} & local HTTP port, $4317$ by default \\
123
+ \texttt{PAPILOT\_PORT} & local HTTP port, $7274$ by default \\
124
124
  \texttt{PAPILOT\_DATA} & state and generated example, \texttt{\textasciitilde/.papilot} by default \\
125
125
  \texttt{PAPILOT\_CODEX\_BIN} & Codex executable \\
126
126
  \texttt{ANTHROPIC\_API\_KEY} & API key for Claude tasks \\
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -35,6 +35,8 @@
35
35
  "src/server",
36
36
  "src/shared.ts",
37
37
  "src/notes.ts",
38
+ "src/conversation.ts",
39
+ "skills",
38
40
  "dist",
39
41
  "scripts/demo.ts",
40
42
  "scripts/service.ts",
@@ -56,9 +58,12 @@
56
58
  "test": "tsx --test tests/*.test.ts",
57
59
  "setup:review": "npm --prefix vendor/latex-review ci && npm --prefix vendor/latex-review run build",
58
60
  "demo": "tsx scripts/demo.ts",
61
+ "doctor": "node bin/papilot.mjs doctor",
59
62
  "service:install": "tsx scripts/service.ts install",
60
63
  "service:stop": "tsx scripts/service.ts stop",
61
- "test:ui": "npm run build && node scripts/check-ui.mjs",
64
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs",
65
+ "site": "node scripts/build-site.mjs",
66
+ "site:video": "node scripts/build-site.mjs --video",
62
67
  "prepack": "npm run build && npm run setup:review"
63
68
  },
64
69
  "dependencies": {
@@ -78,9 +83,14 @@
78
83
  "@types/react": "^19.3.0",
79
84
  "@types/react-dom": "^19.3.0",
80
85
  "@vitejs/plugin-react": "^6.1.2",
86
+ "katex": "^0.16.47",
81
87
  "pdfjs-dist": "^6.4.299",
82
88
  "react": "^19.3.1",
83
89
  "react-dom": "^19.3.1",
90
+ "react-markdown": "^10.1.0",
91
+ "rehype-katex": "^7.0.1",
92
+ "remark-gfm": "^4.0.1",
93
+ "remark-math": "^6.0.0",
84
94
  "typescript": "^7.0.2",
85
95
  "vite": "^8.3.5"
86
96
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { ROOT, DATA } from '../src/server/paths.ts';
6
+ import { serviceEnvironment } from '../src/server/service-environment.ts';
6
7
 
7
8
  if (process.platform !== 'darwin') throw new Error('This service installer is for macOS. On other systems run npm start with your process manager.');
8
9
  const label = 'local.papilot';
@@ -14,8 +15,13 @@ if (process.argv[2] === 'stop') {
14
15
  const xml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
15
16
  fs.mkdirSync(DATA, { recursive: true, mode: 0o700 });
16
17
  fs.mkdirSync(path.dirname(file), { recursive: true });
17
- const env: Record<string, string> = { PATH: [path.join(ROOT, 'node_modules/.bin'), '/Library/TeX/texbin', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':'), HOME: os.homedir(), NODE_ENV: 'production' };
18
- for (const [key, value] of Object.entries(process.env)) if (key.startsWith('PAPILOT_') && value) env[key] = value;
18
+ let previous: NodeJS.ProcessEnv = {};
19
+ if (fs.existsSync(file)) {
20
+ try {
21
+ previous = JSON.parse(execFileSync('/usr/bin/plutil', ['-extract', 'EnvironmentVariables', 'json', '-o', '-', file], { encoding: 'utf8' }));
22
+ } catch { /* An older or incomplete plist may not contain environment settings. */ }
23
+ }
24
+ const env = serviceEnvironment(ROOT, os.homedir(), process.execPath, process.env, previous);
19
25
  const args = ['/usr/bin/caffeinate', '-i', process.execPath, '--import', 'tsx', path.join(ROOT, 'src/server/main.ts')];
20
26
  const plist = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict>
21
27
  <key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args.map(a => `<string>${xml(a)}</string>`).join('')}</array>
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: Check math
3
+ description: Find errors in notation, equations and proofs
4
+ hint: Which part? e.g. "Section 3 and Appendix A". Leave empty for the whole paper
5
+ order: 4
6
+ ---
7
+ Check the mathematics for errors and inconsistencies.
8
+
9
+ - Every symbol is defined before it is used and means one thing throughout. Indices, signs, dimensions and quantifiers agree. Theorem statements match their proofs and the places that cite them. References to equations, theorems and figures point to the right ones.
10
+ - Fix clear slips directly: a typo in a formula, an inconsistent symbol, a wrong reference.
11
+ - Do not change what a result says. If you suspect a substantive error, leave the text and explain the problem in your summary, with its location and your reasoning.
12
+ - Follow the paper's own macros and environments.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: Consistency
3
+ description: Unify terms, notation, spelling and references
4
+ hint: Which part? Leave empty for the whole paper
5
+ order: 5
6
+ ---
7
+ Make the paper consistent with itself.
8
+
9
+ - Terminology, capitalization and hyphenation; American or British spelling; acronyms defined once at first use and used after that; notation and macros; reference style (for example `\cref` or `Section~\ref`); numbers, units, list and caption punctuation.
10
+ - For each difference, use the variant the paper uses most, and name your choices in the summary.
11
+ - Do not change meaning, and leave quoted material and bibliography entries as they are.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: Fix LaTeX warnings
3
+ description: Resolve undefined references, overfull boxes and other build warnings
4
+ hint: Anything to leave alone? Leave empty to fix every warning you can
5
+ order: 6
6
+ ---
7
+ Fix the problems LaTeX reports when it builds the paper.
8
+
9
+ - Compile in your worktree (for example with `latexmk`) and read the log. If you cannot run commands, look for the same problems in the source instead.
10
+ - Fix undefined or duplicate labels, references and citations; overfull boxes, by rewording slightly or allowing a break where the line overflows; and package warnings that point to real mistakes.
11
+ - Do not silence warnings globally: no document-wide `\sloppy`, badness or tolerance changes, and no new packages, unless the request asks for them.
12
+ - In your summary, list the warnings you fixed and any you left, with the reason.
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: Polish
3
+ description: Improve clarity and flow, keeping the meaning
4
+ hint: Which part? e.g. "the introduction". Leave empty for the whole paper
5
+ order: 1
6
+ ---
7
+ Improve the clarity, concision and flow of the prose, sentence by sentence and paragraph by paragraph.
8
+
9
+ - Keep every claim, number, citation, label, definition and technical term. Do not add claims, results or citations.
10
+ - Prefer concrete subjects and active verbs. Cut filler such as "it is worth noting that" or "in order to".
11
+ - Keep the author's voice and the paper's terminology; never replace a defined term with a synonym.
12
+ - Make the smallest edit that fixes a real problem, and leave sentences that already read well.
13
+ - Do not touch mathematics, code, labels or the document's structure unless the request asks for it.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: Proofread
3
+ description: Fix typos, grammar and punctuation without rewording
4
+ hint: Which part? Leave empty for the whole paper
5
+ order: 2
6
+ ---
7
+ Proofread the prose for typos, spelling, grammar, punctuation, agreement, articles and repeated words.
8
+
9
+ - Change only what is wrong. Do not rephrase correct sentences, reorder content, or change terminology, notation, citations or labels.
10
+ - Keep the paper's spelling convention (American or British) as the majority of the text uses it.
11
+ - Leave mathematics, code, citation keys, labels and URLs as they are.
12
+ - In your summary, list anything you noticed but left because it needs the author's judgment.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: Address reviewers
3
+ description: Revise for reviewer comments and draft a point-by-point reply
4
+ hint: Paste the reviewer comments
5
+ order: 7
6
+ ---
7
+ Revise the paper in response to the reviewer comments in the request.
8
+
9
+ - For each comment, make a targeted change where one is warranted. Keep the rest of the text as it is.
10
+ - Do not invent experiments, results or citations. When a comment needs new work, say so in the reply instead of editing.
11
+ - End with a point-by-point reply in your summary: quote each comment briefly, then say what changed and where (section, and the new wording if it is short), or explain politely why nothing changed.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: Shorten
3
+ description: Cut length without losing content
4
+ hint: What and by how much? e.g. "Section 4 by a third" or "fit in 8 pages"
5
+ order: 3
6
+ ---
7
+ Make the text shorter while keeping its content.
8
+
9
+ - Cut redundancy, repeated motivation, hedging and wordy phrasing first; merge sentences that say the same thing.
10
+ - Keep every result, definition, theorem, figure, table and citation unless the request says otherwise.
11
+ - With a page target, compile in your worktree to check the page count if you can run commands.
12
+ - In your summary, say roughly how much you cut, what kind of text went, and what you considered cutting but kept.
@@ -0,0 +1,37 @@
1
+ import type { FeedbackSummary } from './shared.ts';
2
+
3
+ // What the agent receives when the author sends review feedback without a
4
+ // message of their own. The chat shows the feedback, never this prompt.
5
+ export const reviewInstructions = 'Continue this task using the decisions and comments from this review round. Preserve accepted changes and do not reintroduce rejected edits. Address any remaining revision requests; if no revision is needed, summarize the result.';
6
+ // Earlier versions stored the prompt itself as the author's message.
7
+ export const isFeedbackPrompt = (text: string) => text === reviewInstructions || text === 'Address the comments from this review round. Preserve accepted changes and do not reintroduce rejected edits.';
8
+
9
+ // A skill is a named set of instructions; the author's own words, if any, say where and how.
10
+ export function skillPrompt(skill: { name: string; body: string; folder?: string }, request: string) {
11
+ return [
12
+ `Use the "${skill.name}" skill below for this request.${skill.folder ? ` Files the skill mentions are in ${skill.folder}.` : ''}`,
13
+ `<skill name="${skill.name}">\n${skill.body.trim()}\n</skill>`,
14
+ request.trim() ? `Request: ${request.trim()}` : 'No further details were given: apply the skill to the whole paper.',
15
+ ].join('\n\n');
16
+ }
17
+
18
+ interface FeedbackItem { kind?: string; round?: number | null; text?: string; changes?: (string | { file?: string; lines?: [number, number] })[] }
19
+ // The review engine's feedback (see vendor/latex-review FeedbackItem) as the chat shows it.
20
+ export function summarizeFeedback(feedback: { feedback?: FeedbackItem[]; build_error?: unknown }): FeedbackSummary | undefined {
21
+ const items = feedback.feedback || [];
22
+ if (!items.length && !feedback.build_error) return undefined;
23
+ const summary: FeedbackSummary = { round: null, accepted: 0, rejected: 0, undone: 0, comments: [], ...(feedback.build_error ? { buildError: true } : {}) };
24
+ for (const item of items) {
25
+ if (item.round) summary.round = Math.max(summary.round || 0, item.round);
26
+ const changes = item.changes || [];
27
+ if (item.kind === 'accept') summary.accepted += changes.length || 1;
28
+ else if (item.kind === 'reject') summary.rejected += changes.length || 1;
29
+ else if (item.kind === 'undo') summary.undone++;
30
+ else if ((item.kind === 'comment' || item.kind === 'note') && item.text?.trim()) {
31
+ const first = changes.find(change => typeof change === 'object' && change.file) as { file: string; lines?: [number, number] } | undefined;
32
+ const where = first ? `${first.file}${first.lines ? `:${first.lines[0]}` : ''}${changes.length > 1 ? ` +${changes.length - 1}` : ''}` : item.kind === 'note' ? 'Note on the review' : `Round ${item.round ?? ''}`.trim();
33
+ summary.comments.push({ where, text: item.text.trim().slice(0, 2000) });
34
+ }
35
+ }
36
+ return summary;
37
+ }
package/src/notes.ts CHANGED
@@ -7,6 +7,11 @@ const QUOTE = ' PDF text: "', REGION = ' Marked region: a figure, equation o
7
7
 
8
8
  export interface NoteSummary { where: string; quote: string; comment: string }
9
9
 
10
+ export function reviewNotesInstructions(notes: NoteSummary[]) {
11
+ const items = notes.map((note, index) => `${index + 1}. ${note.where}\n${note.quote ? `${QUOTE}${note.quote}"` : REGION}${NOTE}${note.comment.trim().replace(/\n/g, '\n ')}`);
12
+ return `${PREAMBLE} These notes refer to the displayed review round and its Before, After or Unchanged text. Region coordinates are percentages of the displayed PDF strip (left, top, right, bottom); any quoted text for a region is nearby context. Read the current source before editing. Preserve accepted changes and do not reintroduce rejected edits unless a note explicitly asks for it.\n\n${items.join('\n\n')}`;
13
+ }
14
+
10
15
  export function notesInstructions(notes: Annotation[]) {
11
16
  const items = notes.map((note, index) => {
12
17
  const where = [`Page ${note.page + 1}`, note.source ? `${note.source.file} ${note.source.lines[0] === note.source.lines[1] ? `line ${note.source.lines[0]}` : `lines ${note.source.lines[0]}–${note.source.lines[1]}`}` : ''].filter(Boolean).join(' · ');
@@ -139,10 +139,11 @@ export class ClaudeAgent extends Adapter {
139
139
  this.ready = true;
140
140
  } finally { finish(); probe.close(); }
141
141
  }
142
- // Claude tasks need an API key (or a cloud provider's own credentials):
143
- // Anthropic does not accept a Claude subscription login through the SDK.
142
+ // Claude tasks need an API key, an ANTHROPIC_AUTH_TOKEN (for a gateway or an
143
+ // Anthropic-compatible ANTHROPIC_BASE_URL), or a cloud provider's own
144
+ // credentials: Anthropic does not accept a Claude subscription login through the SDK.
144
145
  protected account() {
145
- const account = this.info.account, keyed = Boolean(account?.apiKeySource && !['none', 'oauth'].includes(account.apiKeySource));
146
+ const account = this.info.account, keyed = Boolean(account?.apiKeySource && !['none', 'oauth'].includes(account.apiKeySource)) || account?.tokenSource === 'ANTHROPIC_AUTH_TOKEN';
146
147
  const external = Boolean(account?.apiProvider && account.apiProvider !== 'firstParty');
147
148
  if (keyed || external) return { account: { email: account?.email, type: external ? account!.apiProvider : 'API key' }, requiresOpenaiAuth: false };
148
149
  return { account: null, requiresOpenaiAuth: true, reason: CLAUDE_KEY };
@@ -1,7 +1,7 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { EventEmitter } from 'node:events';
4
- import { CODEX, ROOT } from './paths.ts';
4
+ import { CODEX, ROOT, VERSION } from './paths.ts';
5
5
 
6
6
  export class RpcResponseError extends Error {}
7
7
 
@@ -34,7 +34,7 @@ export class Codex extends EventEmitter {
34
34
  };
35
35
  child.once('error', failed);
36
36
  child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`)));
37
- await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: '0.1.0' } });
37
+ await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION } });
38
38
  this.notify('initialized', {}); this.ready = true;
39
39
  }
40
40
  private receive(message: any) {
@@ -6,11 +6,12 @@ import path from 'node:path';
6
6
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
7
7
  import { Store } from './store.ts';
8
8
  import { Workspace } from './workspace.ts';
9
- import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER } from './paths.ts';
9
+ import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION } from './paths.ts';
10
10
  import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
11
11
  import { authorize } from './security.ts';
12
12
  import { fullPaperPdf } from './pdf.ts';
13
13
  import { Manuscripts, annotationTask, gitCommit, gitDiff, gitRemote, gitStatus } from './manuscript.ts';
14
+ import { findSkill, listedSkill, loadSkills } from './skills.ts';
14
15
  import type { AgentId } from '../shared.ts';
15
16
 
16
17
  const store = new Store(path.join(DATA, 'papilot.sqlite'));
@@ -27,7 +28,7 @@ app.addHook('onRequest', async (request, reply) => {
27
28
  if (request.method === 'POST' && !request.url.startsWith('/review/') && !equals(String(request.headers['x-papilot-token'] || ''), csrf)) return reply.code(403).send({ error: 'This page has expired. Refresh it and try again.' });
28
29
  });
29
30
  app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
30
- app.get('/api/health', () => ({ ok: true, name: 'Papilot', version: '0.1.0' }));
31
+ app.get('/api/health', () => ({ ok: true, name: 'Papilot', version: VERSION }));
31
32
  app.get('/api/state', () => ({ ...workspace.snapshot(), csrf }));
32
33
  app.get('/api/events', (request, reply) => {
33
34
  reply.hijack();
@@ -46,9 +47,14 @@ const effort = (value: unknown) => typeof value === 'string' && /^[a-z]{1,16}$/.
46
47
  const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
47
48
  app.post('/api/projects', async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
48
49
  app.post<{ Params: { id: string } }>('/api/projects/:id/rename', request => workspace.renameProject(request.params.id, string((request.body as any).name, 'Paper name', 120)));
50
+ // A skill's name: what the author typed after "/".
51
+ const skillName = (value: unknown) => typeof value === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(value) ? value : undefined;
52
+ // Text the author may leave empty when a skill or review feedback says enough.
53
+ const optional = (value: unknown, name: string) => value === undefined || value === '' ? '' : string(value, name);
54
+ app.get<{ Params: { id: string } }>('/api/projects/:id/skills', request => ({ skills: loadSkills(store.project(request.params.id).path).map(listedSkill) }));
49
55
  app.post('/api/tasks', async request => {
50
- const b = request.body as any;
51
- return workspace.createTask({ id: string(b.id, 'Task ID', 64), projectId: string(b.projectId, 'Paper ID', 64), title: string(b.title, 'Task title', 120), instructions: string(b.instructions, 'Instructions'), agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
56
+ const b = request.body as any, skill = skillName(b.skill);
57
+ return workspace.createTask({ id: string(b.id, 'Task ID', 64), projectId: string(b.projectId, 'Paper ID', 64), title: string(b.title, 'Task title', 120), instructions: skill ? optional(b.instructions, 'Instructions') : string(b.instructions, 'Instructions'), skill, agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
52
58
  });
53
59
  app.get<{ Params: { id: string } }>('/api/tasks/:id/items', request => { store.task(request.params.id); return { items: store.items(request.params.id) }; });
54
60
  app.get<{ Params: { id: string }; Querystring: { round?: string; version?: string; download?: string } }>('/api/tasks/:id/pdf', async (request, reply) => {
@@ -61,8 +67,10 @@ app.get<{ Params: { id: string }; Querystring: { round?: string; version?: strin
61
67
  return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${filename}"`).sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
62
68
  });
63
69
  app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', async request => {
64
- const b = request.body as any;
65
- return workspace.enqueue(request.params.id, string(b.text, 'Revision instructions'), string(b.requestKey, 'Request ID', 100), true);
70
+ const b = request.body as any, task = store.task(request.params.id), feedback = b.feedback === true, name = skillName(b.skill);
71
+ const skill = name ? findSkill(store.project(task.projectId).path, name) : undefined;
72
+ const text = feedback || skill ? optional(b.text, 'Revision instructions') : string(b.text, 'Revision instructions');
73
+ return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback });
66
74
  });
67
75
  app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
68
76
  app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
@@ -193,7 +201,13 @@ await app.listen({ host: '127.0.0.1', port: PORT });
193
201
  store.recover();
194
202
  console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}`);
195
203
  void workspace.connect();
196
- for (const task of store.tasks().filter(t => t.status === 'building')) { store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' }); void workspace.refresh(task.id); }
204
+ // Recompute cached feedback counts, including decisions from before this restart.
205
+ for (const task of store.tasks()) {
206
+ if (task.status === 'building') {
207
+ store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
208
+ void workspace.refresh(task.id);
209
+ } else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
210
+ }
197
211
  let shuttingDown = false;
198
212
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
199
213
  if (shuttingDown) return; shuttingDown = true; workspace.close();
@@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
  export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
5
5
  export const DATA = path.resolve(process.env.PAPILOT_DATA || path.join(ROOT, '.papilot'));
6
6
  export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'vendor/latex-review'));
7
- export const PORT = Number(process.env.PAPILOT_PORT || 4317);
7
+ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
8
+ export const PORT = Number(process.env.PAPILOT_PORT || 7274);
8
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
9
10
  export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
10
11
  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');
@@ -34,7 +34,7 @@ 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
- return { ...state, decisionLock, commentLock };
37
+ return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length ?? 0 } } : {}) };
38
38
  }
39
39
 
40
40
  export function allowedReviewRoute(route: string, method: string): boolean {
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+
3
+ /** launchd has no login shell. Keep the tool choices made by the installer. */
4
+ export function serviceEnvironment(
5
+ root: string, home: string, executable: string,
6
+ inherited: NodeJS.ProcessEnv, previous: NodeJS.ProcessEnv = {},
7
+ ): Record<string, string> {
8
+ // An explicit empty value clears a previously saved override.
9
+ const toolPath = inherited.PAPILOT_SERVICE_PATH ?? previous.PAPILOT_SERVICE_PATH ?? '';
10
+ const directories = [
11
+ ...toolPath.split(path.delimiter),
12
+ path.dirname(executable), path.join(root, 'node_modules/.bin'),
13
+ ...(inherited.PATH || '').split(path.delimiter),
14
+ '/Library/TeX/texbin', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
15
+ ].filter(directory => path.isAbsolute(directory));
16
+ const env: Record<string, string> = {
17
+ PATH: [...new Set(directories)].join(path.delimiter), HOME: home, NODE_ENV: 'production',
18
+ };
19
+ for (const [key, value] of Object.entries(inherited)) if (key.startsWith('PAPILOT_') && value) env[key] = value;
20
+ if (toolPath) env.PAPILOT_SERVICE_PATH = toolPath;
21
+ return env;
22
+ }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DATA, ROOT } from './paths.ts';
4
+ import type { Skill } from '../shared.ts';
5
+
6
+ // Skills are Markdown instructions an author runs with "/" in the composer.
7
+ // Papilot ships some in skills/; the data folder's skills/ adds the author's
8
+ // own for every paper, and a paper's .papilot/skills/ its own. A later source
9
+ // replaces an earlier skill with the same name. Each skill is <name>.md, or a
10
+ // folder with a SKILL.md (the Agent Skills layout) whose other files the
11
+ // agent may read. Front matter gives its name, description, composer hint
12
+ // and order.
13
+ export interface SkillFile extends Skill { body: string; folder?: string; order: number }
14
+ const NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
15
+
16
+ export function parseSkill(text: string, id: string, source: Skill['source'], folder?: string): SkillFile | null {
17
+ text = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
18
+ const header = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text), fields: Record<string, string> = {};
19
+ for (const line of header?.[1].split('\n') || []) {
20
+ const field = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
21
+ if (field) fields[field[1].toLowerCase()] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2');
22
+ }
23
+ const body = text.slice(header ? header[0].length : 0).trim().slice(0, 20000);
24
+ if (!body) return null;
25
+ return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), source, body, ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
26
+ }
27
+
28
+ function read(dir: string, source: Skill['source']): SkillFile[] {
29
+ let entries: fs.Dirent[];
30
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
31
+ const found: SkillFile[] = [];
32
+ for (const entry of entries) {
33
+ const id = entry.name.replace(/\.md$/i, '').toLowerCase();
34
+ if (!NAME.test(id)) continue;
35
+ try {
36
+ const skill = entry.isDirectory() ? parseSkill(fs.readFileSync(path.join(dir, entry.name, 'SKILL.md'), 'utf8'), id, source, path.join(dir, entry.name))
37
+ : entry.isFile() && /\.md$/i.test(entry.name) ? parseSkill(fs.readFileSync(path.join(dir, entry.name), 'utf8'), id, source) : null;
38
+ if (skill) found.push(skill);
39
+ } catch { /* A folder without a readable SKILL.md is not a skill. */ }
40
+ }
41
+ return found;
42
+ }
43
+
44
+ export function loadSkills(paper?: string, roots = { builtin: path.join(ROOT, 'skills'), user: path.join(DATA, 'skills') }): SkillFile[] {
45
+ const skills = new Map<string, SkillFile>();
46
+ const sources: [string, Skill['source']][] = [[roots.builtin, 'builtin'], [roots.user, 'user'], ...(paper ? [[path.join(paper, '.papilot', 'skills'), 'paper'] as [string, Skill['source']]] : [])];
47
+ for (const [dir, source] of sources) for (const skill of read(dir, source)) skills.set(skill.id, skill);
48
+ return [...skills.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
49
+ }
50
+
51
+ export function findSkill(paper: string, id: string) {
52
+ const skill = loadSkills(paper).find(item => item.id === id);
53
+ if (!skill) throw new Error(`The skill "${id}" is not available for this paper.`);
54
+ return skill;
55
+ }
56
+
57
+ export const listedSkill = ({ id, name, description, hint, source }: SkillFile): Skill => ({ id, name, description, hint, source });