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.
- package/README.md +16 -4
- package/README.zh-CN.md +15 -3
- package/bin/doctor.mjs +239 -0
- package/bin/papilot.mjs +95 -86
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-BkowXmKr.js +206 -0
- package/dist/assets/index-Dq3s-vhH.css +1 -0
- package/dist/assets/math-BTm2hvOz.js +263 -0
- package/dist/assets/math-DEcVZfaU.css +1 -0
- package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BEn1EztO.js} +18 -18
- package/dist/index.html +11 -2
- package/examples/papilot-paper/preamble.tex +17 -11
- package/package.json +13 -2
- package/scripts/service.ts +8 -2
- package/skills/check-math.md +12 -0
- package/skills/consistency.md +11 -0
- package/skills/latex-warnings.md +12 -0
- package/skills/polish.md +13 -0
- package/skills/proofread.md +12 -0
- package/skills/reviewer-response.md +11 -0
- package/skills/shorten.md +12 -0
- package/src/commit.ts +23 -0
- package/src/conversation.ts +48 -0
- package/src/notes.ts +5 -0
- package/src/server/agents.ts +4 -3
- package/src/server/main.ts +51 -8
- package/src/server/manuscript.ts +91 -10
- package/src/server/review.ts +2 -1
- package/src/server/service-environment.ts +22 -0
- package/src/server/skills.ts +57 -0
- package/src/server/workspace.ts +186 -26
- package/src/shared.ts +17 -3
- package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
- package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +19 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/client.ts +10 -3
- package/vendor/latex-review/src/core/build.ts +53 -7
- package/vendor/latex-review/src/core/outline.ts +6 -3
- package/vendor/latex-review/src/core/publish.ts +8 -6
- package/vendor/latex-review/src/runtime.ts +14 -0
- package/vendor/latex-review/src/server.ts +2 -1
- package/vendor/latex-review/src/types.ts +16 -1
- package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
- package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
- package/vendor/latex-review/src/web/i18n.ts +48 -0
- package/vendor/latex-review/src/web/model.ts +23 -4
- package/vendor/latex-review/src/web/overview.tsx +25 -20
- package/vendor/latex-review/src/web/review.tsx +399 -145
- package/vendor/latex-review/src/web/style.css +130 -0
- package/vendor/latex-review/src/web/zh-CN.ts +188 -0
- package/dist/assets/index-BhQFCMTx.js +0 -131
- package/dist/assets/index-CAV3-87U.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
- 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
|
|
3
|
-
|
|
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
|
-
%
|
|
22
|
-
%
|
|
23
|
-
|
|
24
|
-
\
|
|
25
|
-
\
|
|
26
|
-
\
|
|
27
|
-
\
|
|
28
|
-
\declaretheorem[style=
|
|
29
|
-
\declaretheorem[style
|
|
30
|
-
\
|
|
31
|
-
\
|
|
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.
|
|
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
|
}
|
package/scripts/service.ts
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
16
|
fs.mkdirSync(DATA, { recursive: true, mode: 0o700 });
|
|
16
17
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
package/skills/polish.md
ADDED
|
@@ -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(' · ');
|
package/src/server/agents.ts
CHANGED
|
@@ -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 (
|
|
143
|
-
// Anthropic
|
|
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 };
|
package/src/server/main.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/src/server/manuscript.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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; }
|
package/src/server/review.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|