papilot 0.1.1 → 0.3.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 (112) 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 +95 -86
  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-BkowXmKr.js +206 -0
  65. package/dist/assets/index-Dq3s-vhH.css +1 -0
  66. package/dist/assets/math-BTm2hvOz.js +263 -0
  67. package/dist/assets/math-DEcVZfaU.css +1 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BEn1EztO.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/package.json +13 -2
  72. package/scripts/service.ts +8 -2
  73. package/skills/check-math.md +12 -0
  74. package/skills/consistency.md +11 -0
  75. package/skills/latex-warnings.md +12 -0
  76. package/skills/polish.md +13 -0
  77. package/skills/proofread.md +12 -0
  78. package/skills/reviewer-response.md +11 -0
  79. package/skills/shorten.md +12 -0
  80. package/src/commit.ts +23 -0
  81. package/src/conversation.ts +48 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/main.ts +51 -8
  85. package/src/server/manuscript.ts +91 -10
  86. package/src/server/review.ts +2 -1
  87. package/src/server/service-environment.ts +22 -0
  88. package/src/server/skills.ts +57 -0
  89. package/src/server/workspace.ts +186 -26
  90. package/src/shared.ts +17 -3
  91. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
  92. package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +19 -0
  93. package/vendor/latex-review/dist/web/index.html +2 -2
  94. package/vendor/latex-review/src/client.ts +10 -3
  95. package/vendor/latex-review/src/core/build.ts +53 -7
  96. package/vendor/latex-review/src/core/outline.ts +6 -3
  97. package/vendor/latex-review/src/core/publish.ts +8 -6
  98. package/vendor/latex-review/src/runtime.ts +14 -0
  99. package/vendor/latex-review/src/server.ts +2 -1
  100. package/vendor/latex-review/src/types.ts +16 -1
  101. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  102. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  103. package/vendor/latex-review/src/web/i18n.ts +48 -0
  104. package/vendor/latex-review/src/web/model.ts +23 -4
  105. package/vendor/latex-review/src/web/overview.tsx +25 -20
  106. package/vendor/latex-review/src/web/review.tsx +399 -145
  107. package/vendor/latex-review/src/web/style.css +130 -0
  108. package/vendor/latex-review/src/web/zh-CN.ts +188 -0
  109. package/dist/assets/index-BhQFCMTx.js +0 -131
  110. package/dist/assets/index-CAV3-87U.css +0 -1
  111. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  112. 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-BkowXmKr.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Dq3s-vhH.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}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.1.1",
3
+ "version": "0.3.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,9 @@
35
35
  "src/server",
36
36
  "src/shared.ts",
37
37
  "src/notes.ts",
38
+ "src/conversation.ts",
39
+ "src/commit.ts",
40
+ "skills",
38
41
  "dist",
39
42
  "scripts/demo.ts",
40
43
  "scripts/service.ts",
@@ -56,9 +59,12 @@
56
59
  "test": "tsx --test tests/*.test.ts",
57
60
  "setup:review": "npm --prefix vendor/latex-review ci && npm --prefix vendor/latex-review run build",
58
61
  "demo": "tsx scripts/demo.ts",
62
+ "doctor": "node bin/papilot.mjs doctor",
59
63
  "service:install": "tsx scripts/service.ts install",
60
64
  "service:stop": "tsx scripts/service.ts stop",
61
- "test:ui": "npm run build && node scripts/check-ui.mjs",
65
+ "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",
66
+ "site": "node scripts/build-site.mjs",
67
+ "site:video": "node scripts/build-site.mjs --video",
62
68
  "prepack": "npm run build && npm run setup:review"
63
69
  },
64
70
  "dependencies": {
@@ -78,9 +84,14 @@
78
84
  "@types/react": "^19.3.0",
79
85
  "@types/react-dom": "^19.3.0",
80
86
  "@vitejs/plugin-react": "^6.1.2",
87
+ "katex": "^0.16.47",
81
88
  "pdfjs-dist": "^6.4.299",
82
89
  "react": "^19.3.1",
83
90
  "react-dom": "^19.3.1",
91
+ "react-markdown": "^10.1.0",
92
+ "rehype-katex": "^7.0.1",
93
+ "remark-gfm": "^4.0.1",
94
+ "remark-math": "^6.0.0",
84
95
  "typescript": "^7.0.2",
85
96
  "vite": "^8.3.5"
86
97
  }
