papilot 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +16 -4
  2. package/README.zh-CN.md +15 -3
  3. package/bin/doctor.mjs +239 -0
  4. package/bin/papilot.mjs +95 -86
  5. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/assets/index-BkowXmKr.js +206 -0
  65. package/dist/assets/index-Dq3s-vhH.css +1 -0
  66. package/dist/assets/math-BTm2hvOz.js +263 -0
  67. package/dist/assets/math-DEcVZfaU.css +1 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BEn1EztO.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/package.json +13 -2
  72. package/scripts/service.ts +8 -2
  73. package/skills/check-math.md +12 -0
  74. package/skills/consistency.md +11 -0
  75. package/skills/latex-warnings.md +12 -0
  76. package/skills/polish.md +13 -0
  77. package/skills/proofread.md +12 -0
  78. package/skills/reviewer-response.md +11 -0
  79. package/skills/shorten.md +12 -0
  80. package/src/commit.ts +23 -0
  81. package/src/conversation.ts +48 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/main.ts +51 -8
  85. package/src/server/manuscript.ts +91 -10
  86. package/src/server/review.ts +2 -1
  87. package/src/server/service-environment.ts +22 -0
  88. package/src/server/skills.ts +57 -0
  89. package/src/server/workspace.ts +186 -26
  90. package/src/shared.ts +17 -3
  91. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
  92. package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +19 -0
  93. package/vendor/latex-review/dist/web/index.html +2 -2
  94. package/vendor/latex-review/src/client.ts +10 -3
  95. package/vendor/latex-review/src/core/build.ts +53 -7
  96. package/vendor/latex-review/src/core/outline.ts +6 -3
  97. package/vendor/latex-review/src/core/publish.ts +8 -6
  98. package/vendor/latex-review/src/runtime.ts +14 -0
  99. package/vendor/latex-review/src/server.ts +2 -1
  100. package/vendor/latex-review/src/types.ts +16 -1
  101. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  102. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  103. package/vendor/latex-review/src/web/i18n.ts +48 -0
  104. package/vendor/latex-review/src/web/model.ts +23 -4
  105. package/vendor/latex-review/src/web/overview.tsx +25 -20
  106. package/vendor/latex-review/src/web/review.tsx +399 -145
  107. package/vendor/latex-review/src/web/style.css +130 -0
  108. package/vendor/latex-review/src/web/zh-CN.ts +188 -0
  109. package/dist/assets/index-BhQFCMTx.js +0 -131
  110. package/dist/assets/index-CAV3-87U.css +0 -1
  111. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  112. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +0 -19
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DATA, ROOT } from './paths.ts';
4
+ import type { Skill } from '../shared.ts';
5
+
6
+ // Skills are Markdown instructions an author runs with "/" in the composer.
7
+ // Papilot ships some in skills/; the data folder's skills/ adds the author's
8
+ // own for every paper, and a paper's .papilot/skills/ its own. A later source
9
+ // replaces an earlier skill with the same name. Each skill is <name>.md, or a
10
+ // folder with a SKILL.md (the Agent Skills layout) whose other files the
11
+ // agent may read. Front matter gives its name, description, composer hint
12
+ // and order.
13
+ export interface SkillFile extends Skill { body: string; folder?: string; order: number }
14
+ const NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
15
+
16
+ export function parseSkill(text: string, id: string, source: Skill['source'], folder?: string): SkillFile | null {
17
+ text = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
18
+ const header = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text), fields: Record<string, string> = {};
19
+ for (const line of header?.[1].split('\n') || []) {
20
+ const field = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
21
+ if (field) fields[field[1].toLowerCase()] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2');
22
+ }
23
+ const body = text.slice(header ? header[0].length : 0).trim().slice(0, 20000);
24
+ if (!body) return null;
25
+ return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), source, body, ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
26
+ }
27
+
28
+ function read(dir: string, source: Skill['source']): SkillFile[] {
29
+ let entries: fs.Dirent[];
30
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
31
+ const found: SkillFile[] = [];
32
+ for (const entry of entries) {
33
+ const id = entry.name.replace(/\.md$/i, '').toLowerCase();
34
+ if (!NAME.test(id)) continue;
35
+ try {
36
+ const skill = entry.isDirectory() ? parseSkill(fs.readFileSync(path.join(dir, entry.name, 'SKILL.md'), 'utf8'), id, source, path.join(dir, entry.name))
37
+ : entry.isFile() && /\.md$/i.test(entry.name) ? parseSkill(fs.readFileSync(path.join(dir, entry.name), 'utf8'), id, source) : null;
38
+ if (skill) found.push(skill);
39
+ } catch { /* A folder without a readable SKILL.md is not a skill. */ }
40
+ }
41
+ return found;
42
+ }
43
+
44
+ export function loadSkills(paper?: string, roots = { builtin: path.join(ROOT, 'skills'), user: path.join(DATA, 'skills') }): SkillFile[] {
45
+ const skills = new Map<string, SkillFile>();
46
+ const sources: [string, Skill['source']][] = [[roots.builtin, 'builtin'], [roots.user, 'user'], ...(paper ? [[path.join(paper, '.papilot', 'skills'), 'paper'] as [string, Skill['source']]] : [])];
47
+ for (const [dir, source] of sources) for (const skill of read(dir, source)) skills.set(skill.id, skill);
48
+ return [...skills.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
49
+ }
50
+
51
+ export function findSkill(paper: string, id: string) {
52
+ const skill = loadSkills(paper).find(item => item.id === id);
53
+ if (!skill) throw new Error(`The skill "${id}" is not available for this paper.`);
54
+ return skill;
55
+ }
56
+
57
+ export const listedSkill = ({ id, name, description, hint, source }: SkillFile): Skill => ({ id, name, description, hint, source });
@@ -9,7 +9,11 @@ import { Codex, RpcResponseError } from './codex.ts';
9
9
  import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from './agents.ts';
10
10
  import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
11
11
  import { VERSION } from './paths.ts';
12
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId } from '../shared.ts';
12
+ import { findSkill, type SkillFile } from './skills.ts';
13
+ import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
+ import { commitMessageFrom } from '../commit.ts';
15
+ import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
16
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure } from '../shared.ts';
13
17
 
14
18
  const execute = promisify(execFile);
