papilot 0.7.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 +52 -30
- package/README.zh-CN.md +54 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
- 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-BsEl3u6P.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 +10 -6
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +53 -11
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +165 -23
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +3 -3
- package/src/server/skills.ts +5 -8
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +43 -14
- package/src/server/workspace.ts +183 -29
- package/src/shared.ts +78 -7
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -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/changes.ts +39 -0
- package/vendor/latex-review/src/core/review.ts +1 -0
- 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 +65 -0
- package/vendor/latex-review/src/types.ts +125 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +259 -131
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +75 -0
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
|
@@ -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,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import type { LatexInstallation } from '../shared.ts';
|
|
6
|
+
|
|
7
|
+
type Probe = (file: string, args: string[], env: NodeJS.ProcessEnv) => Promise<string | null>;
|
|
8
|
+
const command: Probe = (file, args, env) => new Promise(resolve => {
|
|
9
|
+
const child = execFile(file, args, { env, cwd: os.homedir(), timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 128 * 1024 }, (error, stdout) => resolve(error ? null : stdout.trim()));
|
|
10
|
+
child.stdin?.end();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Identify the installation actually found on the server's PATH. In particular,
|
|
14
|
+
// a MacTeX receipt elsewhere on the machine must not relabel another TeX Live.
|
|
15
|
+
export async function detectLatex(env: NodeJS.ProcessEnv = process.env, platform = process.platform, probe: Probe = command): Promise<LatexInstallation | null> {
|
|
16
|
+
const suffix = platform === 'win32' ? '.exe' : '';
|
|
17
|
+
const executable = (name: string) => (env.PATH || '').split(path.delimiter).filter(Boolean).map(directory => path.resolve(directory, name + suffix)).find(candidate => {
|
|
18
|
+
try { fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return fs.statSync(candidate).isFile(); } catch { return false; }
|
|
19
|
+
});
|
|
20
|
+
let found = false;
|
|
21
|
+
for (const engine of ['pdflatex', 'xelatex', 'lualatex']) {
|
|
22
|
+
const file = executable(engine);
|
|
23
|
+
if (!file) continue;
|
|
24
|
+
found = true;
|
|
25
|
+
const banner = await probe(file, ['--version'], env);
|
|
26
|
+
if (!banner) continue;
|
|
27
|
+
const texlive = banner.match(/\bTeX Live (\d{4})\b/i);
|
|
28
|
+
const miktex = banner.match(/\bMiKTeX[ \t]+([\d.]+)/i);
|
|
29
|
+
let distribution: LatexInstallation['distribution'] = texlive ? 'TeX Live' : /\bMiKTeX\b/i.test(banner) ? 'MiKTeX' : null;
|
|
30
|
+
const resolved = fs.realpathSync(file);
|
|
31
|
+
const root = await probe(path.join(path.dirname(file), 'kpsewhich' + suffix), ['-var-value=TEXMFROOT'], env);
|
|
32
|
+
const location = root && path.isAbsolute(root) && !/[\r\n]/.test(root) ? root : path.dirname(resolved);
|
|
33
|
+
if (platform === 'darwin' && texlive) {
|
|
34
|
+
const receipt = await probe('/usr/sbin/pkgutil', ['--file-info', resolved], env);
|
|
35
|
+
const ids = [...(receipt || '').matchAll(/^pkgid:\s*(\S+)/gm)].map(match => match[1]);
|
|
36
|
+
if (ids.some(id => /^org\.tug\.mactex\.basictex\d*$/i.test(id))) distribution = 'BasicTeX';
|
|
37
|
+
else if (ids.some(id => /^org\.tug\.mactex\.texlive\d*$/i.test(id))) distribution = 'MacTeX';
|
|
38
|
+
}
|
|
39
|
+
return { distribution, version: texlive?.[1] || miktex?.[1] || null, path: location };
|
|
40
|
+
}
|
|
41
|
+
if (found) throw new Error('Could not read the LaTeX installation.');
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/server/manuscript.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReviewSnapshot } from './ai-review.ts';
|
|
1
2
|
import { execFile } from 'node:child_process';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { EventEmitter } from 'node:events';
|
|
@@ -7,10 +8,11 @@ import path from 'node:path';
|
|
|
7
8
|
import { promisify } from 'node:util';
|
|
8
9
|
import { pathToFileURL } from 'node:url';
|
|
9
10
|
import { ENGINE } from './paths.ts';
|
|
10
|
-
import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
|
|
11
|
+
import type { Annotation, BuildProblem, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
|
|
11
12
|
import { notesInstructions } from '../notes.ts';
|
|
12
13
|
import { ignorePattern } from '../commit.ts';
|
|
13
14
|
import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
|
|
15
|
+
import { collectProblems, countProblems, locateProblems, scrubLog, sortProblems, type StoredProblem } from './build-problems.ts';
|
|
14
16
|
|
|
15
17
|
type Rect = [number, number, number, number];
|
|
16
18
|
|
|
@@ -340,12 +342,21 @@ export async function pdfWords(pdf: string, page: number) {
|
|
|
340
342
|
} finally { document.close(); }
|
|
341
343
|
}
|
|
342
344
|
|
|
343
|
-
|
|
345
|
+
// `problems` come from the last finished build. They are placed on the PDF
|
|
346
|
+
// named `problemsKey`; a failed build has no PDF of its own to place them on.
|
|
347
|
+
interface Build {
|
|
348
|
+
tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string;
|
|
349
|
+
problems?: StoredProblem[]; problemsKey?: string; log?: string; logFolder?: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
344
353
|
|
|
345
354
|
export class Manuscripts extends EventEmitter {
|
|
346
355
|
private builds = new Map<string, Build>();
|
|
347
356
|
private oldPdfs = new Map<string, string | null>();
|
|
348
357
|
private placements = new Map<string, (Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'>) | null>();
|
|
358
|
+
// Placed problems per problem list; a rebuild in progress shares its predecessor's list.
|
|
359
|
+
private located = new WeakMap<StoredProblem[], Promise<BuildProblem[]>>();
|
|
349
360
|
get busyForUpdate() { return [...this.builds.values()].some(build => build.status === 'building'); }
|
|
350
361
|
busyForProject(id: string) { return this.builds.get(id)?.status === 'building'; }
|
|
351
362
|
forgetProject(id: string) { this.builds.delete(id); }
|
|
@@ -355,29 +366,55 @@ export class Manuscripts extends EventEmitter {
|
|
|
355
366
|
let build = this.builds.get(projectId);
|
|
356
367
|
if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
|
|
357
368
|
const { status: state, key, pages, builtAt, error } = build;
|
|
358
|
-
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready' } };
|
|
369
|
+
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems) } };
|
|
359
370
|
}
|
|
360
371
|
|
|
361
372
|
// Keep the previous pages visible while a newer working tree compiles.
|
|
362
373
|
private start(projectId: string, root: string, tree: string, previous?: Build, force = false) {
|
|
363
374
|
if (previous?.key && previous.pdf) this.oldPdfs.set(`${root}:${previous.key}`, previous.pdf);
|
|
364
|
-
const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt };
|
|
375
|
+
const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt, problems: previous?.problems, problemsKey: previous?.problemsKey, log: previous?.log, logFolder: previous?.logFolder };
|
|
365
376
|
this.builds.set(projectId, build);
|
|
366
377
|
void (async () => {
|
|
378
|
+
let project: { source_dir?: string; doc_dir?: string; state: string } | undefined;
|
|
367
379
|
try {
|
|
368
380
|
const { Builder, Repo, loadProject, mupdf } = await engine();
|
|
369
|
-
|
|
381
|
+
project = loadProject(root);
|
|
382
|
+
const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force });
|
|
370
383
|
const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
|
|
371
384
|
const pages: [number, number][] = [];
|
|
372
385
|
for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
|
|
373
386
|
document.destroy?.();
|
|
374
387
|
const key = createHash('sha256').update(result.key + result.pdf).digest('hex').slice(0, 16);
|
|
375
388
|
if (this.builds.get(projectId) !== build) return;
|
|
376
|
-
|
|
389
|
+
const log = String(result.pdf).replace(/\.pdf$/, '.log');
|
|
390
|
+
let problems: StoredProblem[] = [];
|
|
391
|
+
try { problems = collectProblems(fs.readFileSync(log), { cwd: result.cwd, root: result.root, sourceDir: project!.source_dir || '', outDir: path.dirname(result.pdf) }); } catch { /* A build without a readable log reports no problems. */ }
|
|
392
|
+
Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now(), error: undefined, problems, problemsKey: key, log: fs.existsSync(log) ? log : undefined, logFolder: path.dirname(result.root) });
|
|
393
|
+
// Place the problems now, so the list is ready when someone opens it.
|
|
394
|
+
if (problems.length) void this.problems(projectId).catch(() => undefined);
|
|
377
395
|
} catch (error) {
|
|
378
396
|
if (this.builds.get(projectId) !== build) return;
|
|
379
|
-
const detail = error as { message?: string; details?: string[] };
|
|
380
|
-
|
|
397
|
+
const detail = error as { message?: string; details?: string[]; log?: string };
|
|
398
|
+
const message = [detail.message, ...(detail.details || [])].filter(Boolean).join('\n') || 'The manuscript could not be compiled.';
|
|
399
|
+
// The engine keeps latexmk's transcript and the TeX log of a failed
|
|
400
|
+
// build; its sources are gone, so files resolve in the working tree.
|
|
401
|
+
let problems: StoredProblem[] = [];
|
|
402
|
+
if (detail.log && project) try {
|
|
403
|
+
const kept = fs.readFileSync(detail.log), marker = Buffer.from('\n\n--- TeX log ---\n'), at = kept.indexOf(marker);
|
|
404
|
+
// TeX stopped in its first pass, before references resolve, so only
|
|
405
|
+
// the errors of a failed run mean anything.
|
|
406
|
+
if (at >= 0) problems = collectProblems(kept.subarray(at + marker.length), { cwd: path.join(root, project.doc_dir || '.'), root: path.join(root, project.source_dir || '.'), sourceDir: project.source_dir || '' })
|
|
407
|
+
.filter(problem => problem.severity === 'error').map(({ source: _source, ...problem }) => problem);
|
|
408
|
+
} catch { /* Fall back to the compiler's own message below. */ }
|
|
409
|
+
// BibTeX, Biber, latexmk and helper programs fail outside the TeX log;
|
|
410
|
+
// whatever the compiler said that the parsed errors do not cover stays.
|
|
411
|
+
const plain = (text: string) => text.replace(/\s+/g, ' ').trim();
|
|
412
|
+
const unexplained = (detail.details || []).filter(line => line.trim() && !/==> Fatal error occurred|^Emergency stop/.test(line) && !problems.some(problem => plain(line).includes(plain(problem.message).replace(/^[^:]+:\d+: /, ''))));
|
|
413
|
+
const logFolder = detail.log && project ? path.join(project.state, 'cache/builds', path.basename(detail.log, '.log')) : undefined;
|
|
414
|
+
if (!problems.length || unexplained.length) problems.push({ kind: 'error', severity: 'error', message: scrubLog([...(problems.length ? [] : [detail.message || 'The manuscript could not be compiled.']), ...unexplained].join('\n'), logFolder) });
|
|
415
|
+
// The previous PDF stays on screen, and so do its warnings and places.
|
|
416
|
+
const shown = build.problemsKey && build.problemsKey === build.key ? (build.problems || []).filter(problem => problem.severity !== 'error') : [];
|
|
417
|
+
Object.assign(build, { status: 'failed', error: message, problems: [...problems, ...shown], problemsKey: shown.length ? build.problemsKey : undefined, log: detail.log && fs.existsSync(detail.log) ? detail.log : undefined, logFolder });
|
|
381
418
|
}
|
|
382
419
|
this.emit('change', projectId);
|
|
383
420
|
})();
|
|
@@ -400,21 +437,7 @@ export class Manuscripts extends EventEmitter {
|
|
|
400
437
|
if (!this.placements.has(cacheKey)) {
|
|
401
438
|
let placement: Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'> | null = null;
|
|
402
439
|
try {
|
|
403
|
-
const
|
|
404
|
-
if (!this.oldPdfs.has(lookup)) {
|
|
405
|
-
const cache = path.join(loadProject(root).state, 'cache/builds');
|
|
406
|
-
let found: string | null = null;
|
|
407
|
-
for (const entry of fs.readdirSync(cache, { withFileTypes: true })) {
|
|
408
|
-
if (!entry.isDirectory() || entry.name.endsWith('.lock')) continue;
|
|
409
|
-
try {
|
|
410
|
-
const folder = path.join(cache, entry.name), saved = JSON.parse(fs.readFileSync(path.join(folder, 'result.json'), 'utf8'));
|
|
411
|
-
if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) continue;
|
|
412
|
-
if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === note.key && fs.existsSync(saved.pdf)) { found = saved.pdf; break; }
|
|
413
|
-
} catch { /* An incomplete or pruned build cannot locate a note. */ }
|
|
414
|
-
}
|
|
415
|
-
this.oldPdfs.set(lookup, found);
|
|
416
|
-
}
|
|
417
|
-
const pdf = this.oldPdfs.get(lookup);
|
|
440
|
+
const pdf = await this.cachedPdf(root, note.key);
|
|
418
441
|
if (pdf) {
|
|
419
442
|
if (!old.has(pdf)) { const document = new PdfDocument(pdf); old.set(pdf, { document, pages: new AnnotationPages(document) }); }
|
|
420
443
|
const match = placeAnnotation(note, old.get(pdf)!.pages, target);
|
|
@@ -435,6 +458,125 @@ export class Manuscripts extends EventEmitter {
|
|
|
435
458
|
} finally { current.close(); for (const entry of old.values()) entry.document.close(); }
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
// Every problem of the last finished build, placed on its PDF when there is
|
|
462
|
+
// one. Placing runs SyncTeX per problem, once per build.
|
|
463
|
+
async problems(projectId: string): Promise<ManuscriptProblems> {
|
|
464
|
+
const build = this.builds.get(projectId);
|
|
465
|
+
if (!build?.problems) return { status: build?.status || 'building', problems: [], log: Boolean(build?.log) };
|
|
466
|
+
const { problems, problemsKey: key, pdf } = build;
|
|
467
|
+
if (!key || key !== build.key || !pdf) return { status: build.status, problems: sortProblems(problems.map(({ source: _source, occurrence: _occurrence, ...problem }) => problem)), log: Boolean(build.log) };
|
|
468
|
+
let placed = this.located.get(problems);
|
|
469
|
+
if (!placed) {
|
|
470
|
+
placed = (async () => {
|
|
471
|
+
const { PdfDocument } = await engine();
|
|
472
|
+
const document = new PdfDocument(pdf), pages = new Map<number, ReturnType<typeof document.page>>();
|
|
473
|
+
try {
|
|
474
|
+
return sortProblems(await locateProblems(problems, pdf, page => {
|
|
475
|
+
if (!Number.isInteger(page) || page < 0 || page >= document.length) return null;
|
|
476
|
+
if (!pages.has(page)) pages.set(page, document.page(page));
|
|
477
|
+
return pages.get(page)!;
|
|
478
|
+
}));
|
|
479
|
+
} finally { for (const page of pages.values()) page.close(); document.close(); }
|
|
480
|
+
})();
|
|
481
|
+
placed.catch(() => this.located.delete(problems));
|
|
482
|
+
this.located.set(problems, placed);
|
|
483
|
+
}
|
|
484
|
+
return { status: build.status, key, problems: await placed, log: Boolean(build.log) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
log(projectId: string) {
|
|
488
|
+
const build = this.builds.get(projectId);
|
|
489
|
+
if (!build?.log || !fs.existsSync(build.log)) throw Object.assign(new Error('There is no compilation log yet.'), { statusCode: 404 });
|
|
490
|
+
return scrubLog(fs.readFileSync(build.log, 'utf8'), build.logFolder);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Compile the task's frozen source tree, then retain its PDF outside the build cache.
|
|
494
|
+
async captureReview(root: string, worktree: string, taskId: string): Promise<ReviewSnapshot> {
|
|
495
|
+
const { Builder, Repo, loadProject } = await engine();
|
|
496
|
+
const tree = await workingTree(worktree);
|
|
497
|
+
const result = await new Builder(loadProject(root), new Repo(root)).build(tree, ['full']);
|
|
498
|
+
const pdf = await this.preserveReviewPdf(root, taskId, result.pdf);
|
|
499
|
+
const key = createHash('sha256').update(fs.readFileSync(pdf)).digest('hex').slice(0, 16);
|
|
500
|
+
return { ...await this.snapshotPdf(pdf, key), pdf };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async reviewPdfPath(root: string, taskId: string) {
|
|
504
|
+
const { loadProject } = await engine();
|
|
505
|
+
return path.join(loadProject(root).state, 'ai-reviews', createHash('sha256').update(taskId).digest('hex'), 'review.pdf');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async savedReviewPdf(root: string, taskId: string): Promise<string | null> {
|
|
509
|
+
const pdf = await this.reviewPdfPath(root, taskId);
|
|
510
|
+
return fs.existsSync(pdf) ? pdf : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async preserveReviewPdf(root: string, taskId: string, source: string) {
|
|
514
|
+
const pdf = await this.reviewPdfPath(root, taskId), folder = path.dirname(pdf);
|
|
515
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
516
|
+
// A retry always uses the original task copy, even after its build cache is pruned.
|
|
517
|
+
if (!fs.existsSync(pdf)) { const temporary = path.join(folder, 'review.tmp'); fs.copyFileSync(source, temporary); fs.renameSync(temporary, pdf); }
|
|
518
|
+
return pdf;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async cachedPdf(root: string, key: string): Promise<string | null> {
|
|
522
|
+
const lookup = `${root}:${key}`;
|
|
523
|
+
if (this.oldPdfs.has(lookup)) return this.oldPdfs.get(lookup)!;
|
|
524
|
+
const { loadProject } = await engine();
|
|
525
|
+
const cache = path.join(loadProject(root).state, 'cache/builds');
|
|
526
|
+
let found: string | null = null;
|
|
527
|
+
if (fs.existsSync(cache)) for (const entry of fs.readdirSync(cache, { withFileTypes: true })) {
|
|
528
|
+
if (!entry.isDirectory() || entry.name.endsWith('.lock')) continue;
|
|
529
|
+
try {
|
|
530
|
+
const folder = path.join(cache, entry.name), saved = JSON.parse(fs.readFileSync(path.join(folder, 'result.json'), 'utf8'));
|
|
531
|
+
if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) continue;
|
|
532
|
+
if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === key && fs.existsSync(saved.pdf)) { found = saved.pdf; break; }
|
|
533
|
+
} catch { /* Incomplete builds have no usable PDF. */ }
|
|
534
|
+
}
|
|
535
|
+
this.oldPdfs.set(lookup, found); return found;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async placeReviewNote(projectId: string, root: string, note: Annotation, pdf: string): Promise<Annotation> {
|
|
539
|
+
const state = await this.state(projectId, root);
|
|
540
|
+
if (!state.build.current || !state.build.key) throw new Error('Wait for the current manuscript PDF to finish compiling, then copy the suggestion again.');
|
|
541
|
+
const key = state.build.key, { PdfDocument } = await engine();
|
|
542
|
+
const original = new PdfDocument(pdf), current = new PdfDocument(this.pdf(projectId, key));
|
|
543
|
+
try {
|
|
544
|
+
const match = placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
|
|
545
|
+
if (!match) throw new Error('This passage could not be located reliably in the current manuscript. The suggestion remains in the review task.');
|
|
546
|
+
const { source } = await this.locate(projectId, key, match.page, match.rect, match.rects, note.quote);
|
|
547
|
+
const latest = await this.state(projectId, root);
|
|
548
|
+
if (!latest.build.current || latest.build.key !== key) throw new Error('The manuscript changed while copying this suggestion. Try again.');
|
|
549
|
+
return { ...note, ...match, key, source };
|
|
550
|
+
} finally { original.close(); current.close(); }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async reviewSnapshot(projectId: string, key: string): Promise<ReviewSnapshot> {
|
|
554
|
+
return this.snapshotPdf(this.pdf(projectId, key), key);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async snapshotPdf(pdf: string, key: string): Promise<ReviewSnapshot> {
|
|
558
|
+
const { PdfDocument } = await engine();
|
|
559
|
+
const document = new PdfDocument(pdf), lines: ReviewSnapshot['lines'] = [];
|
|
560
|
+
let length = 0;
|
|
561
|
+
try {
|
|
562
|
+
for (let index = 0; index < document.length; index++) {
|
|
563
|
+
const page = document.page(index), [left, top, right, bottom] = page.bounds;
|
|
564
|
+
for (const [number, line] of page.lines.entries()) {
|
|
565
|
+
const text = line.chars.map((char: { text: string }) => char.text).join('').trim();
|
|
566
|
+
if (!text) continue;
|
|
567
|
+
length += text.length;
|
|
568
|
+
if (length > 180000) throw new Error('This PDF is too long for one AI review (180,000 characters maximum).');
|
|
569
|
+
const [x0, y0, x1, y1] = line.rect;
|
|
570
|
+
const rect = [(x0 - left) / (right - left), (y0 - top) / (bottom - top), (x1 - left) / (right - left), (y1 - top) / (bottom - top)].map(value => Math.max(0, Math.min(1, value))) as Rect;
|
|
571
|
+
if (rect[2] <= rect[0] || rect[3] <= rect[1]) continue;
|
|
572
|
+
lines.push({ id: `p${index + 1}-l${number + 1}`, page: index, text, rect });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!lines.length) throw new Error('This PDF has no selectable text to review.');
|
|
576
|
+
return { key, lines };
|
|
577
|
+
} finally { document.close(); }
|
|
578
|
+
}
|
|
579
|
+
|
|
438
580
|
pdf(projectId: string, key: string) {
|
|
439
581
|
const build = this.builds.get(projectId);
|
|
440
582
|
if (!build?.pdf || build.key !== key) throw Object.assign(new Error('This manuscript preview is out of date. Refresh it and try again.'), { statusCode: 404 });
|
package/src/server/paths.ts
CHANGED
|
@@ -12,4 +12,4 @@ export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
|
|
|
12
12
|
// port its proxy (such as Tailscale Funnel) forwards to.
|
|
13
13
|
export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
|
|
14
14
|
export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
|
|
15
|
-
export const CODEX = process.env.PAPILOT_CODEX_BIN ||
|
|
15
|
+
export const CODEX = process.env.PAPILOT_CODEX_BIN || [path.join(ROOT, 'node_modules/.bin/codex'), path.join(DATA, 'agents/node_modules/.bin/codex')].find(file => fs.existsSync(file)) || 'codex';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { DATA, ENGINE } from './paths.ts';
|
|
5
|
+
import type { BibliographyReport, ReferenceCheck } from '../shared.ts';
|
|
6
|
+
|
|
7
|
+
// The paper's bibliography as it is on disk: every entry, where it is cited,
|
|
8
|
+
// what is wrong with it without looking anything up, and, when asked, what
|
|
9
|
+
// Crossref, DBLP and arXiv know of it. The review engine does the reading
|
|
10
|
+
// and the checking; this is where Papilot asks it for a whole paper.
|
|
11
|
+
|
|
12
|
+
type Input = { id: string; type: string; fields: Record<string, string> };
|
|
13
|
+
interface Checker { lookup(inputs: Input[], start?: boolean, retry?: boolean): { checks: Record<string, ReferenceCheck>; pending: number } }
|
|
14
|
+
interface Confirmations { of(inputs: Input[]): Record<string, number>; set(input: Input, on: boolean): void }
|
|
15
|
+
interface Engine {
|
|
16
|
+
bibliographyReport(doc: { read(file: string): string | null; main: string; outline: unknown[] }): Omit<BibliographyReport, 'checks' | 'pending' | 'confirmed'>;
|
|
17
|
+
buildOutline(read: (file: string) => string | null, main: string): { nodes: unknown[] };
|
|
18
|
+
loadProject(root: string): { main: string };
|
|
19
|
+
checkInputs(references: unknown): Input[];
|
|
20
|
+
entryInputs(entries: unknown[]): Input[];
|
|
21
|
+
ReferenceChecker: new (file: string) => Checker;
|
|
22
|
+
Confirmations: new (file: string) => Confirmations;
|
|
23
|
+
Repo: new (root: string) => { gitDir(): string };
|
|
24
|
+
}
|
|
25
|
+
let loading: Promise<Engine> | undefined;
|
|
26
|
+
export function engine(): Promise<Engine> {
|
|
27
|
+
const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
|
|
28
|
+
loading ??= Promise.all(['src/core/bib.ts', 'src/core/outline.ts', 'src/core/config.ts', 'src/core/verify.ts', 'src/core/git.ts'].map(load))
|
|
29
|
+
.then(modules => Object.assign({}, ...modules) as Engine);
|
|
30
|
+
return loading;
|
|
31
|
+
}
|
|
32
|
+
// One checker for Papilot's process, so that the panel and the review of
|
|
33
|
+
// uncommitted changes share their results and their file.
|
|
34
|
+
let shared: Checker | undefined;
|
|
35
|
+
export async function checker(file = path.join(DATA, 'references.json')) {
|
|
36
|
+
const { ReferenceChecker } = await engine();
|
|
37
|
+
return shared ??= new ReferenceChecker(file);
|
|
38
|
+
}
|
|
39
|
+
// Confirmations live beside the review engine's state in the repository, so
|
|
40
|
+
// a task's review, the uncommitted changes and this panel agree.
|
|
41
|
+
export async function confirmations(root: string) {
|
|
42
|
+
const { Confirmations, Repo } = await engine();
|
|
43
|
+
return new Confirmations(path.join(new Repo(root).gitDir(), 'latex-review', 'references-confirmed.json'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A file of the paper, read only from inside its folder.
|
|
47
|
+
function reader(root: string) {
|
|
48
|
+
const base = fs.realpathSync(root);
|
|
49
|
+
return (file: string) => {
|
|
50
|
+
const target = path.resolve(base, file);
|
|
51
|
+
if (target !== base && !target.startsWith(base + path.sep)) return null;
|
|
52
|
+
try { return fs.statSync(target).isFile() ? fs.readFileSync(target, 'utf8') : null; } catch { return null; }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function report(root: string) {
|
|
56
|
+
const mods = await engine(), read = reader(root), { main } = mods.loadProject(root);
|
|
57
|
+
const result = mods.bibliographyReport({ read, main, outline: mods.buildOutline(read, main).nodes });
|
|
58
|
+
return { result, inputs: mods.entryInputs(result.entries) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function bibliography(root: string, check: boolean, retry: boolean): Promise<BibliographyReport> {
|
|
62
|
+
const { result, inputs } = await report(root);
|
|
63
|
+
return { ...result, ...(await checker()).lookup(inputs, check, retry), confirmed: (await confirmations(root)).of(inputs) };
|
|
64
|
+
}
|
|
65
|
+
export async function confirmEntry(root: string, id: string, on: boolean) {
|
|
66
|
+
const { inputs } = await report(root), input = inputs.find(item => item.id === id);
|
|
67
|
+
if (!input) throw Object.assign(new Error('This entry is no longer in the bibliography. Refresh the panel.'), { statusCode: 404 });
|
|
68
|
+
(await confirmations(root)).set(input, on);
|
|
69
|
+
return bibliography(root, false, false);
|
|
70
|
+
}
|
package/src/server/review.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { ENGINE } from './paths.ts';
|
|
4
4
|
import type { Task } from '../shared.ts';
|
|
5
5
|
|
|
6
|
-
export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown): Promise<T> }
|
|
6
|
+
export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown, timeout?: number): Promise<T> }
|
|
7
7
|
let modulePromise: Promise<{ connect: (root: string) => Promise<ReviewClient> }> | null = null;
|
|
8
8
|
export function reviewClient(root: string): Promise<ReviewClient> {
|
|
9
9
|
modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
|
|
@@ -41,8 +41,8 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
|
|
|
41
41
|
|
|
42
42
|
export function allowedReviewRoute(route: string, method: string): boolean {
|
|
43
43
|
if (route.includes('..') || route.includes('\\')) return false;
|
|
44
|
-
if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d
|
|
45
|
-
return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note)$/.test(route);
|
|
44
|
+
if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d+|\/references)?))?)?$/.test(route);
|
|
45
|
+
return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note|rounds\/\d+\/references\/confirm)$/.test(route);
|
|
46
46
|
}
|
|
47
47
|
export function rewriteReviewJson(value: any, prefix: string): any {
|
|
48
48
|
if (Array.isArray(value)) return value.map(v => rewriteReviewJson(v, prefix));
|
package/src/server/skills.ts
CHANGED
|
@@ -3,6 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { ROOT } from './paths.ts';
|
|
5
5
|
import type { Skill } from '../shared.ts';
|
|
6
|
+
import { isSkillIcon } from '../skill-icons.ts';
|
|
7
|
+
import { skillMetadata, skillInstructions } from '../skill-metadata.ts';
|
|
6
8
|
|
|
7
9
|
// Skills are Markdown instructions an author runs with "/" in the composer.
|
|
8
10
|
// Papilot ships some in skills/; ~/.papilot/skills/ adds the author's
|
|
@@ -33,14 +35,9 @@ export function skillFiles(dir: string): { id: string; file: string; folder?: st
|
|
|
33
35
|
|
|
34
36
|
export function parseSkill(text: string, id: string, source: Skill['source'], folder?: string): SkillFile | null {
|
|
35
37
|
text = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
36
|
-
const
|
|
37
|
-
for (const line of header?.[1].split('\n') || []) {
|
|
38
|
-
const field = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
|
|
39
|
-
if (field) fields[field[1].toLowerCase()] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
40
|
-
}
|
|
41
|
-
const body = text.slice(header ? header[0].length : 0).trim().slice(0, 20000);
|
|
38
|
+
const fields = skillMetadata(text), body = skillInstructions(text).trim().slice(0, 20000);
|
|
42
39
|
if (!body) return null;
|
|
43
|
-
return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), source, body, ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
|
|
40
|
+
return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), ...(isSkillIcon(fields.icon) ? { icon: fields.icon } : {}), source, body, ...(fields.output === 'annotations' ? { output: 'annotations' as const } : {}), ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
function read(dir: string, source: Skill['source']): SkillFile[] {
|
|
@@ -67,4 +64,4 @@ export function findSkill(paper: string, id: string) {
|
|
|
67
64
|
return skill;
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
export const listedSkill = ({ id, name, description, hint, source }: SkillFile): Skill => ({ id, name, description, hint, source });
|
|
67
|
+
export const listedSkill = ({ id, name, description, hint, source, icon, output }: SkillFile): Skill => ({ id, name, description, hint, source, ...(icon ? { icon } : {}), ...(output ? { output } : {}) });
|