papilot 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -30
- package/README.zh-CN.md +58 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +8 -0
- package/bin/installation.mjs +54 -0
- package/bin/papilot.mjs +6 -2
- package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
- package/dist/assets/index-Cc-awdNn.css +1 -0
- package/dist/assets/index-D_VX51no.js +265 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
- package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +15 -7
- package/scripts/service.ts +1 -0
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/note-groups.ts +30 -0
- package/src/notes.ts +34 -10
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +237 -47
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/annotation-placement.ts +99 -0
- package/src/server/app.ts +216 -21
- package/src/server/attachments.ts +128 -0
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +28 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/diagnostics.ts +45 -0
- package/src/server/git-overview.ts +69 -0
- package/src/server/imports.ts +205 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/main.ts +8 -42
- package/src/server/manuscript.ts +212 -12
- package/src/server/note-results.ts +65 -0
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skill-library.ts +113 -0
- package/src/server/skill-routes.ts +19 -0
- package/src/server/skills.ts +29 -19
- package/src/server/start.ts +51 -0
- package/src/server/store.ts +44 -4
- package/src/server/update-install.ts +124 -0
- package/src/server/update-worker.ts +78 -0
- package/src/server/updates.ts +152 -0
- package/src/server/workspace.ts +450 -60
- package/src/shared.ts +120 -10
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/src/task-caption.ts +20 -0
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/build.ts +5 -1
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/delivery.ts +109 -0
- package/vendor/latex-review/src/core/review.ts +12 -4
- package/vendor/latex-review/src/core/store.ts +3 -0
- package/vendor/latex-review/src/core/tasks.ts +5 -2
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +81 -1
- package/vendor/latex-review/src/types.ts +141 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/i18n.ts +1 -1
- package/vendor/latex-review/src/web/overview.tsx +1 -5
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +381 -230
- package/vendor/latex-review/src/web/shortcuts.ts +53 -0
- package/vendor/latex-review/src/web/style.css +295 -0
- package/vendor/latex-review/src/web/zh-CN.ts +95 -26
- package/dist/assets/index-BNGqA0_L.css +0 -1
- package/dist/assets/index-Bt9UXsGX.js +0 -222
- package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { countProblems, type BuildProblem } from '../shared.ts';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// A problem as read from the log: `input` is the file as TeX named it, relative
|
|
11
|
+
// to the directory TeX ran in. It never leaves the server.
|
|
12
|
+
export interface LogProblem extends Omit<BuildProblem, 'file' | 'page' | 'rect'> { input?: string; occurrence?: number }
|
|
13
|
+
export interface StoredProblem extends BuildProblem { source?: string; occurrence?: number }
|
|
14
|
+
export { countProblems };
|
|
15
|
+
|
|
16
|
+
// Overfull lines narrower than this are invisible in print.
|
|
17
|
+
export const OVERFULL_MIN_PT = 1;
|
|
18
|
+
// Locating runs SyncTeX once or twice per problem; a paper with more problems
|
|
19
|
+
// lists the rest without a page, starting with the least important.
|
|
20
|
+
export const LOCATE_LIMIT = 300;
|
|
21
|
+
const WRAP = 79;
|
|
22
|
+
const utf8Length = (lead: number) => lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
|
|
23
|
+
|
|
24
|
+
// TeX breaks every log line at 79 columns, even inside file names. pdfTeX
|
|
25
|
+
// counts bytes and may cut a character in two, LuaTeX breaks early rather than
|
|
26
|
+
// cut one, and XeTeX counts characters. Rejoin the pieces on the raw bytes,
|
|
27
|
+
// before decoding splits a character for good.
|
|
28
|
+
export function unwrapLog(input: string | Buffer) {
|
|
29
|
+
const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : input, raw: Buffer[] = [];
|
|
30
|
+
for (let start = 0, i = 0; i <= bytes.length; i++) {
|
|
31
|
+
if (i < bytes.length && bytes[i] !== 0x0a) continue;
|
|
32
|
+
raw.push(bytes.subarray(start, i > start && bytes[i - 1] === 0x0d ? i - 1 : i)); start = i + 1;
|
|
33
|
+
}
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
let parts: Buffer[] = [];
|
|
36
|
+
for (let k = 0; k < raw.length; k++) {
|
|
37
|
+
const line = raw[k], next = raw[k + 1], text = line.toString('utf8');
|
|
38
|
+
parts.push(line);
|
|
39
|
+
const wrapped = next !== undefined && (line.length === WRAP || [...text].length === WRAP
|
|
40
|
+
|| (line.length < WRAP && next.length > 0 && next[0] >= 0xc0 && line.length + utf8Length(next[0]) > WRAP)
|
|
41
|
+
// Text decoded earlier keeps each half of a cut character as U+FFFD.
|
|
42
|
+
|| (text.endsWith('�') && next.toString('utf8').startsWith('�')));
|
|
43
|
+
if (!wrapped) { out.push(parts.length === 1 ? text : Buffer.concat(parts).toString('utf8')); parts = []; }
|
|
44
|
+
}
|
|
45
|
+
if (parts.length) out.push(Buffer.concat(parts).toString('utf8'));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const looksLikeFile = (name: string) => /^(?:\.{1,2}\/|\/|[A-Za-z]:[\\/])/.test(name) || /\.[A-Za-z][\w-]*$/.test(name);
|
|
50
|
+
const IGNORED = [
|
|
51
|
+
/^There (?:were|was) (?:undefined|multiply)/, /^Rerun to get/, /Label\(s\) may have changed/, /^Please (?:\(re\))?run/i,
|
|
52
|
+
/^Writing or overwriting file/, /^File [`'][^']+' already exists on the system/, /^Unused global option/,
|
|
53
|
+
];
|
|
54
|
+
// The source context TeX prints after an error or a pdfTeX warning: a line
|
|
55
|
+
// starting "<to be read again> " or "l.12 " quotes the source up to where TeX
|
|
56
|
+
// stopped, cut on the left, so its parentheses may be unmatched. The indented
|
|
57
|
+
// line below it continues the source and then TeX's own output, which can
|
|
58
|
+
// close and open files, so that one is read as usual.
|
|
59
|
+
const CONTEXT = /^(?:<[a-z*][a-z ]*>|l\.\d+) /;
|
|
60
|
+
|
|
61
|
+
// TeX reports which file it is reading with "(name" and ")" around everything
|
|
62
|
+
// that file prints. Only a "(" followed by a file that exists opens a file, so
|
|
63
|
+
// parentheses in messages and wrapped text keep the stack balanced but never
|
|
64
|
+
// change the current file. `opened` collects every file TeX read.
|
|
65
|
+
export function parseLatexLog(text: string | Buffer, isFile: (name: string) => boolean = () => true, opened?: Set<string>): LogProblem[] {
|
|
66
|
+
const lines = unwrapLog(text), stack: (string | null)[] = [], found: LogProblem[] = [], seen = new Set<string>(), occurrences = new Map<string, number>();
|
|
67
|
+
const current = () => { for (let i = stack.length - 1; i >= 0; i--) if (stack[i]) return stack[i]!; return undefined; };
|
|
68
|
+
const add = (problem: LogProblem) => {
|
|
69
|
+
// Keep the position among all marks on the line, before repeated keys
|
|
70
|
+
// collapse into one row. In a,a,b, b is still the third printed mark.
|
|
71
|
+
if (problem.kind === 'reference' || problem.kind === 'citation') {
|
|
72
|
+
const line = JSON.stringify([problem.input, problem.line, problem.kind]);
|
|
73
|
+
problem.occurrence = occurrences.get(line) || 0;
|
|
74
|
+
occurrences.set(line, problem.occurrence + 1);
|
|
75
|
+
}
|
|
76
|
+
const id = JSON.stringify([problem.kind, problem.key, problem.input, problem.line, problem.end, problem.message]);
|
|
77
|
+
if (!seen.has(id)) { seen.add(id); found.push(problem); }
|
|
78
|
+
};
|
|
79
|
+
// pdfTeX prints a name with spaces unquoted, so the longest run of the line
|
|
80
|
+
// that names an existing file wins.
|
|
81
|
+
const fileAt = (rest: string): [string, number] | null => {
|
|
82
|
+
const quoted = /^"([^"]+)"/.exec(rest);
|
|
83
|
+
if (quoted) return isFile(quoted[1]) ? [quoted[1], quoted[0].length] : null;
|
|
84
|
+
const run = /^[^()[\]{}<>"]*/.exec(rest.slice(0, 512))![0];
|
|
85
|
+
if (!run || /^\s/.test(run)) return null;
|
|
86
|
+
const cuts = [run.length];
|
|
87
|
+
for (let at = run.lastIndexOf(' '); at > 0; at = run.lastIndexOf(' ', at - 1)) cuts.push(at);
|
|
88
|
+
for (const cut of cuts) {
|
|
89
|
+
const name = run.slice(0, cut).trimEnd();
|
|
90
|
+
if (name && looksLikeFile(name) && isFile(name)) return [name, name.length];
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const scan = (line: string) => {
|
|
95
|
+
for (let j = 0; j < line.length; j++) {
|
|
96
|
+
if (line[j] === ')') { stack.pop(); continue; }
|
|
97
|
+
if (line[j] !== '(') continue;
|
|
98
|
+
const file = fileAt(line.slice(j + 1));
|
|
99
|
+
if (file) { stack.push(file[0]); opened?.add(file[0]); j += file[1]; } else stack.push(null);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
// file:line: message (from -file-line-error) or "! message". The file may
|
|
105
|
+
// be one TeX generated, such as the .bbl, which a failed build has removed.
|
|
106
|
+
const located = /^(.+?):(\d+): (.*)$/.exec(line);
|
|
107
|
+
const errorFile = located && (located[1] === current() || looksLikeFile(located[1])) ? located : null;
|
|
108
|
+
if (errorFile || line.startsWith('! ')) {
|
|
109
|
+
let message = errorFile ? errorFile[3] : line.slice(2), last = line, end = i + 1;
|
|
110
|
+
for (; end < lines.length && end < i + 20; end++) {
|
|
111
|
+
const next = lines[end];
|
|
112
|
+
if (!next.trim() || next.startsWith('! ') || /^(?:See |Type |l\.\d+ |\s*\.\.\.|\s*<)/.test(next) || /^(.+?):(\d+): /.test(next)) break;
|
|
113
|
+
message += (last.length >= WRAP ? '' : ' ') + next; last = next;
|
|
114
|
+
}
|
|
115
|
+
let context = -1;
|
|
116
|
+
for (let k = end; k < Math.min(lines.length, end + 12); k++) if (/^l\.\d+ /.test(lines[k])) { context = k; break; }
|
|
117
|
+
message = message.replace(/\s+/g, ' ').trim();
|
|
118
|
+
if (!/==> Fatal error occurred|^Emergency stop/.test(message)) {
|
|
119
|
+
const missing = /File [`']([^']+)' not found/.exec(message);
|
|
120
|
+
add({
|
|
121
|
+
kind: missing ? 'missing-file' : 'error', severity: 'error', message, key: missing?.[1],
|
|
122
|
+
input: errorFile ? errorFile[1] : current(), line: errorFile ? Number(errorFile[2]) : context >= 0 ? Number(/^l\.(\d+)/.exec(lines[context])![1]) : undefined,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Help text and the quoted source are TeX's commentary, not file events.
|
|
126
|
+
i = context >= 0 ? context : end - 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (CONTEXT.test(line)) continue;
|
|
130
|
+
const warning = /^(LaTeX|Package|Class|Module)(?: (\S+))? Warning: (.*)$|^(pdfTeX) warning(?: \([^)]*\))?: (.*)$/i.exec(line);
|
|
131
|
+
if (warning) {
|
|
132
|
+
const owner = warning[2] || '', isLatex = warning[1] === 'LaTeX';
|
|
133
|
+
let message = warning[3] ?? warning[5], end = i + 1;
|
|
134
|
+
// Continuation lines start with "(package)" or, for LaTeX, an indent.
|
|
135
|
+
for (; end < lines.length; end++) {
|
|
136
|
+
const next = lines[end], prefix = owner ? `(${owner})` : '';
|
|
137
|
+
if (prefix && next.startsWith(prefix)) message += ' ' + next.slice(prefix.length).trim();
|
|
138
|
+
else if (isLatex && !owner && /^\s{4,}\S/.test(next)) message += ' ' + next.trim();
|
|
139
|
+
else break;
|
|
140
|
+
}
|
|
141
|
+
i = end - 1;
|
|
142
|
+
message = message.replace(/\s+/g, ' ').trim();
|
|
143
|
+
if (owner === 'Font' || IGNORED.some(pattern => pattern.test(message))) continue;
|
|
144
|
+
const inputLine = /on input line (\d+)/.exec(message), at = inputLine ? Number(inputLine[1]) : undefined;
|
|
145
|
+
const reference = /^Reference [`']([^']+)' on page \S+ undefined/.exec(message);
|
|
146
|
+
const citation = /Citation [`']([^']+)'(?: on page \S+)? undefined/.exec(message);
|
|
147
|
+
const label = /^Label [`']([^']+)' multiply defined/.exec(message);
|
|
148
|
+
if (reference) add({ kind: 'reference', severity: 'warning', message, key: reference[1], input: current(), line: at });
|
|
149
|
+
else if (citation) add({ kind: 'citation', severity: 'warning', message, key: citation[1], input: current(), line: at });
|
|
150
|
+
// cleveref repeats every label as `name@cref`; the user's label is reported already.
|
|
151
|
+
else if (label) { if (!label[1].includes('@')) add({ kind: 'label', severity: 'warning', message, key: label[1] }); }
|
|
152
|
+
else add({ kind: 'other', severity: 'minor', message: `${owner && !isLatex ? owner + ': ' : ''}${message}`, input: current(), line: at });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const box = /^(Overfull|Underfull) \\([hv])box \((?:badness \d+|([\d.]+)pt too (?:wide|high))\)(?: (?:in paragraph|in alignment|detected) at lines? (\d+)(?:--(\d+))?)?/.exec(line);
|
|
156
|
+
if (box) {
|
|
157
|
+
const amount = Number(box[3]);
|
|
158
|
+
if (box[1] === 'Overfull' && amount >= OVERFULL_MIN_PT && box[4]) {
|
|
159
|
+
const start = Number(box[4]), stop = box[5] ? Number(box[5]) : start;
|
|
160
|
+
add({ kind: 'overfull', severity: 'minor', message: line.trim(), amount: Math.round(amount * 10) / 10, input: current(), line: start, end: stop > start ? stop : undefined });
|
|
161
|
+
}
|
|
162
|
+
// The box's contents follow until a blank line; their text has stray parentheses.
|
|
163
|
+
while (i + 1 < lines.length && lines[i + 1].trim()) i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const glyph = /^Missing character: There is no (.+?) in font (.+?)!/.exec(line);
|
|
167
|
+
if (glyph) { add({ kind: 'glyph', severity: 'warning', message: line.trim(), key: glyph[1], input: current() }); continue; }
|
|
168
|
+
scan(line);
|
|
169
|
+
}
|
|
170
|
+
// A remark repeated for many objects, such as pdfTeX's duplicate link
|
|
171
|
+
// targets for every equation, is listed once with how often it occurred.
|
|
172
|
+
const remarks = new Map<string, LogProblem>();
|
|
173
|
+
return found.filter(problem => {
|
|
174
|
+
if (problem.kind !== 'other') return true;
|
|
175
|
+
const shape = problem.message.replace(/\{[^{}]*\}/g, '{}').replace(/[`'][^`']*'/g, "''").replace(/\d+/g, '#');
|
|
176
|
+
const first = remarks.get(shape);
|
|
177
|
+
if (!first) { remarks.set(shape, problem); return true; }
|
|
178
|
+
first.count = (first.count || 1) + 1;
|
|
179
|
+
return false;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The log without the build folder's path or the home directory.
|
|
184
|
+
export function scrubLog(text: string, folder?: string, home = os.homedir()) {
|
|
185
|
+
if (folder && folder.length > 1) text = text.split(folder).join('…');
|
|
186
|
+
if (home && home.length > 1) text = text.split(home + path.sep).join('~' + path.sep);
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stripComments = (line: string) => line.replace(/(^|[^\\])%.*$/, '$1');
|
|
191
|
+
// Files TeX writes for itself: the build folder and auxiliary files. A problem
|
|
192
|
+
// reported in one has no place in the manuscript to show.
|
|
193
|
+
const GENERATED = new Set(['.aux', '.bbl', '.toc', '.lof', '.lot', '.loa', '.out', '.ind', '.idx', '.gls', '.glo', '.nls', '.brf', '.nav', '.snm', '.vrb', '.bcf']);
|
|
194
|
+
|
|
195
|
+
// Where every problem is in the manuscript. TeX names files relative to the
|
|
196
|
+
// directory it ran in; the paper shows them relative to its repository, like
|
|
197
|
+
// note sources. A label defined twice is listed at each definition among the
|
|
198
|
+
// files TeX read.
|
|
199
|
+
export function collectProblems(log: string | Buffer, { cwd, root, sourceDir, outDir = path.join(cwd, 'build') }: { cwd: string; root: string; sourceDir: string; outDir?: string }): StoredProblem[] {
|
|
200
|
+
const resolve = (name: string) => path.resolve(cwd, name), known = new Map<string, boolean>(), opened = new Set<string>();
|
|
201
|
+
const isFile = (name: string) => {
|
|
202
|
+
let result = known.get(name);
|
|
203
|
+
if (result === undefined) { try { result = fs.statSync(resolve(name)).isFile(); } catch { result = false; } known.set(name, result); }
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
206
|
+
const parsed = parseLatexLog(log, isFile, opened);
|
|
207
|
+
const display = (absolute: string) => {
|
|
208
|
+
const relative = path.relative(root, absolute);
|
|
209
|
+
return relative.startsWith('..') || path.isAbsolute(relative) ? undefined : path.posix.join(sourceDir || '', relative.split(path.sep).join('/'));
|
|
210
|
+
};
|
|
211
|
+
const generated = (absolute: string) => {
|
|
212
|
+
const inside = path.relative(outDir, absolute);
|
|
213
|
+
return (!inside.startsWith('..') && !path.isAbsolute(inside)) || GENERATED.has(path.extname(absolute)) || absolute.endsWith('.run.xml');
|
|
214
|
+
};
|
|
215
|
+
let sources: { file: string; lines: string[] }[] | null = null;
|
|
216
|
+
const output: StoredProblem[] = [];
|
|
217
|
+
for (const { input, ...problem } of parsed) {
|
|
218
|
+
if (problem.kind === 'label' && problem.key) {
|
|
219
|
+
sources ??= [...opened].map(resolve).filter(file => file.endsWith('.tex') && display(file) !== undefined && !generated(file)).flatMap(file => {
|
|
220
|
+
try { return [{ file, lines: fs.readFileSync(file, 'utf8').split('\n') }]; } catch { return []; }
|
|
221
|
+
});
|
|
222
|
+
const needle = `\\label{${problem.key}}`, places: StoredProblem[] = [];
|
|
223
|
+
for (const { file, lines } of sources) lines.forEach((text, index) => {
|
|
224
|
+
if (stripComments(text).includes(needle)) places.push({ ...problem, file: display(file), line: index + 1, source: file });
|
|
225
|
+
});
|
|
226
|
+
output.push(...(places.length ? places : [problem]));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const absolute = input ? resolve(input) : undefined;
|
|
230
|
+
if (absolute && generated(absolute)) {
|
|
231
|
+
// An error in, say, the .bbl names that file so its cause can be traced.
|
|
232
|
+
const named = problem.severity === 'error' ? { message: `${path.basename(absolute)}${problem.line ? ':' + problem.line : ''}: ${problem.message}` } : {};
|
|
233
|
+
output.push({ ...problem, ...named, line: undefined, end: undefined });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const file = absolute ? display(absolute) : undefined;
|
|
237
|
+
output.push(file ? { ...problem, file, source: absolute } : { ...problem, line: undefined, end: undefined });
|
|
238
|
+
}
|
|
239
|
+
return output;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface SyncBox { page: number; x: number; y: number; h: number; v: number; W: number; H: number }
|
|
243
|
+
// `synctex view` lists one record per box: the point it names (x, y) and the
|
|
244
|
+
// box itself, h from the left and v at its baseline, W wide and H tall.
|
|
245
|
+
export function parseSynctexView(output: string): SyncBox[] {
|
|
246
|
+
const boxes: Partial<SyncBox>[] = [];
|
|
247
|
+
for (const line of output.split('\n')) {
|
|
248
|
+
const match = /^(Page|x|y|h|v|W|H):(-?[\d.]+)\s*$/.exec(line);
|
|
249
|
+
if (!match) continue;
|
|
250
|
+
if (match[1] === 'Page') boxes.push({ page: Number(match[2]) - 1 });
|
|
251
|
+
else if (boxes.length) (boxes[boxes.length - 1] as Record<string, number>)[match[1]] = Number(match[2]);
|
|
252
|
+
}
|
|
253
|
+
return boxes.filter((box): box is SyncBox => ['page', 'x', 'y', 'h', 'v', 'W', 'H'].every(key => Number.isFinite((box as Record<string, number>)[key])));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface PageText { width: number; height: number; bounds: number[]; search(needle: string): number[][] }
|
|
257
|
+
// Put each problem on the page SyncTeX names. A missing reference or citation
|
|
258
|
+
// is narrowed to its "??" or "?" in that line; everything else marks its lines.
|
|
259
|
+
// Which problems get a place when there are more than `limit`: errors, then
|
|
260
|
+
// warnings, then typesetting, each in the order TeX reported them.
|
|
261
|
+
export function placementOrder(problems: Pick<BuildProblem, 'severity'>[], limit = LOCATE_LIMIT) {
|
|
262
|
+
const rank = { error: 0, warning: 1, minor: 2 };
|
|
263
|
+
return new Set(problems.map((problem, index) => index).sort((a, b) => rank[problems[a].severity] - rank[problems[b].severity] || a - b).slice(0, limit));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function locateProblems(problems: StoredProblem[], pdf: string, openPage: (page: number) => PageText | null, limit = LOCATE_LIMIT): Promise<BuildProblem[]> {
|
|
267
|
+
// SyncTeX reads its whole file on every call, so each source line is asked once.
|
|
268
|
+
const views = new Map<string, Promise<SyncBox[]>>();
|
|
269
|
+
const view = (file: string, line: number) => {
|
|
270
|
+
const id = `${line}:${file}`;
|
|
271
|
+
if (!views.has(id)) views.set(id, exec('synctex', ['view', '-i', `${line}:0:${file}`, '-o', pdf], { timeout: 10000 }).then(result => parseSynctexView(result.stdout), () => []));
|
|
272
|
+
return views.get(id)!;
|
|
273
|
+
};
|
|
274
|
+
// TeX warns about the missing references of one source line in reading
|
|
275
|
+
// order, so the second warning for a line belongs to its second "??".
|
|
276
|
+
const ordinal = new Map<StoredProblem, number>(), counted = new Map<string, number>();
|
|
277
|
+
for (const problem of problems) {
|
|
278
|
+
const id = JSON.stringify([problem.source, problem.line, problem.kind]), seen = counted.get(id) || 0;
|
|
279
|
+
ordinal.set(problem, problem.occurrence ?? seen); counted.set(id, (problem.occurrence ?? seen) + 1);
|
|
280
|
+
}
|
|
281
|
+
const place = async (problem: StoredProblem): Promise<BuildProblem> => {
|
|
282
|
+
const { source, occurrence: _occurrence, ...shown } = problem;
|
|
283
|
+
if (!source || !problem.line) return shown;
|
|
284
|
+
const lines = problem.end && problem.end !== problem.line ? [problem.line, problem.end] : [problem.line];
|
|
285
|
+
const boxes = (await Promise.all(lines.map(line => view(source, line)))).flat();
|
|
286
|
+
if (!boxes.length) return shown;
|
|
287
|
+
const page = boxes[0].page, text = openPage(page);
|
|
288
|
+
if (!text) return shown;
|
|
289
|
+
const [x0, y0] = text.bounds;
|
|
290
|
+
const onPage = boxes.filter(box => box.page === page), tidy = onPage.filter(box => box.H > 0 && box.H < text.height * 0.4);
|
|
291
|
+
let rect: number[] = tidy.length
|
|
292
|
+
? [Math.min(...tidy.map(b => b.h)), Math.min(...tidy.map(b => b.v - b.H)), Math.max(...tidy.map(b => b.h + b.W)), Math.max(...tidy.map(b => b.v))]
|
|
293
|
+
: [onPage[0].x - 6, onPage[0].y - 10, onPage[0].x + 6, onPage[0].y + 2];
|
|
294
|
+
rect = [rect[0] + x0, rect[1] + y0, rect[2] + x0, rect[3] + y0];
|
|
295
|
+
if (problem.kind === 'reference' || problem.kind === 'citation') {
|
|
296
|
+
// A citation prints "?" or its key; the "?" of a missing reference's "??" is not one.
|
|
297
|
+
const pairs = text.search('??'), inside = (mark: number[], outer: number[]) => mark[0] >= outer[0] - 0.5 && mark[2] <= outer[2] + 0.5 && mark[1] >= outer[1] - 0.5 && mark[3] <= outer[3] + 0.5;
|
|
298
|
+
const marks = (problem.kind === 'reference' ? pairs : [...text.search('?').filter(mark => !pairs.some(pair => inside(mark, pair))), ...(problem.key ? text.search(problem.key) : [])])
|
|
299
|
+
.filter(mark => { const middle = (mark[1] + mark[3]) / 2; return onPage.some(box => middle >= box.v - box.H + y0 - 3 && middle <= box.v + y0 + 3); })
|
|
300
|
+
.sort((a, b) => Math.abs(a[1] - b[1]) > 2 ? a[1] - b[1] : a[0] - b[0]);
|
|
301
|
+
// SyncTeX's points sit on the nodes this source line printed, so a mark
|
|
302
|
+
// that starts beside one came from this line, not from its neighbours.
|
|
303
|
+
const gap = (mark: number[]) => Math.min(...onPage.map(box => Math.abs(mark[0] - (box.x + x0))));
|
|
304
|
+
const own = marks.filter(mark => gap(mark) <= 6);
|
|
305
|
+
let best: number[] | undefined = own[ordinal.get(problem) || 0];
|
|
306
|
+
if (!best && marks.length) best = marks.reduce((a, b) => gap(b) < gap(a) ? b : a);
|
|
307
|
+
if (best) rect = [best[0] - 2, best[1] - 1, best[2] + 2, best[3] + 1];
|
|
308
|
+
}
|
|
309
|
+
const unit = (value: number) => Math.min(1, Math.max(0, Math.round(value * 10000) / 10000));
|
|
310
|
+
return { ...shown, page, rect: [unit((rect[0] - x0) / text.width), unit((rect[1] - y0) / text.height), unit((rect[2] - x0) / text.width), unit((rect[3] - y0) / text.height)] };
|
|
311
|
+
};
|
|
312
|
+
const output: BuildProblem[] = new Array(problems.length), chosen = placementOrder(problems, limit);
|
|
313
|
+
let next = 0;
|
|
314
|
+
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
315
|
+
for (let index = next++; index < problems.length; index = next++) {
|
|
316
|
+
if (!chosen.has(index)) { const { source: _source, occurrence: _occurrence, ...shown } = problems[index]; output[index] = shown; continue; }
|
|
317
|
+
output[index] = await place(problems[index]);
|
|
318
|
+
}
|
|
319
|
+
}));
|
|
320
|
+
return output;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Errors first, then the paper's order: page, then position on the page.
|
|
324
|
+
export function sortProblems(problems: BuildProblem[]) {
|
|
325
|
+
const rank = { error: 0, warning: 1, minor: 2 };
|
|
326
|
+
return [...problems].sort((a, b) => rank[a.severity] - rank[b.severity]
|
|
327
|
+
|| (a.page ?? Infinity) - (b.page ?? Infinity) || (a.rect?.[1] ?? 0) - (b.rect?.[1] ?? 0)
|
|
328
|
+
|| (a.file || '').localeCompare(b.file || '') || (a.line ?? 0) - (b.line ?? 0));
|
|
329
|
+
}
|
package/src/server/changes.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { DATA, ENGINE } from './paths.ts';
|
|
6
6
|
import { mergeInProgress, workingTree } from './manuscript.ts';
|
|
7
7
|
import { ignorePattern } from '../commit.ts';
|
|
8
|
+
import { checker, confirmations, engine as referenceEngine } from './references.ts';
|
|
8
9
|
|
|
9
10
|
// The manuscript's uncommitted changes as a PDF comparison: the last commit
|
|
10
11
|
// against the working tree, rendered by the review engine like a task's round
|
|
@@ -78,6 +79,9 @@ const readBytes = (file: string) => { try { return fs.readFileSync(file); } catc
|
|
|
78
79
|
|
|
79
80
|
export class Changes {
|
|
80
81
|
private comparisons = new Map<string, Comparison>();
|
|
82
|
+
get busyForUpdate() { return [...this.comparisons.values()].some(comparison => comparison.job?.status === 'running'); }
|
|
83
|
+
busyForProject(id: string) { return this.comparisons.get(id)?.job?.status === 'running'; }
|
|
84
|
+
forgetProject(id: string) { this.comparisons.delete(id); }
|
|
81
85
|
constructor(private dir = path.join(DATA, 'changes')) { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
82
86
|
|
|
83
87
|
private comparison(projectId: string) {
|
|
@@ -194,14 +198,36 @@ export class Changes {
|
|
|
194
198
|
detail(projectId: string, number: number) {
|
|
195
199
|
const round = this.comparison(projectId).rounds.find(item => item.number === number);
|
|
196
200
|
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
|
|
197
|
-
|
|
198
|
-
try { render = JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { /* An empty comparison has no render. */ }
|
|
201
|
+
const render = this.rendered(round);
|
|
199
202
|
return {
|
|
200
203
|
task: 'changes', round: round.number, before: round.before, after: round.after,
|
|
201
204
|
units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
|
|
202
205
|
warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
|
|
206
|
+
...(render.references ? { references: render.references } : {}),
|
|
203
207
|
};
|
|
204
208
|
}
|
|
209
|
+
private rendered(round: Round): { units: Record<string, object>; sections: unknown[]; loose: unknown[]; references?: unknown } {
|
|
210
|
+
try { return JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { return { units: {}, sections: [], loose: [] }; } // An empty comparison has no render.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Online checks of the bibliography entries the uncommitted changes add or
|
|
214
|
+
// modify, as for a task's round; check asks for lookups to start.
|
|
215
|
+
async references(projectId: string, root: string, number: number, check: boolean, retry: boolean) {
|
|
216
|
+
const inputs = await this.entryInputs(projectId, number);
|
|
217
|
+
return { ...(await checker()).lookup(inputs, check, retry), confirmed: (await confirmations(root)).of(inputs) };
|
|
218
|
+
}
|
|
219
|
+
// The author checked an entry by hand, or takes that back.
|
|
220
|
+
async confirmReference(projectId: string, root: string, number: number, id: string, on: boolean) {
|
|
221
|
+
const inputs = await this.entryInputs(projectId, number), input = inputs.find(item => item.id === id);
|
|
222
|
+
if (!input) throw Object.assign(new Error('This entry is no longer among the changes. Refresh the page.'), { statusCode: 404 });
|
|
223
|
+
(await confirmations(root)).set(input, on);
|
|
224
|
+
return this.references(projectId, root, number, false, false);
|
|
225
|
+
}
|
|
226
|
+
private async entryInputs(projectId: string, number: number) {
|
|
227
|
+
const round = this.comparison(projectId).rounds.find(item => item.number === number);
|
|
228
|
+
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
|
|
229
|
+
return (await referenceEngine()).checkInputs(this.rendered(round).references);
|
|
230
|
+
}
|
|
205
231
|
|
|
206
232
|
// The places a change alters away from its own lines, past those the
|
|
207
233
|
// comparison rendered, rendered into its folder when first asked for.
|
package/src/server/codex.ts
CHANGED
|
@@ -34,7 +34,8 @@ export class Codex extends EventEmitter {
|
|
|
34
34
|
};
|
|
35
35
|
child.once('error', failed);
|
|
36
36
|
child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`)));
|
|
37
|
-
|
|
37
|
+
// The experimental API carries the tools Papilot gives its threads (see VIEW_PDF_TOOL).
|
|
38
|
+
await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION }, capabilities: { experimentalApi: true } });
|
|
38
39
|
this.notify('initialized', {}); this.ready = true;
|
|
39
40
|
}
|
|
40
41
|
private receive(message: any) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Adapter, loadPackage, toolItem, type Item, type Thread, type Turn } from './agents.ts';
|
|
5
|
+
import { imageType } from './attachments.ts';
|
|
6
|
+
import { DATA } from './paths.ts';
|
|
7
|
+
|
|
8
|
+
// The parts of @cursor/sdk Papilot uses. The SDK is fetched on first use, so
|
|
9
|
+
// its types are restated here instead of being a build dependency.
|
|
10
|
+
export const CURSOR_SDK_VERSION = '1.2.0';
|
|
11
|
+
interface ModelSelection { id: string; params?: { id: string; value: string }[] }
|
|
12
|
+
interface CursorModel { id: string; displayName: string; parameters?: { id: string; values: { value: string; displayName?: string }[] }[]; variants?: { params: { id: string; value: string }[]; isDefault?: boolean }[] }
|
|
13
|
+
interface CursorMessage { type: string; call_id?: string; name?: string; status?: string; args?: any; result?: any; message?: { content: { type: string; text?: string }[] } }
|
|
14
|
+
interface CursorRun { stream(): AsyncGenerator<CursorMessage, void>; wait(): Promise<{ status: 'finished' | 'error' | 'cancelled'; error?: { message: string } }>; cancel(): Promise<void> }
|
|
15
|
+
interface CursorAgentHandle { agentId: string; send(message: string | { text: string; images?: { data: string; mimeType: string }[] }, options?: { model?: ModelSelection; onDelta?: (args: { update: { type: string; text?: string } }) => void }): Promise<CursorRun>; close(): void }
|
|
16
|
+
interface AgentOptions { model?: ModelSelection; name?: string; local: { cwd: string; settingSources: string[]; sandboxOptions: { enabled: boolean } } }
|
|
17
|
+
export interface CursorSdk {
|
|
18
|
+
Agent: { create(options: AgentOptions): Promise<CursorAgentHandle>; resume(agentId: string, options: Partial<AgentOptions>): Promise<CursorAgentHandle> };
|
|
19
|
+
Cursor: { me(): Promise<{ apiKeyName: string; userEmail?: string }>; models: { list(): Promise<CursorModel[]> } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CURSOR_KEY = 'Cursor needs a Cursor API key: start Papilot with CURSOR_API_KEY set (create one in the Cursor dashboard). The SDK does not use the Cursor app or CLI login.';
|
|
23
|
+
// The SDK reads CURSOR_API_KEY, then the login it stores itself.
|
|
24
|
+
export function cursorConfigured(env: NodeJS.ProcessEnv = process.env) {
|
|
25
|
+
return Boolean(env.CURSOR_API_KEY) || fs.existsSync(path.join(os.homedir(), '.cursor/sdk/auth.json'));
|
|
26
|
+
}
|
|
27
|
+
let cursor: Promise<CursorSdk> | null = null;
|
|
28
|
+
export function loadCursor() {
|
|
29
|
+
cursor ??= loadPackage('@cursor/sdk', CURSOR_SDK_VERSION, 'the Cursor SDK for Cursor tasks', () => { if (!cursorConfigured()) throw new Error(CURSOR_KEY); })
|
|
30
|
+
.catch(error => { cursor = null; throw error; });
|
|
31
|
+
return cursor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Effort is a model parameter in Cursor's catalog.
|
|
35
|
+
const effortParameter = (model?: CursorModel) => model?.parameters?.find(parameter => /effort|reasoning/i.test(parameter.id));
|
|
36
|
+
|
|
37
|
+
// Cursor runs as a local SDK agent in the task worktree, with Cursor's own
|
|
38
|
+
// sandbox: writes stay in the worktree and temporary folders, and commands
|
|
39
|
+
// run without network access. The SDK has no interactive approvals, so
|
|
40
|
+
// anything outside the sandbox fails and is reported in the conversation.
|
|
41
|
+
// Each Papilot thread maps to a Cursor agent ID, resumed after a restart.
|
|
42
|
+
export class CursorAgent extends Adapter {
|
|
43
|
+
private user: { apiKeyName: string; userEmail?: string } | null = null;
|
|
44
|
+
private catalog: CursorModel[] = [];
|
|
45
|
+
private agents = new Map<string, CursorAgentHandle>();
|
|
46
|
+
private ids: Record<string, string> = {};
|
|
47
|
+
constructor(private load: () => Promise<CursorSdk> = loadCursor, private file = path.join(DATA, 'cursor-agents.json'), private configured = () => cursorConfigured()) {
|
|
48
|
+
super();
|
|
49
|
+
try { this.ids = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { /* None yet. */ }
|
|
50
|
+
}
|
|
51
|
+
detect() { return this.configured(); }
|
|
52
|
+
async start() {
|
|
53
|
+
if (this.ready) return;
|
|
54
|
+
const sdk = await this.load();
|
|
55
|
+
const [user, models] = await Promise.all([sdk.Cursor.me(), sdk.Cursor.models.list()]);
|
|
56
|
+
this.user = user; this.catalog = models; this.ready = true;
|
|
57
|
+
}
|
|
58
|
+
private defaultModel() { return this.catalog.find(model => model.id === 'auto') || this.catalog[0]; }
|
|
59
|
+
protected account() {
|
|
60
|
+
return this.user ? { account: { email: this.user.userEmail, type: 'Cursor API key' }, requiresOpenaiAuth: false } : { account: null, requiresOpenaiAuth: true, reason: CURSOR_KEY };
|
|
61
|
+
}
|
|
62
|
+
protected models() {
|
|
63
|
+
const preferred = this.defaultModel();
|
|
64
|
+
return { data: this.catalog.map(model => {
|
|
65
|
+
const parameter = effortParameter(model), fallback = model.variants?.find(variant => variant.isDefault)?.params.find(param => param.id === parameter?.id)?.value;
|
|
66
|
+
return { model: model.id, displayName: model.displayName, isDefault: model === preferred,
|
|
67
|
+
supportedReasoningEfforts: (parameter?.values || []).map(value => ({ reasoningEffort: value.value })), defaultReasoningEffort: fallback || null };
|
|
68
|
+
}) };
|
|
69
|
+
}
|
|
70
|
+
private selection(model: string | null, effort: string | null): ModelSelection | undefined {
|
|
71
|
+
const entry = this.catalog.find(item => item.id === model) || (model ? undefined : this.defaultModel());
|
|
72
|
+
const id = entry?.id || model;
|
|
73
|
+
if (!id) return undefined;
|
|
74
|
+
const parameter = effortParameter(entry);
|
|
75
|
+
if (effort && !parameter?.values.some(value => value.value === effort)) throw new Error(`Cursor does not support reasoning effort ${effort} for this model.`);
|
|
76
|
+
return { id, ...(effort && parameter ? { params: [{ id: parameter.id, value: effort }] } : {}) };
|
|
77
|
+
}
|
|
78
|
+
private async agent(threadId: string, cwd: string, model: ModelSelection | undefined) {
|
|
79
|
+
const known = this.agents.get(threadId);
|
|
80
|
+
if (known) return known;
|
|
81
|
+
const sdk = await this.load();
|
|
82
|
+
const options: AgentOptions = { local: { cwd, settingSources: ['project'], sandboxOptions: { enabled: true } } };
|
|
83
|
+
const id = this.ids[threadId];
|
|
84
|
+
const agent = id ? await sdk.Agent.resume(id, options) : await sdk.Agent.create({ ...options, name: 'Papilot task', ...(model ? { model } : {}) });
|
|
85
|
+
if (!id) { this.ids[threadId] = agent.agentId; this.save(); }
|
|
86
|
+
this.agents.set(threadId, agent);
|
|
87
|
+
return agent;
|
|
88
|
+
}
|
|
89
|
+
protected run(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) { void this.execute(turn, thread, text, effort, images); }
|
|
90
|
+
private async execute(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) {
|
|
91
|
+
let stopped = false, run: CursorRun | null = null;
|
|
92
|
+
turn.stop = () => { stopped = true; void run?.cancel().catch(() => {}); if (!run) this.finish(turn, 'interrupted'); };
|
|
93
|
+
let live = '', segment = 0, streamed = false;
|
|
94
|
+
const flush = () => {
|
|
95
|
+
if (live.trim()) this.item(turn, { id: `${turn.id}:text:${segment}`, type: 'agentMessage', text: live });
|
|
96
|
+
if (live) segment++;
|
|
97
|
+
live = '';
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
const model = this.selection(thread.model, effort);
|
|
101
|
+
const agent = await this.agent(turn.threadId, thread.cwd, model);
|
|
102
|
+
// Cursor's harness prompt stays in place: the first turn carries Papilot's instructions.
|
|
103
|
+
const prompt = thread.started ? text : `${thread.instructions}\n\n---\n\n${text}`;
|
|
104
|
+
const attached = await Promise.all(images.map(async file => {
|
|
105
|
+
const bytes = await fs.promises.readFile(file), mimeType = imageType(bytes);
|
|
106
|
+
if (!mimeType) throw new Error('This image format is not supported.');
|
|
107
|
+
return { data: bytes.toString('base64'), mimeType };
|
|
108
|
+
}));
|
|
109
|
+
if (stopped) return;
|
|
110
|
+
run = await agent.send(attached.length ? { text: prompt, images: attached } : prompt, { ...(model ? { model } : {}), onDelta: ({ update }) => {
|
|
111
|
+
if (update.type !== 'text-delta' || !update.text) return;
|
|
112
|
+
streamed = true; live += update.text;
|
|
113
|
+
this.notify('item/agentMessage/delta', { threadId: turn.threadId, turnId: turn.id, itemId: `${turn.id}:text:${segment}`, delta: update.text });
|
|
114
|
+
} });
|
|
115
|
+
this.threads.set(turn.threadId, { ...thread, started: true });
|
|
116
|
+
if (stopped) await run.cancel().catch(() => {});
|
|
117
|
+
for await (const message of run.stream()) {
|
|
118
|
+
if (message.type === 'tool_call' && message.call_id) { flush(); this.tool(turn, message); }
|
|
119
|
+
// Without text deltas, the completed assistant text is the reply.
|
|
120
|
+
else if (message.type === 'assistant' && !streamed) for (const block of message.message?.content || []) if (block.type === 'text' && block.text) live += block.text;
|
|
121
|
+
}
|
|
122
|
+
const result = await run.wait();
|
|
123
|
+
flush();
|
|
124
|
+
if (stopped || result.status === 'cancelled') this.finish(turn, 'interrupted');
|
|
125
|
+
else if (result.status === 'finished') this.finish(turn, 'completed');
|
|
126
|
+
else this.finish(turn, 'failed', result.error?.message || 'Cursor stopped with an error.');
|
|
127
|
+
} catch (error) {
|
|
128
|
+
flush();
|
|
129
|
+
this.agents.delete(turn.threadId);
|
|
130
|
+
this.finish(turn, stopped ? 'interrupted' : 'failed', stopped ? undefined : error instanceof Error ? error.message : String(error));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
private tool(turn: Turn, message: CursorMessage) {
|
|
134
|
+
const id = `${turn.id}:${message.call_id}`, args = message.args && typeof message.args === 'object' ? message.args : {};
|
|
135
|
+
const status = message.status === 'error' || message.result?.status === 'error' ? 'failed' : message.status === 'completed' ? 'completed' : 'inProgress';
|
|
136
|
+
let item: Item;
|
|
137
|
+
if (message.name === 'shell') {
|
|
138
|
+
const value = message.result?.value || {}, output = [value.stdout, value.stderr].filter(Boolean).join('\n') || (message.result?.error ? String(message.result.error.message || message.result.error) : '');
|
|
139
|
+
item = { id, type: 'commandExecution', command: String(args.command || 'shell'), aggregatedOutput: output.slice(-40000), status };
|
|
140
|
+
} else if (['edit', 'write', 'delete'].includes(message.name || '') && typeof args.path === 'string') {
|
|
141
|
+
item = { id, type: 'fileChange', changes: [{ path: args.path }], status };
|
|
142
|
+
} else item = { ...toolItem(id, message.name || 'tool', { ...args, file_path: args.path, pattern: args.pattern || args.globPattern }), status };
|
|
143
|
+
this.item(turn, item, status !== 'inProgress');
|
|
144
|
+
}
|
|
145
|
+
private save() {
|
|
146
|
+
try { fs.mkdirSync(path.dirname(this.file), { recursive: true }); fs.writeFileSync(this.file, JSON.stringify(this.ids)); } catch { /* A later turn starts a new agent. */ }
|
|
147
|
+
}
|
|
148
|
+
close() {
|
|
149
|
+
super.close();
|
|
150
|
+
for (const agent of this.agents.values()) { try { agent.close(); } catch { /* Already closed. */ } }
|
|
151
|
+
this.agents.clear(); this.ready = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { DiagnosticCheck, DiagnosticReport } from '../shared.ts';
|
|
4
|
+
import { DATA, PORT, ROOT } from './paths.ts';
|
|
5
|
+
|
|
6
|
+
type DoctorReport = Pick<DiagnosticReport, 'version' | 'ok' | 'checks'>;
|
|
7
|
+
|
|
8
|
+
// A failed environment check exits with 1 and still returns a useful report.
|
|
9
|
+
// Never surface raw stdout/stderr from a failed diagnostic process.
|
|
10
|
+
export function parseDoctorReport(output: string): DoctorReport {
|
|
11
|
+
const report = JSON.parse(output);
|
|
12
|
+
if (typeof report.version !== 'string' || typeof report.ok !== 'boolean' || !Array.isArray(report.checks) || !report.checks.length || !report.checks.every((item: DiagnosticCheck) =>
|
|
13
|
+
item && typeof item.id === 'string' && typeof item.ok === 'boolean' && ['error', 'warn', 'info'].includes(item.severity)
|
|
14
|
+
&& typeof item.detail === 'string' && (item.fix === null || typeof item.fix === 'string'))) throw new Error('Invalid diagnostic report.');
|
|
15
|
+
return { version: report.version, ok: report.ok, checks: report.checks };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function runDoctor(repo?: string, port = PORT): Promise<DoctorReport> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const args = [path.join(ROOT, 'bin/papilot.mjs'), 'doctor', '--json', '--data', DATA, '--port', String(port), ...(repo ? ['--repo', repo] : [])];
|
|
21
|
+
const child = execFile(process.execPath, args, { cwd: ROOT, timeout: 45000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
22
|
+
if (!error || error.code === 1) {
|
|
23
|
+
try { resolve(parseDoctorReport(stdout)); return; } catch { /* Report only the safe failure below. */ }
|
|
24
|
+
}
|
|
25
|
+
reject(new Error('Could not complete the environment checks. Please try again.'));
|
|
26
|
+
});
|
|
27
|
+
child.stdin?.end();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Tabs checking the same paper share one probe, without caching old results.
|
|
32
|
+
export class Diagnostics {
|
|
33
|
+
private pending = new Map<string, Promise<DiagnosticReport>>();
|
|
34
|
+
constructor(private probe: (repo?: string, port?: number) => Promise<DoctorReport> = runDoctor) {}
|
|
35
|
+
check(repo?: string, port = PORT): Promise<DiagnosticReport> {
|
|
36
|
+
const key = JSON.stringify([repo, port]);
|
|
37
|
+
const current = this.pending.get(key);
|
|
38
|
+
if (current) return current;
|
|
39
|
+
const run = Promise.resolve().then(() => this.probe(repo, port)).then(report => ({
|
|
40
|
+
...report, checkedAt: Date.now(), platform: process.platform, arch: process.arch, node: process.versions.node,
|
|
41
|
+
})).finally(() => { this.pending.delete(key); });
|
|
42
|
+
this.pending.set(key, run);
|
|
43
|
+
return run;
|
|
44
|
+
}
|
|
45
|
+
}
|