15
19
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -33,8 +37,16 @@ export function accountUsage(response: any): Usage | null {
33
37
  return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, credits, reached: Boolean(limits.rateLimitReachedType) || response.ordinaryUsageAllowed === false };
34
38
  }
35
39
  const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'deleting']);
40
+ // Review builds that fail on the agent's own edits (see the review engine's
41
+ // publish.ts), and the request keys of the turns that send them back to it.
42
+ const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
43
+ const BUILD_FIX = 'build-fix-';
36
44
 
37
45
  const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
46
+ // A commit message's agent sees the changes in its prompt and needs no tools.
47
+ const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
48
+ // A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
49
+ const mergeInstructions = 'You resolve Git merge conflicts in the repository of a LaTeX paper. Edit only the files listed as conflicted, and in each one remove every conflict marker (<<<<<<<, ||||||| , =======, >>>>>>>) so that the text reads as one coherent version. Keep the changes from both sides wherever they are compatible; where both sides rewrote the same passage, combine their intent, and where they truly contradict each other, prefer the version that is more complete and correct and say so. Keep the LaTeX valid: balanced environments and braces, labels, references and citations. You may read any file to understand the context. Do not edit other files, and do not run Git commands that change the repository, such as add, commit, merge, checkout, reset, restore or stash: the author reviews the result and commits the merge.';
38
50
  const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
39
51
 
40
52
  export class Workspace extends EventEmitter {
@@ -47,6 +59,8 @@ export class Workspace extends EventEmitter {
47
59
  private loaded = new Set<string>();
48
60
  private preparing = new Set<string>();
49
61
  private reviewWrites = new Set<string>();
62
+ // Papers with a commit in progress: no review write may change their files meanwhile.
63
+ private committing = new Set<string>();
50
64
  private commentWrites = new Map<string, number>();
51
65
  private deletions = new Set<string>();
52
66
  private publishing = new Set<string>();
@@ -122,25 +136,31 @@ export class Workspace extends EventEmitter {
122
136
  const project = { ...this.store.project(projectId), name };
123
137
  this.store.saveProject(project); return project;
124
138
  }
125
- async createTask(input: { projectId: string; title: string; instructions: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
139
+ // A task can start from a skill; its instructions are then the author's own words, possibly none.
140
+ async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
126
141
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
127
142
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
128
143
  const existing = this.store.tasks().find(t => t.id === input.id); if (existing) return existing;
129
144
  if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
130
145
  this.preparing.add(input.id);
131
146
  try {
132
- const project = this.store.project(input.projectId), client = await this.connectReview(project.path);
147
+ const project = this.store.project(input.projectId), skill = input.skill ? findSkill(project.path, input.skill) : undefined;
148
+ if (!skill && !input.instructions.trim()) throw new Error('Describe the revision, or choose a skill.');
149
+ const client = await this.connectReview(project.path);
133
150
  const name = 'p-' + input.id.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48);
134
151
  const info = await client.post('/api/tasks', { name, title: input.title });
135
- const task: Task = { id: input.id, projectId: project.id, name, title: input.title, instructions: input.instructions,
152
+ const task: Task = { id: input.id, projectId: project.id, name, title: input.title, instructions: skill ? skillPrompt(skill, input.instructions) : input.instructions,
136
153
  worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
137
154
  createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
138
155
  this.store.saveTask(task);
139
- await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false);
156
+ await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
140
157
  return this.store.task(task.id);
141
158
  } finally { this.preparing.delete(input.id); }
142
159
  }
143
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true) {
160
+ // The author's message, a skill run with or without one, the review
161
+ // feedback alone, or a build the agent broke. The chat keeps what happened;
162
+ // the agent gets the prompt.
163
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
144
164
  const task = this.store.task(taskId);
145
165
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
146
166
  const existing = this.store.runByKey(requestKey);
@@ -148,15 +168,18 @@ export class Workspace extends EventEmitter {
148
168
  if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
149
169
  const pauseVersion = task.pauseVersion || 0;
150
170
  if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
151
- let prompt = text.trim(), cursor = task.feedbackCursor;
171
+ let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
172
+ let summary: FeedbackSummary | undefined;
152
173
  if (includeFeedback) {
153
174
  const client = await this.connectReview(this.store.project(task.projectId).path);
154
175
  const feedback = await client.get(`${taskRoute(task.name)}/feedback?after=${task.feedbackCursor}`);
155
- cursor = feedback.cursor;
176
+ cursor = feedback.cursor; summary = summarizeFeedback(feedback);
177
+ if (options.feedback && !summary) throw new Error('There is no new review feedback to send.');
156
178
  if (feedback.feedback?.length || feedback.build_error) prompt += '\n\nPapilot review feedback (bound to the specified review round and source changes):\n' + JSON.stringify(feedback, null, 2);
157
179
  if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
158
180
  }
159
181
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
182
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
160
183
  const saved = this.store.transaction(() => {
161
184
  // Feedback loading yields to other requests. Recheck identity, queue, and
162
185
  // task status together before committing; none of the earlier reads own a lock.
@@ -172,7 +195,7 @@ export class Workspace extends EventEmitter {
172
195
  if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
173
196
  this.store.saveRun(run);
174
197
  if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
175
- this.store.saveItem({ id: run.id, taskId, type: 'user', text: text.trim(), createdAt: run.createdAt });
198
+ this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
176
199
  return run;
177
200
  });
178
201
  this.schedule();
@@ -195,12 +218,20 @@ export class Workspace extends EventEmitter {
195
218
  void this.startRun(run).catch(e => this.failRun(run, e));
196
219
  }
197
220
  }
198
- private instructions(task: Task) {
221
+ // The paths a task's review covers, or none when it covers the whole worktree.
222
+ private async reviewScope(task: Task): Promise<string[]> {
223
+ try {
224
+ const client = await this.connectReview(this.store.project(task.projectId).path);
225
+ const files: string[] = (await client.get(taskRoute(task.name))).scope?.files || [];
226
+ return files.some(file => file === '' || file === '.') ? [] : files;
227
+ } catch { return []; }
228
+ }
229
+ private instructions(task: Task, scope: string[] = []) {
199
230
  return `You are ${this.agentOf(task) === 'codex' ? 'the official Codex agent' : AGENT_NAMES[this.agentOf(task)]} running inside Papilot, a paper editing and review workspace.
200
231
  Your task is already created. Your editable working directory is ${task.worktree}.
201
232
  Original task: ${task.instructions}
202
233
  Only edit this isolated task worktree. Never edit the manuscript checkout, other task worktrees, or Papilot's own source/state. Never commit, push, stash, or accept/reject/undo review decisions.
203
- Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary.
234
+ ${scope.length ? `The review covers only ${scope.join(', ')} in this worktree, and an edit to any other file stops the PDF build. Leave other files unchanged even when project instructions ask for a matching update; mention that update in your summary instead.\n` : ''}Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow.
204
235
  Preserve meaning and mathematical correctness. Read relevant project instructions. Treat reviewer comments as instructions tied to their round, file and changes. Rejected edits must not be reintroduced unless explicitly requested. If there is a conflict with accepted text, explain it and propose a revision for a new review round; never overwrite the accepted manuscript.
205
236
  ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`;
206
237
  }
@@ -211,7 +242,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
211
242
  this.store.saveRun({ ...run, status: 'running' });
212
243
  this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
213
244
  if (!task.threadId || !this.loaded.has(task.threadId)) {
214
- const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task), ...(task.model ? { model: task.model } : {}) };
245
+ const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
215
246
  const result = task.threadId
216
247
  ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
217
248
  : await agent.request('thread/start', options);
@@ -317,7 +348,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
317
348
  this.store.saveRun({ ...run, status, finishedAt: Date.now() });
318
349
  this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
319
350
  this.schedule();
320
- if (status === 'completed') await this.publish(task.id, true);
351
+ if (status === 'completed') await this.publish(task.id, run);
321
352
  } else if (method === 'error' && !p.willRetry) {
322
353
  if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
323
354
  this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
@@ -428,6 +459,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
428
459
  }
429
460
  }
430
461
  if (this.active.has(taskId) || this.publishing.has(taskId) || BUSY.has(task.status) || task.status === 'queued') throw new Error('Pause the task before accepting, rejecting, or undoing changes. Wait for any PDF build to finish.');
462
+ if (this.committing.has(task.projectId)) throw new Error('A commit is in progress. Try again when it finishes.');
463
+ if (mergeInProgress(this.store.project(task.projectId).path)) throw new Error('Finish or abort the merge in the Git panel before accepting, rejecting, or undoing changes.');
431
464
  if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
432
465
  this.reviewWrites.add(taskId);
433
466
  if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
@@ -441,17 +474,19 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
441
474
  }
442
475
  finally { this.reviewWrites.delete(taskId); this.schedule(); }
443
476
  }