@@ -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.
package/src/commit.ts ADDED
@@ -0,0 +1,23 @@
1
+ // What the Git panel leaves out of a commit unless chosen, and offers to add
2
+ // to .gitignore: LaTeX's build output, minted's cache and macOS folder files.
3
+ // Each file maps to the pattern that ignores every file of its kind.
4
+ const BUILD = /\.(aux|log|out|toc|lof|lot|lol|fls|fdb_latexmk|synctex(?:\.gz)?|bbl|blg|bcf|run\.xml|nav|snm|vrb|xdv|dvi|idx|ilg|ind|glo|gls|glg|ist|thm|loa)$/;
5
+ export function ignorePattern(file: string): string | null {
6
+ const name = file.slice(file.lastIndexOf('/') + 1);
7
+ if (name === '.DS_Store') return '.DS_Store';
8
+ if (/(?:^|\/)_minted[^/]*\//.test(file)) return '_minted*/';
9
+ const build = BUILD.exec(name);
10
+ return build ? `*.${build[1]}` : null;
11
+ }
12
+
13
+ // An agent's reply as a commit message: without a code fence, a label or
14
+ // quotes, a subject without a final period, and any body after a blank line.
15
+ export function commitMessageFrom(reply: string) {
16
+ const text = reply.trim().replace(/^```[\w-]*\n([\s\S]*?)\n```$/, '$1').trim()
17
+ .replace(/^(?:\*\*)?commit message(?:\*\*)?\s*:\s*(?:\*\*)?[ \t]*/i, '');
18
+ const lines = text.split('\n'), first = lines.findIndex(line => line.trim());
19
+ if (first < 0) return '';
20
+ const subject = lines[first].trim().replace(/^#+\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').replace(/^(["'`])(.+)\1$/, '$2').replace(/\.$/, '').trim();
21
+ const body = lines.slice(first + 1).join('\n').trim();
22
+ return body ? `${subject}\n\n${body}` : subject;
23
+ }
@@ -0,0 +1,48 @@
1
+ import type { BuildFailure, 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
+ // What the agent receives when its own edits stop the review build. The chat
10
+ // shows the error, never this prompt.
11
+ export function buildFixPrompt(failure: BuildFailure) {
12
+ return [
13
+ 'Papilot could not build the PDF review after your last turn:',
14
+ [failure.error, ...failure.details].join('\n'),
15
+ failure.log ? `The compiler log is at ${failure.log}.` : '',
16
+ 'Fix this in the task worktree, then end your turn; Papilot builds the review again. Restore any file outside the review\'s scope to how it was when the task started, and mention in your summary any update that file still needs.',
17
+ ].filter(Boolean).join('\n\n');
18
+ }
19
+
20
+ // A skill is a named set of instructions; the author's own words, if any, say where and how.
21
+ export function skillPrompt(skill: { name: string; body: string; folder?: string }, request: string) {
22
+ return [
23
+ `Use the "${skill.name}" skill below for this request.${skill.folder ? ` Files the skill mentions are in ${skill.folder}.` : ''}`,
24
+ `<skill name="${skill.name}">\n${skill.body.trim()}\n</skill>`,
25
+ request.trim() ? `Request: ${request.trim()}` : 'No further details were given: apply the skill to the whole paper.',
26
+ ].join('\n\n');
27
+ }
28
+
29
+ interface FeedbackItem { kind?: string; round?: number | null; text?: string; changes?: (string | { file?: string; lines?: [number, number] })[] }
30
+ // The review engine's feedback (see vendor/latex-review FeedbackItem) as the chat shows it.
31
+ export function summarizeFeedback(feedback: { feedback?: FeedbackItem[]; build_error?: unknown }): FeedbackSummary | undefined {
32
+ const items = feedback.feedback || [];
33
+ if (!items.length && !feedback.build_error) return undefined;
34
+ const summary: FeedbackSummary = { round: null, accepted: 0, rejected: 0, undone: 0, comments: [], ...(feedback.build_error ? { buildError: true } : {}) };
35
+ for (const item of items) {
36
+ if (item.round) summary.round = Math.max(summary.round || 0, item.round);
37
+ const changes = item.changes || [];
38
+ if (item.kind === 'accept') summary.accepted += changes.length || 1;
39
+ else if (item.kind === 'reject') summary.rejected += changes.length || 1;
40
+ else if (item.kind === 'undo') summary.undone++;
41
+ else if ((item.kind === 'comment' || item.kind === 'note') && item.text?.trim()) {
42
+ const first = changes.find(change => typeof change === 'object' && change.file) as { file: string; lines?: [number, number] } | undefined;
43
+ 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();
44
+ summary.comments.push({ where, text: item.text.trim().slice(0, 2000) });
45
+ }
46
+ }
47
+ return summary;
48
+ }
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 };
@@ -10,7 +10,8 @@ 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
- import { Manuscripts, annotationTask, gitCommit, gitDiff, gitRemote, gitStatus } from './manuscript.ts';
13
+ import { Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, 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'));
@@ -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,15 @@ 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 });
74
+ });
75
+ // Commits what the author accepted from this task, with the message they confirmed.
76
+ app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', request => {
77
+ const task = store.task(request.params.id), b = request.body as any;
78
+ return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
66
79
  });