444
- publish(taskId: string, automatic = false): Promise<void> {
445
- const task = this.store.task(taskId);
477
+ // A build is automatic after the agent's run `after` completes. When its
478
+ // edits break the build, the agent gets the error in a new turn, once.
479
+ publish(taskId: string, after?: Run): Promise<void> {
480
+ const task = this.store.task(taskId), automatic = Boolean(after);
446
481
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
447
482
  if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
448
483
  if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
449
484
  if (this.publishing.has(taskId) || (!automatic && (BUSY.has(task.status) || task.status === 'queued'))) throw new Error('This task is busy. Wait for it to finish or pause it first.');
450
485
  this.publishing.add(taskId);
451
486
  this.store.updateTask(taskId, { status: 'building', error: null });
452
- return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
487
+ return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX))).finally(() => { this.publishing.delete(taskId); this.schedule(); });
453
488
  }
454
- private async buildReview(task: Task) {
489
+ private async buildReview(task: Task, sendBack: boolean) {
455
490
  const taskId = task.id;
456
491
  try {
457
492
  const client = await this.connectReview(this.store.project(task.projectId).path);
@@ -460,9 +495,17 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
460
495
  if (this.closing) return;
461
496
  const current = await client.get(`/api/jobs/${job.job}`);
462
497
  const summary = await client.get(taskRoute(task.name));
463
- this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current, unsent: this.store.task(taskId).review?.unsent } });
498
+ this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
464
499
  if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
465
- if (current.status === 'failed') throw new Error([current.error, ...(current.details || [])].filter(Boolean).join('\n') || 'PDF build failed.');
500
+ if (current.status === 'failed') {
501
+ const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
502
+ // Instructions the author queued meanwhile go first; the build after them sends any error that remains.
503
+ if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + job.job, false, { build: failure }).then(() => true, () => false)) {
504
+ this.store.updateTask(taskId, { status: 'queued', error: null });
505
+ return;
506
+ }
507
+ throw new Error([failure.error, ...failure.details].join('\n'));
508
+ }
466
509
  await delay(1500);
467
510
  }
468
511
  this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
@@ -473,19 +516,121 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
473
516
  try {
474
517
  const task = this.store.task(taskId);
475
518
  if (task.status === 'deleting') return;
476
- const client = await this.connectReview(this.store.project(task.projectId).path);
519
+ const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
477
520
  const summary = await client.get(taskRoute(task.name));
478
- const unsent = await this.unsentComments(client, this.store.task(taskId));
521
+ const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
522
+ const files = await acceptedFiles(project.path, task.name, task.committed?.accepted);
479
523
  const current = this.store.task(taskId);
480
- if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent } });
524
+ if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
481
525
  } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
482
526
  }
483
- // Review comments Codex has not received yet. A queued or in-flight message
484
- // already carries the feedback up to its own cursor.
485
- private async unsentComments(client: ReviewClient, task: Task) {
527
+ // Review decisions, comments and undo updates the agent has not received yet.
528
+ // The engine excludes decisions undone before delivery. A queued or in-flight message
529
+ // already carries the feedback up to its own cursor. Comments and notes ask for a revision.
530
+ private async unsentReviewFeedback(client: ReviewClient, task: Task) {
486
531
  const after = Math.max(task.feedbackCursor, ...this.store.runs().filter(r => r.taskId === task.id && ['queued', 'running', 'uncertain'].includes(r.status)).map(r => r.feedbackCursor || 0));
487
532
  const feedback = await client.get<{ feedback?: { kind?: string }[] }>(`${taskRoute(task.name)}/feedback?after=${after}`);
488
- return (feedback.feedback || []).filter(item => item.kind === 'comment').length;
533
+ const items = feedback.feedback || [];
534
+ return { unsent: items.length, comments: items.filter(item => item.kind === 'comment' || item.kind === 'note').length };
535
+ }
536
+ // The agent that writes a commit message or resolves a merge: the one the
537
+ // author chose, or else the first that is signed in, with the chosen model
538
+ // or the default one.
539
+ private helper(choice: { agent?: AgentId; model?: string }, task: string) {
540
+ const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
541
+ const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
542
+ if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
543
+ const models = this.runtimes[id].models;
544
+ return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
545
+ }
546
+ // One turn on a thread the agent does not keep, outside any task: its
547
+ // reply is returned and no chat shows it.
548
+ private async oneOff(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
549
+ const agent = this.agents[id], name = AGENT_NAMES[id];
550
+ const thread = await agent.request('thread/start', { cwd: options.cwd, approvalPolicy: 'never', sandbox: options.sandbox, ephemeral: true, developerInstructions: options.instructions, ...(options.model ? { model: options.model.id } : {}) });
551
+ const threadId = thread.thread.id;
552
+ return new Promise<string>((resolve, reject) => {
553
+ let text = '', turnId = '';
554
+ const listen = (message: any) => {
555
+ const p = message.params || {};
556
+ if (p.threadId !== threadId) return;
557
+ if (message.method === 'item/completed' && p.item?.type === 'agentMessage') text = p.item.text || text;
558
+ else if (message.method === 'turn/completed') finish(p.turn?.status === 'completed' ? null : new Error(p.turn?.error?.message || `${name} stopped before it could ${options.what}.`));
559
+ };
560
+ const timer = setTimeout(() => {
561
+ if (turnId) agent.request('turn/interrupt', { threadId, turnId }).catch(() => undefined);
562
+ finish(new Error(`${name} did not ${options.what} in time. Try again.`));
563
+ }, options.timeout);
564
+ const finish = (error: Error | null) => { clearTimeout(timer); agent.off('notification', listen); if (error) reject(error); else resolve(text); };
565
+ agent.on('notification', listen);
566
+ agent.request('turn/start', { threadId, input: [{ type: 'text', text: options.prompt }], cwd: options.cwd, ...(options.effort ? { effort: options.effort } : {}) })
567
+ .then(result => { turnId = result?.turn?.id || ''; }, error => finish(error instanceof Error ? error : new Error(String(error))));
568
+ });
569
+ }
570
+ // Writes a commit message for the chosen manuscript files, like Cursor's
571
+ // generate button: one read-only turn with the lightest reasoning the model offers.
572
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; model?: string } = {}) {
573
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message');
574
+ const { files, patch, recent } = await commitChanges(project.path, paths);
575
+ const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
576
+ const prompt = [
577
+ recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
578
+ tasks.length ? `Papilot tasks whose accepted changes are in these files:\n${tasks.map(title => `- ${title}`).join('\n')}` : '',
579
+ `Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
580
+ `Diff:\n${patch}`,
581
+ ].filter(Boolean).join('\n\n');
582
+ const reply = await this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' });
583
+ const message = commitMessageFrom(reply);
584
+ if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
585
+ return { message, agent: id };
586
+ }
587
+ // Resolves a merge's conflicts in the manuscript checkout itself: the agent
588
+ // edits only the conflicted files, at the model's default reasoning, and
589
+ // leaves committing the merge to the author once they have read the diffs.
590
+ async resolveConflicts(projectId: string, choice: { agent?: AgentId; model?: string } = {}) {
591
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'resolve conflicts');
592
+ const { files, branch, upstream, ours, theirs } = await mergeConflicts(project.path);
593
+ if (!files.length) throw new Error('No file has conflict markers left. Commit the merge.');
594
+ const list = (items: string[]) => items.length ? items.map(item => `- ${item}`).join('\n') : '- (none listed)';
595
+ const prompt = [
596
+ `${branch} is being merged with ${upstream} in ${project.path}, and these files have conflicts:\n${list(files)}`,
597
+ `Commits on ${branch} since the two diverged:\n${list(ours)}`,
598
+ `Commits from ${upstream} being merged in:\n${list(theirs)}`,
599
+ 'Resolve every conflict in those files, then reply with a short summary for the author: how you resolved each conflict, and any place where you had to choose one side.',
600
+ ].join('\n\n');
601
+ const summary = await this.oneOff(id, { cwd: project.path, sandbox: 'workspace-write', instructions: mergeInstructions, prompt, model, effort: model?.defaultEffort, timeout: 600000, what: 'resolve the conflicts' });
602
+ return { summary: summary.trim(), remaining: files.filter(file => conflictMarkers(project.path, file)), agent: id, git: await gitStatus(project.path) };
603
+ }
604
+ // Commits what the author accepted from this task since its last commit,
605
+ // with the message they confirmed, and pushes it if they asked. Other
606
+ // uncommitted files stay as they are; a file another task also changed is
607
+ // committed as it is on disk.
608
+ async commitTask(taskId: string, message: string, push = false) {
609
+ const task = this.store.task(taskId);
610
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
611
+ if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
612
+ const project = this.store.project(task.projectId);
613
+ return this.exclusiveCommit(project.id, async () => {
614
+ const accepted = await acceptedCommit(project.path, task.name);
615
+ const files = accepted ? await acceptedFiles(project.path, task.name, this.store.task(taskId).committed?.accepted) : [];
616
+ const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
617
+ if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
618
+ const hash = await gitCommit(project.path, message, paths);
619
+ this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
620
+ void this.refresh(taskId);
621
+ // The commit stands even when the push fails; the author can push again.
622
+ let pushError: string | undefined;
623
+ if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
624
+ return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
625
+ });
626
+ }
627
+ // A commit reads the working tree that accepting, rejecting and undoing
628
+ // write, so neither starts while the other runs on the same paper.
629
+ async exclusiveCommit<T>(projectId: string, work: () => Promise<T>): Promise<T> {
630
+ if (this.committing.has(projectId)) throw new Error('A commit is already in progress. Wait for it to finish.');
631
+ if ([...this.reviewWrites].some(id => this.store.task(id).projectId === projectId)) throw new Error('Wait for the current review action before committing.');
632
+ this.committing.add(projectId);
633
+ try { return await work(); } finally { this.committing.delete(projectId); }
489
634
  }
490
635
  renameTask(taskId: string, title: string) {
491
636
  if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
@@ -528,3 +673,18 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
528
673
  }
529
674
  close() { this.closing = true; for (const timer of this.usageTimers.values()) clearTimeout(timer); this.flush(); for (const id of AGENTS) this.agents[id].close(); }
530
675
  }
676
+
677
+ // The review engine records each task's accepted text as a commit beside the
678
+ // one it started from; their difference is what the task put in the
679
+ // manuscript, and the difference from the accepted text at the task's last
680
+ // commit is what it has added since. A rename counts as both of its paths.
681
+ export async function acceptedFiles(root: string, name: string, since?: string) {
682
+ try {
683
+ const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', since || `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
684
+ return stdout.split('\0').filter(Boolean);
685
+ } catch { return []; }
686
+ }
687
+ export async function acceptedCommit(root: string, name: string) {
688
+ try { return (await execute('git', ['-C', root, 'rev-parse', '--verify', '-q', `refs/latex-review/${name}/accepted^{commit}`])).stdout.trim() || undefined; }
689
+ catch { return undefined; }
690
+ }
package/src/shared.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export type TaskStatus = 'idle' | 'queued' | 'running' | 'waiting_input' | 'building' | 'reviewing' | 'interrupted' | 'failed' | 'uncertain' | 'deleting';
2
2
  export interface Project { id: string; name: string; path: string; createdAt: number }