67
80
  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
81
  app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
@@ -89,11 +102,35 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projec
89
102
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', request => {
90
103
  const project = store.project(request.params.id), b = request.body as any;
91
104
  const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
92
- return serialGit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) }));
105
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
106
+ });
107
+ const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
108
+ // An agent writes a commit message for the chosen files; nothing is written.
109
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', request => {
110
+ const b = request.body as any;
111
+ return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined });
112
+ });
113
+ // A merge in progress is resolved by an agent, committed, or abandoned.
114
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', request => {
115
+ const project = store.project(request.params.id), b = request.body as any;
116
+ return serialGit(project.id, () => workspace.resolveConflicts(project.id, { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined }));
117
+ });
118
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/finish', request => {
119
+ const project = store.project(request.params.id);
120
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitFinishMerge(project.path), git: await gitStatus(project.path) })));
121
+ });
122
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/abort', request => {
123
+ const project = store.project(request.params.id);
124
+ return serialGit(project.id, async () => { await gitAbortMerge(project.path); return { git: await gitStatus(project.path) }; });
125
+ });
126
+ // Chosen untracked build files go into .gitignore by kind.
127
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/ignore', request => {
128
+ const project = store.project(request.params.id);
129
+ return serialGit(project.id, async () => ({ git: await gitIgnore(project.path, paths((request.body as any).paths)) }));
93
130
  });
94
131
  app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', request => {
95
132
  const project = store.project(request.params.id), action = request.params.action;
96
- if (action !== 'fetch' && action !== 'pull' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
133
+ if (action !== 'fetch' && action !== 'pull' && action !== 'merge' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
97
134
  return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
98
135
  });
99
136
  app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path, true)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
@@ -193,7 +230,13 @@ await app.listen({ host: '127.0.0.1', port: PORT });
193
230
  store.recover();