3
- export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; next?: string; job?: { phase: string; status: string; error?: string; details?: string[] } | null }
3
+ // unsent counts review updates the agent has not received; comments are the
4
+ // ones that ask for a revision. files are the manuscript files this task's
5
+ // accepted changes touched since its last commit, relative to the repository root.
6
+ export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; comments?: number; files?: string[]; next?: string; job?: { phase: string; status: string; error?: string; details?: string[] } | null }
4
7
  export interface PendingReview { route: string; body: Record<string, unknown>; previousStatus: TaskStatus }
5
8
  export interface Task {
6
9
  id: string; projectId: string; name: string; title: string; instructions: string;
@@ -9,9 +12,19 @@ export interface Task {
9
12
  error: string | null; review: ReviewSummary | null;
10
13
  pauseVersion?: number; pendingRunId?: string | null;
11
14
  pendingReview?: PendingReview | null;
15
+ // The task's last commit from its review, and the accepted text it included.
16
+ committed?: { hash: string; accepted: string; at: number } | null;
12
17
  }
13
18
  export interface Run { id: string; taskId: string; requestKey: string; prompt: string; feedbackCursor?: number; status: 'queued' | 'running' | 'completed' | 'failed' | 'interrupted' | 'uncertain'; createdAt: number; finishedAt: number | null }
14
- export interface TimelineItem { id: string; taskId: string; type: string; text: string; detail?: string; status?: string; createdAt: number }
19
+ export interface TimelineItem { id: string; taskId: string; type: string; text: string; detail?: string; status?: string; createdAt: number; meta?: MessageMeta }
20
+ // A user message can run a skill or carry review feedback; the chat shows
21
+ // those as what they are rather than as the prompt the agent receives.
22
+ // Papilot itself sends the agent a build its own edits broke.
23
+ export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure }
24
+ // A failed review build as the engine reports it; log is the compiler log's path.
25
+ export interface BuildFailure { error: string; details: string[]; log?: string }
26
+ export interface FeedbackSummary { round: number | null; accepted: number; rejected: number; undone: number; comments: { where: string; text: string }[]; buildError?: boolean }
27
+ export interface Skill { id: string; name: string; description: string; hint: string; source: 'builtin' | 'user' | 'paper' }
15
28
  export interface PendingRequest { id: string; taskId: string; method: string; params: Record<string, any>; createdAt: number }
16
29
  export type AgentId = 'codex' | 'claude' | 'antigravity';
17
30
  export interface AgentModel { id: string; name: string; isDefault: boolean; efforts: string[]; defaultEffort: string | null }
@@ -21,7 +34,8 @@ export interface Usage { plan: string | null; windows: UsageWindow[]; credits: {
21
34
  export interface RuntimeStatus { connected: boolean; authenticated: boolean; account: string | null; error: string | null; models: AgentModel[]; usage?: Usage | null }
22
35
  export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string }
23
36
  export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
24
- export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean }
37
+ // merging is a merge in progress, with the files whose conflict markers remain.
38
+ export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
25
39
  export interface GitDiff { path: string; diff: string; truncated: boolean }
26
40
  export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean }
27
41
  export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string }
@@ -0,0 +1 @@
1
+ @font-face{font-family:Review Serif;src:url(/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf)format("opentype");font-weight:400;font-display:swap}@font-face{font-family:Review Serif;src:url(/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf)format("opentype");font-weight:700;font-display:swap}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--serif:"Review Serif", Palatino, "Palatino Linotype", "Songti SC", Georgia, serif;--sans:-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans", sans-serif;--frame:#fff;--bar:#fff;--ink:#171717;--muted:#666;--line:#e5e5e5;--paper:#fff;--accent:#356aa0;--accept:#1d7448;--reject:#b0313b;--comment:#356aa0;--before:#fbeeee;--before-edge:#d17c82;--after:#eef8f1;--after-edge:#68ab82;--head:#fafafa;--shadow:0 1px 2px #0000000f, 0 4px 18px #0000000d;--paper-top:24px;font:14px/1.5 var(--sans);color:var(--ink);background:var(--frame)}*{box-sizing:border-box}body{background:var(--frame);text-rendering:optimizelegibility;grid-template-rows:auto 1fr;height:100dvh;margin:0;display:grid;overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.38}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}[hidden]{display:none!important}.muted{color:var(--muted)}a{color:var(--accent);text-underline-offset:.18em}h1,h2,.brand{font-family:var(--serif);font-weight:400;line-height:1.3}.brand{letter-spacing:.02em;color:var(--ink);font-size:20px;font-variant-caps:small-caps;text-decoration:none}kbd{border:1px solid var(--line);color:var(--muted);background:var(--frame);vertical-align:1px;border-radius:4px;margin-left:6px;padding:2px 4px;font:600 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.bar{background:var(--bar);border-bottom:1px solid var(--line);z-index:3;align-items:center;gap:18px;min-height:62px;padding:10px 24px;display:flex}.identity{flex:1;align-items:baseline;gap:10px;min-width:0;display:flex}.identity h1{letter-spacing:.02em;white-space:nowrap;text-overflow:ellipsis;margin:0;font-size:20px;font-variant-caps:small-caps;overflow:hidden}.identity span{white-space:nowrap;font-size:13px}.meter{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums;align-items:center;gap:10px;font-size:12px;display:flex}.track{background:var(--line);width:120px;height:2px;overflow:hidden}.track i{background:#999;width:0;height:100%;transition:width .3s;display:block}.bar-end{align-items:center;gap:8px;display:flex}.status:empty{display:none}.status{background:var(--head);color:var(--muted);white-space:nowrap;border-radius:999px;padding:3px 9px;font-size:12px}.status[data-state=build_failed],.status[data-state=offline]{color:var(--reject)}.ghost{color:var(--muted);background:0 0;border:1px solid #0000;border-radius:7px;padding:5px 9px;font-size:13px}.ghost:hover{border-color:var(--line);color:var(--ink);text-underline-offset:.18em;text-decoration:underline}.accept-all,.reject-all{color:var(--ink);border-color:var(--line);border-radius:999px;box-shadow:0 1px 3px #0000000d}.batch-count{text-align:center;font-variant-numeric:tabular-nums;min-width:18px;color:var(--muted);background:#0000000f;border-radius:999px;margin-left:6px;padding:0 5px;font-size:11.5px;line-height:18px;display:inline-block}.overview-link{color:var(--accent);flex:none;text-decoration:none}.ghost.round{border-color:var(--line);border-radius:50%;width:30px;height:30px;padding:0}#paper{padding:var(--paper-top) 24px 90px;overflow-anchor:none;outline:none;scroll-padding:70px 0 90px;overflow:auto}.document{max-width:980px;margin:0 auto}.empty{text-align:center;color:var(--muted);padding:60px 16px}.part+.part{margin-top:30px}.part-title{color:var(--muted);letter-spacing:.02em;margin:14px 0 18px;font-size:19px;font-variant-caps:small-caps;font-weight:400}.part-title .number{font-variant-numeric:tabular-nums;margin-right:8px}.sheet{background:var(--paper);box-shadow:var(--shadow);border-radius:3px;padding:10px 0}.context,.side{line-height:0}.document img{width:100%;height:auto;display:block}.fold{color:#9a9d98;letter-spacing:4px;background:repeating-linear-gradient(90deg,#0000 0 6px,#ececec 6px 12px) 50%/100% 1px no-repeat;justify-content:center;align-items:center;height:22px;margin:4px 0;font-size:16px;display:flex}.change{color:var(--ink);outline:none;scroll-margin:70px;position:relative}.change.standalone{background:var(--paper);box-shadow:var(--shadow);border-radius:3px;margin-bottom:26px}.change.inline{margin:6px 0}.change-head{background:var(--head);color:var(--muted);border-top:1px solid var(--line);border-bottom:1px solid var(--line);align-items:baseline;gap:10px;padding:8px 12px;font-size:12px;line-height:1.6;display:flex}.change-number{color:var(--ink);white-space:nowrap;font-weight:400}.change-where{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font:11px/1.8 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden}.change-kind{white-space:nowrap}.badge{white-space:nowrap;font-weight:600}.badge:empty{display:none}.link{color:var(--accent);background:0 0;border:0;padding:0 2px;font-size:12px}.link.active{font-weight:600;text-decoration:underline}.edits{white-space:nowrap}.side{border-left:2px solid #0000;position:relative}.side.before{background:var(--before);border-left-color:var(--before-edge)}.side.after{background:var(--after);border-left-color:var(--after-edge)}.side.after.identical{border-left-color:var(--line);background:0 0}.side img{mix-blend-mode:multiply}.side:after{font:10px/1 var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--before-edge);opacity:.9;pointer-events:none;position:absolute;top:4px;right:8px}.side.before:after{content:"before"}.side.after:after{content:"after";color:var(--after-edge)}:where(:lang(zh-CN)) .side.before:after{content:"修改前"}:where(:lang(zh-CN)) .side.after:after{content:"修改后"}.side.after.identical:after,.change[data-resolved] .side:after{content:none}.caption{color:var(--muted);margin:0;padding:4px 12px 8px;font-size:12px}.change.current{box-shadow:0 0 0 2px var(--accent);z-index:1}.change.current .change-head{background:color-mix(in srgb, var(--accent) 8%, var(--head));color:var(--ink)}.change[data-state=accepted] .badge{color:var(--accept)}.change[data-state=rejected] .badge{color:var(--reject)}.change[data-state=commented] .badge,.change[data-state=mixed] .badge{color:var(--comment)}.change[data-resolved=after] .side.before,.change[data-resolved=before] .side.after:not(.identical){display:none}.change[data-resolved] .side{background:0 0;border-left-color:#0000}.change[data-resolved] .caption{display:none}.remark,.histories{padding:8px 14px;font-size:13px;line-height:1.55}.remark{background:color-mix(in srgb, var(--comment) 8%, #fff);border-top:1px solid var(--line);color:var(--ink)}.remark p,.histories p{white-space:pre-wrap;margin:0}.histories{border-bottom:1px solid var(--line);color:var(--muted);background:#f6f7fb}.source{background:var(--paper);border-top:1px solid var(--line);padding:10px 14px 12px;line-height:1.5}.source-unit+.source-unit{margin-top:12px}.source-title{color:#555;align-items:center;gap:8px;margin-bottom:6px;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;display:flex}.unit-actions{font:13px var(--sans);align-items:center;gap:2px;margin-left:auto;display:flex}.unit-actions button{border:1px solid var(--line);background:var(--paper);border-radius:0;padding:1px 8px;font-weight:600}.unit-actions button:not(:disabled):hover{background:var(--frame)}.unit-actions .accept{color:var(--accept)}.unit-actions .reject{color:var(--reject)}.unit-actions .comment{color:var(--comment)}.unit-state{font-weight:600}.source-unit[data-state=accepted] .unit-state{color:var(--accept)}.source-unit[data-state=rejected] .unit-state{color:var(--reject)}.source-unit[data-state=commented] .unit-state{color:var(--comment)}.source-code{border:1px solid var(--line);white-space:pre-wrap;overflow-wrap:anywhere;color:#2b2f33;background:#fff;border-radius:5px;max-height:420px;margin:0;padding:10px 12px;font:12.5px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto}.source-code del{color:#8e1d28;background:#fadfe1;-webkit-text-decoration:line-through #c9636b;text-decoration:line-through #c9636b}.source-code ins{color:#11603a;background:#d6f0df;text-decoration:none}.banner{background:color-mix(in srgb, var(--accent) 6%, var(--paper));border:1px solid var(--line);border-radius:8px;max-width:980px;margin:0 auto 14px;padding:10px 14px;font-size:13px}.banner p{margin:3px 0}.banner .details{background:var(--bar);white-space:pre-wrap;border-radius:5px;max-height:220px;margin:6px 0 2px;padding:8px 10px;font:11.5px/1.5 ui-monospace,Menlo,monospace;overflow:auto}.banner summary{cursor:pointer;color:var(--muted)}.waiting{text-align:center;padding:90px 16px}.waiting p{margin:6px 0}.pulse{background:var(--accent);border-radius:50%;width:12px;height:12px;margin:0 auto 18px;animation:1.6s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:.25;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}.task-list{background:var(--bar);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:8px;margin:0;padding:0;list-style:none}.task-list li+li{border-top:1px solid var(--line)}.index .document{max-width:880px}.overview-heading{margin:32px 0}.overview-heading h1{letter-spacing:.02em;margin:0 0 6px;font-size:34px;font-variant-caps:small-caps}.overview-heading p{margin:6px 0}.overview-summary{color:var(--muted);font-size:13px}.task-tools{gap:10px;margin-bottom:16px;display:flex}.task-tools input,.task-tools select{font:inherit;color:var(--ink);background:var(--bar);border:1px solid var(--line);border-radius:7px;padding:9px 12px}.task-tools input{flex:1;min-width:0}.task-tools input::placeholder{color:var(--muted)}.task-card{color:var(--ink);border-radius:7px;padding:24px 20px;text-decoration:none;display:block}.task-card:hover{background:var(--head)}.task-card:focus-visible,.overview-link:focus-visible,.task-tools :focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-heading{justify-content:space-between;align-items:baseline;gap:10px;display:flex}.task-heading h2{overflow-wrap:anywhere;margin:0;font-size:21px;font-weight:400}.task-meta{color:var(--muted);flex-wrap:wrap;gap:4px 14px;margin-top:5px;font-size:12px;display:flex}.task-name{overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.task-progress{color:var(--muted);align-items:center;gap:10px;margin-top:14px;font-size:12px;display:flex}.task-progress .track{width:140px}.task-footer{justify-content:space-between;align-items:baseline;gap:10px;margin-top:9px;font-size:12px;display:flex}.task-counts{color:var(--muted);flex-wrap:wrap;gap:4px 14px;display:flex}.task-counts [data-kind=pending]{color:var(--accent);font-weight:600}.task-counts [data-kind=commented]{color:var(--comment)}.task-open{color:var(--accent);flex:none;font-weight:600}.actions{z-index:4;background:var(--bar);border:1px solid var(--line);max-width:calc(100vw - 24px);box-shadow:var(--shadow);border-radius:12px;align-items:center;gap:2px;padding:5px;display:flex;position:fixed;bottom:22px;left:50%;transform:translate(-50%)}.actions button{white-space:nowrap;background:0 0;border:0;border-radius:8px;padding:9px 14px;font-size:14px}.actions button:not(:disabled):hover{background:var(--head)}.actions .nav{color:var(--muted);padding:6px 12px;font-size:20px;line-height:1}.actions .accept{color:var(--accept)}.actions .reject{color:var(--reject)}.actions .comment{color:var(--comment)}.actions .skip{font-weight:600}.actions .undo{color:var(--muted);border-left:1px solid var(--line);border-radius:0 8px 8px 0;margin-left:4px;font-size:13px}.actions .working{opacity:.55}.position{text-align:center;min-width:52px;color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px}.complete{color:var(--muted);white-space:nowrap;padding:0 14px;font-size:13px}.change.current>.change-head{top:calc(-1 * var(--paper-top));z-index:4;position:sticky}.change-head .dock{flex:none;align-items:center;margin:0;display:flex}.change-head .actions{background:var(--bar);border:1px solid var(--line);border-radius:10px;gap:0;max-width:none;padding:3px;position:static;transform:none;box-shadow:0 1px 2px #00000014,0 6px 20px #0000001f}.change-head .actions button{padding:4px 8px;font-size:13px;line-height:1.5}.change-head .actions .nav{padding:2px 8px;font-size:17px}.change-head .actions .undo{margin-left:3px;padding:4px 10px;font-size:12px}.change-head .position{min-width:40px;font-size:11.5px}.change-head .complete{padding:0 8px;font-size:12px}.change-head .composer{width:min(380px,100vw - 24px);position:absolute;inset:calc(100% + 6px) 8px auto auto;transform:none}.composer{z-index:5;background:var(--bar);border:1px solid var(--line);border-radius:12px;width:min(540px,100vw - 24px);padding:12px;position:fixed;bottom:84px;left:50%;transform:translate(-50%);box-shadow:0 6px 24px #00000014}.composer label{color:var(--ink);margin-bottom:6px;font-size:17px;font-weight:400;display:block}.composer textarea{resize:vertical;border:1px solid var(--line);background:var(--paper);color:#1f2328;border-radius:7px;width:100%;min-height:84px;padding:8px 10px}.composer textarea:focus{outline:2px solid color-mix(in srgb, var(--comment) 45%, transparent);border-color:#0000}.composer-row{justify-content:space-between;align-items:center;margin-top:8px;font-size:12px;display:flex}.composer-row kbd{margin-left:0}.composer button{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:7px;padding:6px 16px}.toast{z-index:6;border:1px solid var(--line);background:var(--paper);max-width:min(640px,100vw - 24px);color:var(--ink);white-space:pre-wrap;padding:9px 14px;font-size:13px;position:fixed;top:74px;left:50%;transform:translate(-50%);box-shadow:0 3px 12px #0000000f}.toast.error{background:#7f2b31}dialog{border:1px solid var(--line);box-shadow:var(--shadow);background:var(--bar);color:var(--ink);border-radius:12px;max-width:min(460px,100vw - 32px);padding:20px 24px}dialog::backdrop{background:#00000040}dialog h2{margin:0 0 12px;font-size:23px}dialog dl{grid-template-columns:auto 1fr;gap:8px 16px;margin:0 0 12px;font-size:13px;display:grid}dialog dt{white-space:nowrap}dialog dt kbd{margin:0 2px 0 0}dialog dd{margin:0}dialog form{text-align:right}dialog button{border:1px solid var(--line);background:var(--frame);border-radius:7px;padding:5px 14px}@media (width<=720px){.bar{grid-template-columns:auto 1fr auto;gap:8px;padding:12px 16px;display:grid}.identity{grid-area:1/1/auto/-1}.bar .meter{justify-self:center;font-size:11px}.bar-end{gap:2px}.bar .ghost{padding:4px 2px;font-size:12px}.side{min-height:20px}.track{width:70px}.actions button{padding:9px}.change-head{flex-wrap:wrap}.change-head .dock{flex-basis:100%;order:9;justify-content:center;margin:4px 0 2px}.change-head .composer{width:calc(100vw - 24px);right:0}:root{--paper-top:10px}#paper{padding:var(--paper-top) 6px 90px}#paper.index{padding:10px 14px 40px}.overview-heading{margin-top:16px}.task-tools{flex-wrap:wrap}.task-tools input{flex-basis:100%}.task-tools select{width:100%}.task-card{padding:14px}.task-heading{flex-direction:column;gap:6px}}@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}body{display:block}#root{grid-template-rows:auto 1fr;height:100dvh;display:grid;overflow:hidden}.review-body{min-height:0;position:relative}.review-body>#paper{scrollbar-width:none;height:100%;padding-right:54px}.review-body>#paper::-webkit-scrollbar{display:none}.change-map{z-index:5;border:1px solid var(--line);touch-action:none;-webkit-user-select:none;user-select:none;background:#fafafa;border-radius:7px;width:22px;position:absolute;top:12px;bottom:12px;right:18px;box-shadow:0 1px 4px #0000000d}.change-map-viewport{cursor:grab;background:#6a6f741c;border:1px solid #c6c9cc;border-radius:5px;position:absolute;left:0;right:0}.change-map-viewport:active{cursor:grabbing}.change-map-mark{--mark:#b18a3c;background:var(--mark);border:0;border-radius:2px;width:calc(100% - 10px);min-height:0;padding:0;position:absolute;left:5px}.change-map-mark[data-state=accepted]{--mark:var(--accept)}.change-map-mark[data-state=rejected]{--mark:var(--reject)}.change-map-mark[data-state=commented]{--mark:var(--comment)}.change-map-mark[data-state=mixed]{--mark:#796195}.change-map-mark[aria-current]{outline:2px solid var(--ink);outline-offset:2px}.change-map-mark:hover,.change-map-mark:focus-visible{z-index:1;outline:2px solid var(--accent);outline-offset:2px}.change-map-tooltip{border:1px solid var(--line);background:var(--paper);width:max-content;max-width:min(360px,100vw - 64px);box-shadow:var(--shadow);color:var(--ink);font:12px/1.5 var(--sans);text-align:left;overflow-wrap:anywhere;pointer-events:none;visibility:hidden;border-radius:7px;padding:7px 10px;position:absolute;top:0;right:calc(100% + 12px)}.change-map-mark:hover .change-map-tooltip,.change-map-mark:focus-visible .change-map-tooltip{visibility:visible}@media (width<=720px){.review-body>#paper{padding-right:46px}.change-map{width:18px;right:16px}.change-map-mark{width:calc(100% - 6px);left:3px}.change-head .actions{flex-wrap:wrap;justify-content:center;max-width:100%}.change-head .actions button{padding:4px 6px;font-size:12px}.change-head .actions .nav{padding:2px 6px}.change-head .position{min-width:32px}}.pdf-strip{isolation:isolate;width:100%;position:relative;container-type:inline-size}.pdf-strip img{-webkit-user-select:none;user-select:none;pointer-events:none;position:absolute;inset:0}.pdf-text{color:#0000;-webkit-user-select:text;user-select:text;cursor:text;line-height:1;position:absolute;inset:0;overflow:hidden}.pdf-text-line{white-space:pre}.pdf-glyph{white-space:pre;transform-origin:0 0;color:#0000;font-family:Times New Roman,serif;font-weight:400;line-height:1;position:absolute}.pdf-glyph::selection{color:#0000;background:#437ee759}.copy-reference{white-space:nowrap}.change-head{flex-wrap:wrap}.change-head .change-where{min-width:160px}.document .source-unit button{line-height:1.5}.help-backdrop{z-index:8;background:#00000040;place-items:center;display:grid;position:fixed;inset:0}.help-backdrop dialog{margin:0;position:static}.composer{line-height:1.5}@media (width<=900px){.change-head .dock{flex-basis:100%;order:9;justify-content:center;margin:4px 0 2px}}#root:has(>.review-mode){grid-template-rows:auto auto minmax(0,1fr)}.reviewed-change,.change.reviewed-change.current{box-shadow:none;background:0 0;border:0}.reviewed-change>.change-head,.reviewed-change>.histories,.reviewed-change>.remark,.reviewed-change>.source,.reviewed-change>.caption,.mixed-reviewed>.side{display:none}.reviewed-placeholder{color:var(--muted);font:13px/1.5 var(--sans);text-align:center;margin:0;padding:10px 14px}.review-mode{border-bottom:1px solid var(--line);background:var(--paper);font:13px/1.4 var(--sans);flex-wrap:wrap;align-items:center;gap:8px;padding:6px 14px;display:flex}.review-mode>span,.review-mode>label{color:var(--muted);margin-right:auto}.review-mode label{align-items:center;gap:8px;display:flex}.review-mode select{border:1px solid var(--line);background:var(--paper);max-width:160px;font:inherit;color:var(--ink);border-radius:6px;padding:4px 8px}.review-mode .ghost{white-space:nowrap;padding:4px 8px;font-size:13px}.review-mode .undo{border:1px solid var(--line);border-radius:6px}.review-complete{max-width:var(--page-width);border:1px solid var(--line);background:var(--paper);font:14px/1.5 var(--sans);border-radius:10px;flex-wrap:wrap;align-items:baseline;gap:2px 8px;margin:0 auto 14px;padding:9px 14px;display:flex}.review-complete strong{font-weight:600}.review-complete span{color:var(--muted)}.review-complete-actions{align-items:center;gap:8px;margin-left:auto;display:flex}.review-complete-actions:empty{display:none}@media (width<=680px){.review-mode{padding:5px 8px}.review-mode .ghost{min-height:36px}.review-mode label{font-size:12px}.review-mode select{max-width:135px}}