194
231
  console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}`);
195
232
  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); }
233
+ // Recompute cached feedback counts, including decisions from before this restart.
234
+ for (const task of store.tasks()) {
235
+ if (task.status === 'building') {
236
+ store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
237
+ void workspace.refresh(task.id);
238
+ } else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
239
+ }
197
240
  let shuttingDown = false;
198
241
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
199
242
  if (shuttingDown) return; shuttingDown = true; workspace.close();
@@ -7,8 +7,9 @@ import path from 'node:path';
7
7
  import { promisify } from 'node:util';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { ENGINE } from './paths.ts';
10
- import type { Annotation, GitStatus, ManuscriptBuild } from '../shared.ts';
10
+ import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
11
11
  import { notesInstructions } from '../notes.ts';
12
+ import { ignorePattern } from '../commit.ts';
12
13
 
13
14
  type Rect = [number, number, number, number];
14
15
 
@@ -57,8 +58,26 @@ export async function gitStatus(root: string): Promise<GitStatus> {
57
58
  git(root, ['remote']).catch(() => ''),
58
59
  ]);
59
60
  const [hash, subject, time] = head.trim().split('\0');
60
- return { ...parseStatus(status, numstat), head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()) };
61
+ const parsed = parseStatus(status, numstat), dir = gitDir(root);
62
+ // A merge in progress names the files whose conflict markers remain.
63
+ const merging = dir && fs.existsSync(path.join(dir, 'MERGE_HEAD'))
64
+ ? { message: readText(path.join(dir, 'MERGE_MSG')).split('\n')[0] || 'Merge', conflicts: parsed.files.filter(file => file.status === 'U' && conflictMarkers(root, file.path)).map(file => file.path) }
65
+ : null;
66
+ return { ...parsed, head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()), merging };
61
67
  }
68
+ const readText = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
69
+ // The repository's Git directory: .git, or where a .git file points.
70
+ function gitDir(root: string) {
71
+ try {
72
+ const dot = path.join(root, '.git');
73
+ return fs.statSync(dot).isFile() ? path.resolve(root, readText(dot).replace(/^gitdir:\s*/, '').trim()) : dot;
74
+ } catch { return null; }
75
+ }
76
+ export function mergeInProgress(root: string) {
77
+ const dir = gitDir(root);
78
+ return Boolean(dir && fs.existsSync(path.join(dir, 'MERGE_HEAD')));
79
+ }
80
+ export const conflictMarkers = (root: string, file: string) => /^(?:<{7}|>{7})(?: |$)/m.test(readText(path.join(root, file)));
62
81
 
63
82
  // Paths from the browser are matched against the changed files, and Git reads
64
83
  // them literally, so a name cannot act as a pathspec pattern.
@@ -69,17 +88,51 @@ const DIFF_LIMIT = 400_000;
69
88
  export async function gitDiff(root: string, file: string) {
70
89
  const entry = (await gitStatus(root)).files.find(item => item.path === file);
71
90
  if (!entry) throw Object.assign(new Error('That file has no uncommitted changes.'), { statusCode: 404 });
72
- const diff = entry.status === '?'
73
- // `git diff --no-index` exits with 1 when the files differ.
74
- ? await exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
75
- : await git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), file], literal);
91
+ const diff = await fileDiff(root, entry);
76
92
  return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
77
93
  }
94
+ function fileDiff(root: string, entry: GitFile) {
95
+ return entry.status === '?'
96
+ // `git diff --no-index` exits with 1 when the files differ.
97
+ ? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
98
+ : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
99
+ }
100
+
101
+ // What a commit message is written from: the chosen files' diffs, each
102
+ // shortened so that a large change still fits, and the repository's recent
103
+ // commit subjects, whose style and language the message follows.
104
+ export async function commitChanges(root: string, paths: string[]) {
105
+ const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
106
+ if (!files.length) throw new Error('Select at least one changed file.');
107
+ let patch = '';
108
+ for (const [index, file] of files.entries()) {
109
+ if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
110
+ const diff = await fileDiff(root, file);
111
+ patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
112
+ }
113
+ const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
114
+ return { files, patch, recent };
115
+ }
116
+
117
+ // Adds the patterns for the chosen untracked build files to the repository's
118
+ // .gitignore, one per kind of file and each only once, so they leave the list
119
+ // of changes. The .gitignore is then a change to commit like any other.
120
+ export async function gitIgnore(root: string, paths: string[]) {
121
+ const patterns = [...new Set((await gitStatus(root)).files.filter(item => item.status === '?' && paths.includes(item.path)).map(item => ignorePattern(item.path)).filter((pattern): pattern is string => Boolean(pattern)))];
122
+ if (!patterns.length) throw new Error('None of the chosen files is build output that can be ignored.');
123
+ const file = path.join(root, '.gitignore');
124
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
125
+ const present = new Set(current.split(/\r?\n/).map(line => line.trim()));
126
+ const missing = patterns.filter(pattern => !present.has(pattern) && !present.has('/' + pattern));
127
+ if (missing.length) fs.writeFileSync(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${current.trim() ? '\n' : ''}# Build output\n${missing.join('\n')}\n`);
128
+ return gitStatus(root);
129
+ }
78
130
 
79
131
  // Commit exactly the chosen files as they are on disk. Changes staged
80
132
  // elsewhere for other files stay staged and out of this commit.
81
133
  export async function gitCommit(root: string, message: string, paths: string[]) {
82
- const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
134
+ const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
135
+ if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
83
136
  if (!files.length) throw new Error('Select at least one changed file to commit.');
84
137
  if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
85
138
  // A rename is already staged, so only its new path is added; the commit
@@ -91,13 +144,17 @@ export async function gitCommit(root: string, message: string, paths: string[])
91
144
 
92
145
  // Fetch, fast-forward pull and push use the host's Git credentials. Nothing
93
146
  // may prompt, so a missing credential fails instead of hanging.
94
- export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push') {
147
+ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'merge' | 'push') {
95
148
  const status = await gitStatus(root);
96
149
  if (!status.remote) throw new Error('This repository has no remote.');
150
+ if (status.merging && action !== 'fetch') throw new Error('Finish or abort the merge first.');
97
151
  if (action === 'fetch') await gitAction(root, ['fetch', '--prune'], 120_000);
98
- else if (action === 'pull') {
152
+ else if (action === 'pull' || action === 'merge') {
99
153
  if (!status.upstream) throw new Error('This branch does not track a remote branch yet.');
100
- await gitAction(root, ['pull', '--ff-only'], 120_000);
154
+ // Pull only fast-forwards. Merge also joins a branch that has diverged
155
+ // from its upstream; conflicts leave the merge in progress to resolve.
156
+ if (action === 'pull') await gitAction(root, ['pull', '--ff-only'], 120_000);
157
+ else await gitAction(root, ['pull', '--no-rebase', '--ff', '--no-edit'], 120_000).catch(error => { if (!mergeInProgress(root)) throw error; });
101
158
  } else if (status.upstream) await gitAction(root, ['push'], 120_000);
102
159
  else {
103
160
  if (!status.branch) throw new Error('Check out a branch before pushing.');
@@ -106,6 +163,30 @@ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push')
106
163
  }
107
164
  }
108
165
 
166
+ // A merge in progress ends with its commit once no conflict markers remain,
167
+ // or is abandoned, restoring the files as they were before it.
168
+ export async function gitFinishMerge(root: string) {
169
+ const status = await gitStatus(root);
170
+ if (!status.merging) throw new Error('No merge is in progress.');
171
+ if (status.merging.conflicts.length) throw new Error(`Resolve the conflicts in ${status.merging.conflicts.join(', ')} first.`);
172
+ const resolved = status.files.filter(item => item.status === 'U').map(item => item.path);
173
+ if (resolved.length) await gitAction(root, ['add', '-A', '--', ...resolved]);
174
+ await gitAction(root, ['commit', '--no-edit']);
175
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
176
+ }
177
+ export async function gitAbortMerge(root: string) {
178
+ if (!mergeInProgress(root)) throw new Error('No merge is in progress.');
179
+ await gitAction(root, ['merge', '--abort']);
180
+ }
181
+ // What an agent needs to resolve a merge's conflicts: the files with markers
182
+ // and the commits on each side.
183
+ export async function mergeConflicts(root: string) {
184
+ const status = await gitStatus(root);
185
+ if (!status.merging) throw new Error('No merge is in progress.');
186
+ const subjects = async (range: string) => (await git(root, ['log', '-12', '--format=%s', range]).catch(() => '')).split('\n').filter(Boolean);
187
+ return { files: status.merging.conflicts, branch: status.branch || 'HEAD', upstream: status.upstream || 'the remote branch', ours: await subjects('MERGE_HEAD..HEAD'), theirs: await subjects('HEAD..MERGE_HEAD') };
188
+ }
189
+
109
190
  // Git's own message, without the usage noise, becomes the error the user sees.
110
191
  async function gitAction(root: string, args: string[], timeout = 60_000) {
111
192
  try { return (await exec('git', args, { cwd: root, timeout, env: { ...process.env, ...literal, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 64 * 1024 * 1024 })).stdout; }
@@ -34,7 +34,8 @@ 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
+ // 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) } } : {}) };
38
39
  }
39
40
 
40
41
  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
+ }