papilot 0.7.0 → 0.8.1
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 +53 -100
- package/README.zh-CN.md +52 -97
- package/bin/doctor.mjs +55 -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-CMfCtEYQ.js} +54 -3
- package/dist/assets/index-BlLyIqT9.js +265 -0
- package/dist/assets/index-DVZtyZvu.css +1 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
- package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/preview/main.pdf +0 -0
- package/examples/papilot-paper/preview/main.synctex.gz +0 -0
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +18 -8
- 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 +387 -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 +56 -14
- 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/desktop-demo.ts +38 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +183 -23
- package/src/server/paths.ts +1 -1
- package/src/server/reconcile.ts +20 -0
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skills.ts +5 -8
- package/src/server/start.ts +19 -4
- 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 +49 -14
- package/src/server/workspace.ts +236 -41
- package/src/shared.ts +88 -9
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.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/current-commit.ts +104 -0
- package/vendor/latex-review/src/core/delivery.ts +62 -16
- package/vendor/latex-review/src/core/git.ts +2 -1
- package/vendor/latex-review/src/core/publish.ts +7 -1
- package/vendor/latex-review/src/core/reconcile.ts +101 -0
- package/vendor/latex-review/src/core/review.ts +6 -2
- package/vendor/latex-review/src/core/store.ts +1 -0
- package/vendor/latex-review/src/core/tasks.ts +1 -1
- 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 +77 -1
- package/vendor/latex-review/src/types.ts +130 -2
- 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 +263 -133
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +79 -2
- 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
package/src/server/workspace.ts
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
+
import { aiReviewInstructions, parseAiReview, reviewPrompt, REVIEW_AGENTS, type ReviewSnapshot } from './ai-review.ts';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { execFile } from 'node:child_process';
|
|
3
4
|
import { promisify } from 'node:util';
|
|
4
5
|
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
5
7
|
import path from 'node:path';
|
|
6
8
|
import { EventEmitter } from 'node:events';
|
|
7
9
|
import { Store } from './store.ts';
|
|
8
10
|
import { Attachments } from './attachments.ts';
|
|
9
11
|
import { Codex, RpcResponseError } from './codex.ts';
|
|
10
|
-
import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from './agents.ts';
|
|
12
|
+
import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, GlmAgent, type AgentRuntime } from './agents.ts';
|
|
13
|
+
import { ACP_PROFILES, AcpAgent } from './acp.ts';
|
|
14
|
+
import { CursorAgent } from './cursor.ts';
|
|
11
15
|
import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
|
|
12
16
|
import { VERSION } from './paths.ts';
|
|
13
17
|
import { prepareRepository } from './imports.ts';
|
|
14
18
|
import { findSkill, type SkillFile } from './skills.ts';
|
|
15
19
|
import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
|
|
20
|
+
import { reconciliationPrompt } from './reconcile.ts';
|
|
16
21
|
import { commitMessageFrom } from '../commit.ts';
|
|
17
22
|
import { taskCaptionFrom, taskCaptionInstructions } from '../task-caption.ts';
|
|
18
23
|
import { notesInstructions, reviewNotesInstructions, summarizeNotes } from '../notes.ts';
|
|
19
24
|
import { canonicalGroup, groupsWithoutInstructions, parseGroupInstructions, type GroupInstructions } from '../note-groups.ts';
|
|
20
25
|
import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
|
|
21
|
-
import
|
|
26
|
+
import { AGENT_IDS, isTaskReviewNote } from '../shared.ts';
|
|
27
|
+
import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author, Annotation, NoteSummary, TaskReconciliation, TaskCommitReview } from '../shared.ts';
|
|
22
28
|
|
|
23
29
|
const execute = promisify(execFile);
|
|
24
30
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -53,8 +59,26 @@ const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'dele
|
|
|
53
59
|
// publish.ts), and the request keys of the turns that send them back to it.
|
|
54
60
|
const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
|
|
55
61
|
const BUILD_FIX = 'build-fix-';
|
|
62
|
+
// The tool Papilot gives Codex threads for looking at the typeset paper. The
|
|
63
|
+
// review engine compiles the task worktree as it is and draws the pages.
|
|
64
|
+
export const VIEW_PDF = 'view_pdf';
|
|
65
|
+
export const VIEW_PDF_TOOL = {
|
|
66
|
+
type: 'function', name: VIEW_PDF,
|
|
67
|
+
description: 'Compile the paper in your task worktree as it is now and draw typeset pages as PNG files. Ask for the pages where source lines are typeset (locations, such as "main/chapters/intro.tex:42") or for page numbers as printed on the pages; up to 4 pages per call. The result lists each page\'s file: open the ones you need with your image viewer (view_image) to see them. Set accepted to true to draw the author\'s accepted text, before your edits, instead. Only the chapters needed are compiled when the paper allows it, but this can still take a minute or more.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object', additionalProperties: false,
|
|
70
|
+
properties: {
|
|
71
|
+
locations: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Source lines as file:line, relative to the worktree.' },
|
|
72
|
+
pages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Printed page numbers, such as "12" or "iv", when the PDF provides page labels. Use locations if numbering metadata is unavailable.' },
|
|
73
|
+
accepted: { type: 'boolean', description: 'Show the accepted text instead of your worktree.' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const VIEW_PDF_UNSUPPORTED = /dynamicTools|experimental/i;
|
|
78
|
+
// What a view_pdf call asked for, as the chat shows it.
|
|
79
|
+
const viewedPlaces = (args: any) => [...(Array.isArray(args?.locations) ? args.locations : []), ...(Array.isArray(args?.pages) ? args.pages.map((page: unknown) => `p. ${page}`) : [])].filter(place => typeof place === 'string').join(', ') + (args?.accepted === true ? ' (accepted)' : '');
|
|
56
80
|
|
|
57
|
-
const AGENTS: AgentId[] =
|
|
81
|
+
const AGENTS: AgentId[] = AGENT_IDS;
|
|
58
82
|
// The small model an agent offers for short text such as a commit message,
|
|
59
83
|
// by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
|
|
60
84
|
const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
|
|
@@ -67,11 +91,13 @@ const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: fa
|
|
|
67
91
|
|
|
68
92
|
export class Workspace extends EventEmitter {
|
|
69
93
|
// Each task runs on one agent; `runtime` is Codex's status, kept for older clients.
|
|
70
|
-
runtimes
|
|
94
|
+
runtimes = Object.fromEntries(AGENTS.map(id => [id, blankRuntime()])) as Record<AgentId, RuntimeStatus>;
|
|
71
95
|
runtime = this.runtimes.codex;
|
|
72
96
|
agents: Record<AgentId, AgentRuntime>;
|
|
73
97
|
requests = new Map<string, PendingRequest & { rpcId: string | number; agent: AgentId }>();
|
|
74
98
|
private active = new Map<string, Run>();
|
|
99
|
+
// Async tools must never answer a request from an earlier connection.
|
|
100
|
+
private connectionEpochs = new Map<AgentId, number>();
|
|
75
101
|
private loaded = new Set<string>();
|
|
76
102
|
private preparing = new Set<string>();
|
|
77
103
|
private preparingNotes = new Set<string>();
|
|
@@ -94,6 +120,8 @@ export class Workspace extends EventEmitter {
|
|
|
94
120
|
private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
|
|
95
121
|
private titles = new Map<string, Promise<Task | null>>();
|
|
96
122
|
private helperStops = new Set<() => void>();
|
|
123
|
+
// Set when Codex refuses the view_pdf tool, so later threads start without it.
|
|
124
|
+
private noViewPdf = false;
|
|
97
125
|
private projectChanges = new Set<string>();
|
|
98
126
|
private projectRequests = new Map<string, number>();
|
|
99
127
|
importsBusy = () => false;
|
|
@@ -105,7 +133,9 @@ export class Workspace extends EventEmitter {
|
|
|
105
133
|
constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
|
|
106
134
|
super();
|
|
107
135
|
this.attachments = new Attachments(store);
|
|
108
|
-
this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent()
|
|
136
|
+
this.agents = { codex, claude: others.claude || new ClaudeAgent(), cursor: others.cursor || new CursorAgent(), copilot: others.copilot || new AcpAgent(ACP_PROFILES.copilot), grok: others.grok || new AcpAgent(ACP_PROFILES.grok), antigravity: others.antigravity || new AntigravityAgent(),
|
|
137
|
+
kimi: others.kimi || new AcpAgent(ACP_PROFILES.kimi), glm: others.glm || new GlmAgent(), deepseek: others.deepseek || new AcpAgent(ACP_PROFILES.deepseek),
|
|
138
|
+
qwen: others.qwen || new AcpAgent(ACP_PROFILES.qwen), opencode: others.opencode || new AcpAgent(ACP_PROFILES.opencode) };
|
|
109
139
|
store.on('change', () => this.emit('change'));
|
|
110
140
|
for (const id of AGENTS) {
|
|
111
141
|
const agent = this.agents[id];
|
|
@@ -116,6 +146,7 @@ export class Workspace extends EventEmitter {
|
|
|
116
146
|
}
|
|
117
147
|
private agentOf(task: Task): AgentId { return task.agent && AGENTS.includes(task.agent) ? task.agent : 'codex'; }
|
|
118
148
|
private disconnected(id: AgentId, e: unknown) {
|
|
149
|
+
this.connectionEpochs.set(id, (this.connectionEpochs.get(id) || 0) + 1);
|
|
119
150
|
this.runtimes[id].connected = false; this.runtimes[id].error = errorText(e);
|
|
120
151
|
for (const [key, request] of this.requests) if (request.agent === id) this.requests.delete(key);
|
|
121
152
|
for (const [taskId, run] of this.active) {
|
|
@@ -137,6 +168,10 @@ export class Workspace extends EventEmitter {
|
|
|
137
168
|
}
|
|
138
169
|
private async connectAgent(id: AgentId) {
|
|
139
170
|
const agent = this.agents[id], runtime = this.runtimes[id], name = AGENT_NAMES[id];
|
|
171
|
+
// An agent that is not on this host is neither started nor offered;
|
|
172
|
+
// Reconnect looks again. A running agent stays connected.
|
|
173
|
+
runtime.installed = agent.ready || !agent.detect || agent.detect();
|
|
174
|
+
if (!runtime.installed) { Object.assign(runtime, blankRuntime(), { installed: false }); return; }
|
|
140
175
|
try {
|
|
141
176
|
await agent.start();
|
|
142
177
|
const account = await agent.request('account/read', { refreshToken: false });
|
|
@@ -236,12 +271,14 @@ export class Workspace extends EventEmitter {
|
|
|
236
271
|
}
|
|
237
272
|
// A task combines the author's words, an optional skill and selected PDF notes.
|
|
238
273
|
annotationSending(id: string) { return this.preparingNotes.has(id); }
|
|
239
|
-
async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; annotations?: string[]; groupInstructions?: GroupInstructions; attachments?: string[]; agent?: AgentId; model?: string; effort?: string; serviceTier?: string; id: string; author?: Author }) {
|
|
274
|
+
async createTask(input: { reviewInput?: ReviewSnapshot; prepareReview?: (worktree: string) => Promise<ReviewSnapshot>; projectId: string; title: string; instructions: string; skill?: string; annotations?: string[]; groupInstructions?: GroupInstructions; attachments?: string[]; agent?: AgentId; model?: string; effort?: string; serviceTier?: string; id: string; author?: Author }) {
|
|
240
275
|
if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
|
|
241
276
|
if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
|
|
242
277
|
// A retried request returns its task; an ID from another paper is refused.
|
|
243
278
|
const existing = this.store.tasks().find(t => t.id === input.id);
|
|
244
|
-
if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
|
|
279
|
+
if (existing) { if (existing.projectId !== input.projectId || (existing.mode === 'review') !== Boolean(input.reviewInput)) throw new Error('Invalid task ID.'); return existing; }
|
|
280
|
+
if (input.reviewInput && !REVIEW_AGENTS.includes(input.agent || 'codex')) throw new Error('Choose Codex, Claude or GLM for AI review.');
|
|
281
|
+
if (input.reviewInput && (input.annotations?.length || input.attachments?.length)) throw new Error('AI review uses the manuscript PDF. Start a separate editing task to send notes or attachments.');
|
|
245
282
|
if (input.serviceTier !== undefined) {
|
|
246
283
|
if (input.agent && input.agent !== 'codex') throw new Error('Service tiers are only available for Codex.');
|
|
247
284
|
const models = this.runtimes.codex.models;
|
|
@@ -254,9 +291,10 @@ export class Workspace extends EventEmitter {
|
|
|
254
291
|
let releaseAttachments = () => {};
|
|
255
292
|
try {
|
|
256
293
|
const project = this.store.project(input.projectId), skill = input.skill ? findSkill(project.path, input.skill) : undefined;
|
|
294
|
+
if (skill?.output === 'annotations' && !input.reviewInput) throw new Error('Start a new task to run the AI review skill on the current PDF.');
|
|
257
295
|
const files = this.attachments.resolve(project.id, input.attachments || [], input.author?.id || 'owner', input.id);
|
|
258
296
|
releaseAttachments = this.attachments.reserve(project.id, input.attachments || [], input.author?.id || 'owner', input.id);
|
|
259
|
-
const available = new Map(this.store.annotations(project.id).filter(note => !note.taskId).map(note => [note.id, note]));
|
|
297
|
+
const available = new Map(this.store.annotations(project.id).filter(note => !note.taskId && !isTaskReviewNote(note)).map(note => [note.id, note]));
|
|
260
298
|
notes = [...new Set(input.annotations || [])].map(id => {
|
|
261
299
|
const note = available.get(id);
|
|
262
300
|
if (!note) throw new Error('A selected PDF note is no longer available. Refresh the manuscript and try again.');
|
|
@@ -268,16 +306,20 @@ export class Workspace extends EventEmitter {
|
|
|
268
306
|
if (Object.keys(groupInstructions).some(group => !selectedGroups.has(group as keyof GroupInstructions))) throw new Error('Group instructions must refer to selected PDF notes.');
|
|
269
307
|
if (groupsWithoutInstructions(notes, groupInstructions).length && !input.instructions.trim() && !skill) throw new Error('Describe what to do with each selected color group.');
|
|
270
308
|
claimed = notes.map(note => note.id); claimed.forEach(id => this.preparingNotes.add(id));
|
|
271
|
-
if (!skill && !notes.length && !files.length && !input.instructions.trim()) throw new Error('Describe the revision, choose a skill, or add a PDF note.');
|
|
309
|
+
if (!input.reviewInput && !skill && !notes.length && !files.length && !input.instructions.trim()) throw new Error('Describe the revision, choose a skill, or add a PDF note.');
|
|
272
310
|
const instructions = notes.length ? notesInstructions(notes, input.instructions, groupInstructions) : input.instructions;
|
|
273
311
|
const client = await this.connectReview(project.path);
|
|
274
312
|
const name = 'p-' + input.id.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48);
|
|
275
313
|
const info = await client.post('/api/tasks', { name, title: input.title, deferred: true });
|
|
276
|
-
const
|
|
314
|
+
const reviewInput = input.reviewInput && (input.prepareReview ? await input.prepareReview(info.worktree) : input.reviewInput);
|
|
315
|
+
const task: Task = { ...(reviewInput ? { mode: 'review' as const, aiReview: { key: reviewInput.key } } : {}), id: input.id, projectId: project.id, name, title: input.title, titleSource: 'prompt', instructions: skill ? skillPrompt(skill, instructions) : instructions,
|
|
277
316
|
worktree: info.worktree, deferred: info.deferred === true, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), status: 'idle',
|
|
278
317
|
createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
|
|
279
|
-
this.store.
|
|
280
|
-
|
|
318
|
+
this.store.transaction(() => {
|
|
319
|
+
this.store.saveTask(task);
|
|
320
|
+
if (reviewInput) this.store.saveReviewInput(task.id, reviewInput);
|
|
321
|
+
});
|
|
322
|
+
await this.enqueue(task.id, input.instructions || (input.reviewInput && !skill ? 'Review this manuscript and suggest improvements.' : ''), 'initial-' + task.id, false, { skill, notes, groupInstructions, attachments: files.map(file => file.id), author: input.author });
|
|
281
323
|
// Naming is independent of the editing turn. An unavailable helper must
|
|
282
324
|
// not fail creation or prevent the queued task from running.
|
|
283
325
|
try { this.requestTaskTitle(task.id); } catch { /* Keep the prompt-derived title. */ }
|
|
@@ -287,9 +329,11 @@ export class Workspace extends EventEmitter {
|
|
|
287
329
|
// The author's message, a skill run with or without one, the review
|
|
288
330
|
// feedback alone, or a build the agent broke. The chat keeps what happened;
|
|
289
331
|
// the agent gets the prompt.
|
|
290
|
-
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { attachments?: string[]; skill?: SkillFile; notes?: Annotation[]; groupInstructions?: GroupInstructions; reviewNotes?: NoteSummary[]; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
|
|
332
|
+
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { attachments?: string[]; skill?: SkillFile; notes?: Annotation[]; groupInstructions?: GroupInstructions; reviewNotes?: NoteSummary[]; feedback?: boolean; build?: BuildFailure; author?: Author; merge?: TaskReconciliation } = {}) {
|
|
291
333
|
const task = this.store.task(taskId);
|
|
292
334
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
335
|
+
if (task.mode === 'review' && ((options.skill && options.skill.output !== 'annotations') || options.notes?.length || options.reviewNotes?.length || options.attachments?.length || options.feedback || options.build)) throw new Error('AI review accepts review instructions only. Start an editing task to send notes, skills or attachments.');
|
|
336
|
+
if (task.mode !== 'review' && options.skill?.output === 'annotations') throw new Error('Start a new task to run the AI review skill on the current PDF.');
|
|
293
337
|
const existing = this.store.runByKey(requestKey);
|
|
294
338
|
if (existing) { if (existing.taskId !== taskId) throw new Error('This request ID is already in use by another task.'); return existing; }
|
|
295
339
|
if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
|
|
@@ -299,8 +343,9 @@ export class Workspace extends EventEmitter {
|
|
|
299
343
|
const pauseVersion = task.pauseVersion || 0;
|
|
300
344
|
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.');
|
|
301
345
|
const instructions = options.notes?.length ? notesInstructions(options.notes, text, options.groupInstructions) : options.reviewNotes?.length ? reviewNotesInstructions(options.reviewNotes, text) : text.trim();
|
|
302
|
-
let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, instructions) : instructions, cursor = task.feedbackCursor;
|
|
346
|
+
let prompt = options.merge ? reconciliationPrompt(options.merge) : options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, instructions) : instructions, cursor = task.feedbackCursor;
|
|
303
347
|
let summary: FeedbackSummary | undefined;
|
|
348
|
+
if (task.mode === 'review') includeFeedback = false;
|
|
304
349
|
if (includeFeedback) {
|
|
305
350
|
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
306
351
|
const feedback = await client.get(`${taskRoute(task.name)}/feedback?after=${task.feedbackCursor}`);
|
|
@@ -310,8 +355,9 @@ export class Workspace extends EventEmitter {
|
|
|
310
355
|
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.';
|
|
311
356
|
}
|
|
312
357
|
if (files.length) prompt = (prompt || 'The user attached reference material without an instruction. Briefly identify the material and ask what they would like to do with it. Do not change the manuscript yet.') + this.attachments.prompt(files);
|
|
358
|
+
if (task.mode === 'review') prompt = reviewPrompt(prompt, this.store.reviewInput(taskId));
|
|
313
359
|
const run: Run = { id: randomUUID(), taskId, requestKey, prompt, ...(files.length ? { attachments: files } : {}), feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null, reviewRound: 0 };
|
|
314
|
-
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(options.notes?.length ? { notes: summarizeNotes(options.notes) } : options.reviewNotes?.length ? { notes: options.reviewNotes } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
|
|
360
|
+
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name, ...(options.skill.icon ? { icon: options.skill.icon } : {}) } } : {}), ...(options.notes?.length ? { notes: summarizeNotes(options.notes) } : options.reviewNotes?.length ? { notes: options.reviewNotes } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
|
|
315
361
|
if (options.groupInstructions && Object.keys(options.groupInstructions).length) meta.groupInstructions = options.groupInstructions;
|
|
316
362
|
const saved = this.store.transaction(() => {
|
|
317
363
|
// Feedback loading yields to other requests. Recheck identity, queue, and
|
|
@@ -334,6 +380,8 @@ export class Workspace extends EventEmitter {
|
|
|
334
380
|
this.store.saveRun(run);
|
|
335
381
|
for (const note of options.notes || []) this.store.saveAnnotation({ ...note, taskId });
|
|
336
382
|
if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
|
|
383
|
+
// Writing to an archived task brings it back to the task list.
|
|
384
|
+
if (current.archivedAt && !options.build) this.store.updateTask(taskId, { archivedAt: null });
|
|
337
385
|
this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
|
|
338
386
|
return run;
|
|
339
387
|
});
|
|
@@ -365,14 +413,17 @@ export class Workspace extends EventEmitter {
|
|
|
365
413
|
return files.some(file => file === '' || file === '.') ? [] : files;
|
|
366
414
|
} catch { return []; }
|
|
367
415
|
}
|
|
368
|
-
private instructions(task: Task, scope: string[] = []) {
|
|
416
|
+
private instructions(task: Task, scope: string[] = [], viewPdf = false) {
|
|
417
|
+
if (task.mode === 'review') return aiReviewInstructions + '\n\nOriginal review skill and request:\n' + task.instructions;
|
|
369
418
|
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.
|
|
370
419
|
Your task is already created. Your editable working directory is ${task.worktree}.
|
|
371
420
|
Original task: ${task.instructions}
|
|
421
|
+
Papilot offers two ways to save accepted changes: Apply to workspace leaves them uncommitted; Commit to manuscript creates one commit containing only this task on the current manuscript branch. Both update the manuscript directly, including for tasks previously saved to a task branch. Do not tell the author to export patches or run Git commands to apply a task. If application conflicts, Papilot automatically asks AI to prepare another review in this task. Ask the author only when the instructions and manuscript context cannot determine the intended content; continue after their answer.
|
|
372
422
|
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.
|
|
373
423
|
${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.
|
|
374
424
|
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.
|
|
375
|
-
|
|
425
|
+
Never invent a bibliography entry or any of its details. Add a .bib entry only for a work you can identify, take its authors, title, year and venue from the published record, and include its doi or arXiv eprint when it has one. Papilot looks up every added or changed entry in Crossref, DBLP and arXiv and shows the author any it cannot confirm. Name the entries you added or changed in your summary.
|
|
426
|
+
${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.'} ${viewPdf ? `When the look of the pages matters, such as line breaks, spacing, figures, tables or displayed formulas, use the ${VIEW_PDF} tool and open the pages it draws with your image viewer to see them as the author does. It compiles the paper, so do not call it for wording alone. ` : ''}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.`;
|
|
376
427
|
}
|
|
377
428
|
private async startRun(run: Run) {
|
|
378
429
|
let task = this.store.task(run.taskId);
|
|
@@ -384,12 +435,23 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
384
435
|
this.store.saveRun({ ...run, status: 'running' });
|
|
385
436
|
this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
|
|
386
437
|
if (!task.threadId || !this.loaded.has(task.threadId)) {
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
438
|
+
// Codex threads get the view_pdf tool when they start, and keep it when resumed.
|
|
439
|
+
const scope = task.mode === 'review' ? [] : await this.reviewScope(task), codex = this.agentOf(task) === 'codex';
|
|
440
|
+
let viewPdf = task.mode !== 'review' && codex && (task.threadId ? Boolean(task.viewPdf) : !this.noViewPdf);
|
|
441
|
+
const options = (tool: boolean) => ({ cwd: task.worktree, approvalPolicy: task.mode === 'review' ? 'never' : 'on-request', sandbox: task.mode === 'review' ? 'read-only' : 'workspace-write', developerInstructions: this.instructions(task, scope, tool), ...(task.model ? { model: task.model } : {}) });
|
|
442
|
+
let result;
|
|
443
|
+
if (task.threadId) result = await agent.request('thread/resume', { ...options(viewPdf), threadId: task.threadId, excludeTurns: true });
|
|
444
|
+
else {
|
|
445
|
+
try { result = await agent.request('thread/start', viewPdf ? { ...options(true), dynamicTools: [VIEW_PDF_TOOL] } : options(false)); }
|
|
446
|
+
catch (error) {
|
|
447
|
+
// A Codex without the experimental tool API starts its threads without it.
|
|
448
|
+
if (!viewPdf || !(error instanceof RpcResponseError) || !VIEW_PDF_UNSUPPORTED.test(error.message)) throw error;
|
|
449
|
+
this.noViewPdf = true; viewPdf = false;
|
|
450
|
+
result = await agent.request('thread/start', options(false));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
391
453
|
if (this.active.get(task.id)?.id !== run.id) return;
|
|
392
|
-
task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model });
|
|
454
|
+
task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model, ...(task.threadId ? {} : { viewPdf }) });
|
|
393
455
|
this.loaded.add(result.thread.id);
|
|
394
456
|
}
|
|
395
457
|
this.store.updateTask(task.id, { pendingRunId: run.id });
|
|
@@ -453,7 +515,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
453
515
|
if (!task || task.status === 'deleting') return;
|
|
454
516
|
const eventTurn = p.turnId || p.turn?.id;
|
|
455
517
|
if (eventTurn && task.turnId && eventTurn !== task.turnId) return;
|
|
456
|
-
if (method === 'item/agentMessage/delta') this.appendDelta(task, p, 'assistant');
|
|
518
|
+
if (method === 'item/agentMessage/delta') { if (task.mode !== 'review') this.appendDelta(task, p, 'assistant'); }
|
|
457
519
|
else if (method === 'item/commandExecution/outputDelta') this.appendDelta(task, p, 'command');
|
|
458
520
|
else if ((method === 'item/started' || method === 'item/completed') && p.item) {
|
|
459
521
|
const item = p.item, id = `${task.id}:${p.turnId}:${item.id}`;
|
|
@@ -461,11 +523,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
461
523
|
this.flush();
|
|
462
524
|
const previous = this.store.items(task.id).find(i => i.id === id);
|
|
463
525
|
const entry: TimelineItem = { id, taskId: task.id, createdAt: previous?.createdAt || Date.now(), type: item.type, text: item.text || item.command || '', status: item.status || (method === 'item/completed' ? 'completed' : 'inProgress') };
|
|
464
|
-
if (item.type === 'agentMessage') entry.type = 'assistant';
|
|
526
|
+
if (item.type === 'agentMessage') entry.type = task.mode === 'review' ? 'reviewOutput' : 'assistant';
|
|
465
527
|
if (item.type === 'commandExecution') { entry.type = 'command'; entry.detail = String(item.aggregatedOutput || previous?.detail || '').slice(-40000); }
|
|
466
528
|
if (item.type === 'fileChange') { entry.type = 'files'; entry.text = (item.changes || []).map((change: any) => change.path).join('\n'); }
|
|
467
529
|
if (item.type === 'mcpToolCall') { entry.type = 'tool'; entry.text = `${item.server} / ${item.tool}`; }
|
|
468
530
|
if (item.type === 'toolCall') entry.type = 'tool';
|
|
531
|
+
if (item.type === 'dynamicToolCall') {
|
|
532
|
+
if (item.tool !== VIEW_PDF) { entry.type = 'tool'; entry.text = item.tool || ''; }
|
|
533
|
+
else { entry.type = 'pdf'; entry.text = viewedPlaces(item.arguments); }
|
|
534
|
+
if (item.success === false) entry.status = 'failed';
|
|
535
|
+
}
|
|
469
536
|
if (entry.text || entry.detail) this.store.saveItem(entry);
|
|
470
537
|
} else if (method === 'serverRequest/resolved') {
|
|
471
538
|
const buffered = this.earlyRequests.get(task.id);
|
|
@@ -491,7 +558,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
491
558
|
this.store.saveRun({ ...run, status, finishedAt: Date.now() });
|
|
492
559
|
this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
|
|
493
560
|
this.schedule();
|
|
494
|
-
if (status === 'completed')
|
|
561
|
+
if (status === 'completed') {
|
|
562
|
+
if (task.mode === 'review') this.finishAiReview(task, run, eventTurn);
|
|
563
|
+
else await this.publish(task.id, run);
|
|
564
|
+
}
|
|
495
565
|
} else if (method === 'error' && !p.willRetry) {
|
|
496
566
|
if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
|
|
497
567
|
this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
|
|
@@ -499,7 +569,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
499
569
|
}
|
|
500
570
|
private serverRequest(message: any, agent: AgentId = 'codex') {
|
|
501
571
|
const task = this.store.tasks().find(t => t.threadId === message.params?.threadId), runtime = this.agents[agent];
|
|
502
|
-
const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput'];
|
|
572
|
+
const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'item/tool/call'];
|
|
503
573
|
if (!task || this.agentOf(task) !== agent || !this.active.has(task.id) || !allowed.includes(message.method)) { runtime.reject(message.id, 'This request is not supported or its turn is no longer active.'); return; }
|
|
504
574
|
if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; }
|
|
505
575
|
if (!task.turnId && message.params?.turnId) {
|
|
@@ -508,10 +578,50 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
508
578
|
else runtime.reject(message.id, 'Too many pending requests.');
|
|
509
579
|
return;
|
|
510
580
|
}
|
|
581
|
+
if (task.mode === 'review') { runtime.reject(message.id, 'AI review is read-only and cannot request tools or additional permissions.'); return; }
|
|
582
|
+
// Papilot answers its own tools; the author is not asked.
|
|
583
|
+
if (message.method === 'item/tool/call') {
|
|
584
|
+
void this.toolCall(task, message, runtime).catch(error => console.error(`${AGENT_NAMES[agent]} tool:`, errorText(error)));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
511
587
|
const id = randomUUID();
|
|
512
588
|
this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() });
|
|
513
589
|
this.store.updateTask(task.id, { status: 'waiting_input' });
|
|
514
590
|
}
|
|
591
|
+
// The agent looks at typeset pages: the review engine compiles the task
|
|
592
|
+
// worktree and draws them as files, which the agent opens with its own
|
|
593
|
+
// image viewer. Returning the images themselves would put them in front of
|
|
594
|
+
// Codex's code mode as base64 text.
|
|
595
|
+
private async toolCall(task: Task, message: any, runtime: AgentRuntime) {
|
|
596
|
+
const { tool, arguments: args } = message.params || {};
|
|
597
|
+
const agent = this.agentOf(task), run = this.active.get(task.id), epoch = this.connectionEpochs.get(agent);
|
|
598
|
+
const current = () => !this.closing && runtime.ready && this.agents[agent] === runtime
|
|
599
|
+
&& this.connectionEpochs.get(agent) === epoch && run && this.active.get(task.id)?.id === run.id
|
|
600
|
+
&& this.store.task(task.id).turnId === task.turnId;
|
|
601
|
+
const reply = (success: boolean, text: string) => {
|
|
602
|
+
if (!current()) return;
|
|
603
|
+
// A failed transport cannot receive an error reply either. Do not let
|
|
604
|
+
// it escape this detached async call or retry it against a new child.
|
|
605
|
+
try { runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); }
|
|
606
|
+
catch (error) { console.error(`${AGENT_NAMES[agent]} tool reply:`, errorText(error)); }
|
|
607
|
+
};
|
|
608
|
+
if (tool !== VIEW_PDF) { reply(false, `Papilot has no tool named ${tool}.`); return; }
|
|
609
|
+
const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : [];
|
|
610
|
+
try {
|
|
611
|
+
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
612
|
+
if (!current()) return;
|
|
613
|
+
const shown = await client.post<{ pages: { index: number; label: string | null; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>(
|
|
614
|
+
`${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000);
|
|
615
|
+
if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; }
|
|
616
|
+
const compiled = shown.targets[0] === 'full' ? 'the whole paper' : `only ${shown.targets.join(', ')}, so page positions differ from the full PDF while printed page numbers match`;
|
|
617
|
+
reply(true, [
|
|
618
|
+
`${args?.accepted === true ? 'The accepted text' : 'Your worktree'} was compiled (${compiled}).`,
|
|
619
|
+
...shown.pages.map(page => `${page.label ? `Page ${page.label}` : `PDF page ${page.index} (printed page number unavailable)`} shows ${page.shows.join(', ')}: ${page.file}`),
|
|
620
|
+
...shown.missing,
|
|
621
|
+
...(shown.pages.length ? ['Open the page files you need with your image viewer (view_image).'] : []),
|
|
622
|
+
].join('\n'));
|
|
623
|
+
} catch (error) { reply(false, 'Papilot could not show the pages: ' + errorText(error)); }
|
|
624
|
+
}
|
|
515
625
|
answer(id: string, input: { decision?: string; answers?: Record<string, string> }) {
|
|
516
626
|
const request = this.requests.get(id); if (!request) throw new Error('This request has ended or the connection has restarted.');
|
|
517
627
|
if (this.store.task(request.taskId).status === 'deleting') throw new Error('This task is being deleted.');
|
|
@@ -520,6 +630,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
520
630
|
const questions = request.params.questions || [];
|
|
521
631
|
response = { answers: Object.fromEntries(questions.map((q: any) => {
|
|
522
632
|
const answer = input.answers?.[q.id]?.trim(); if (!answer) throw new Error('Answer every question before submitting.');
|
|
633
|
+
// A question without an "Other" choice only takes one of its options.
|
|
634
|
+
if (q.isOther === false && q.options?.length && !q.options.some((option: any) => option.label === answer)) throw new Error('Choose one of the offered answers.');
|
|
523
635
|
return [q.id, { answers: [answer] }];
|
|
524
636
|
})) };
|
|
525
637
|
} else {
|
|
@@ -561,6 +673,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
561
673
|
this.earlyCompletions.delete(taskId);
|
|
562
674
|
this.active.set(taskId, run);
|
|
563
675
|
this.store.updateTask(taskId, { turnId: turn.id, pendingRunId: null, error: null, feedbackCursor: Math.max(task.feedbackCursor, run.feedbackCursor || 0) });
|
|
676
|
+
// Recover the structured review reply when the completion notification was lost.
|
|
677
|
+
if (task.mode === 'review' && turn.status === 'completed') for (const item of turn.items || []) {
|
|
678
|
+
if (item.type === 'agentMessage') await this.notification({ method: 'item/completed', params: { threadId: task.threadId, turnId: turn.id, item } }, this.agentOf(task));
|
|
679
|
+
}
|
|
564
680
|
if (completion) {
|
|
565
681
|
await this.notification(completion, this.agentOf(task));
|
|
566
682
|
} else if (turn.status === 'inProgress' && thread.status?.type !== 'notLoaded') {
|
|
@@ -593,6 +709,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
593
709
|
async reviewWrite<T>(taskId: string, writesFiles: boolean, operation: () => Promise<T>, pending?: Omit<PendingReview, 'previousStatus'>): Promise<T> {
|
|
594
710
|
const task = this.store.task(taskId);
|
|
595
711
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
712
|
+
if (task.mode === 'review') throw new Error('AI review uses manuscript PDF notes, not change decisions.');
|
|
596
713
|
if (!writesFiles) {
|
|
597
714
|
this.commentWrites.set(taskId, (this.commentWrites.get(taskId) || 0) + 1);
|
|
598
715
|
try { return await operation(); }
|
|
@@ -621,6 +738,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
621
738
|
// edits break the build, the agent gets the error in a new turn, once.
|
|
622
739
|
publish(taskId: string, after?: Run): Promise<void> {
|
|
623
740
|
const task = this.store.task(taskId), automatic = Boolean(after);
|
|
741
|
+
if (task.mode === 'review') throw new Error('AI review creates PDF notes only. It cannot publish manuscript changes.');
|
|
624
742
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
625
743
|
if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
|
|
626
744
|
if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
|
|
@@ -661,10 +779,33 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
661
779
|
await this.refresh(taskId);
|
|
662
780
|
} catch (e) { this.store.updateTask(taskId, { status: 'failed', error: errorText(e) }); }
|
|
663
781
|
}
|
|
782
|
+
private finishAiReview(task: Task, run: Run, turnId: string) {
|
|
783
|
+
try {
|
|
784
|
+
const snapshot = this.store.reviewInput(task.id);
|
|
785
|
+
const reply = this.store.items(task.id).filter(item => item.type === 'reviewOutput' && item.id.startsWith(`${task.id}:${turnId}:`)).at(-1);
|
|
786
|
+
const result = parseAiReview(reply?.text || '', snapshot);
|
|
787
|
+
this.store.transaction(() => {
|
|
788
|
+
result.notes.forEach((note, index) => {
|
|
789
|
+
const id = `${run.id}-${index}`;
|
|
790
|
+
// Deterministic IDs make completion replay safe, including deleted/sent notes.
|
|
791
|
+
try { this.store.annotation(task.projectId, id); return; } catch { /* New finding. */ }
|
|
792
|
+
this.store.saveAnnotation({ ...note, id, projectId: task.projectId, key: snapshot.key, source: null, createdAt: Date.now(), aiReview: { taskId: task.id, agent: this.agentOf(task) } });
|
|
793
|
+
});
|
|
794
|
+
this.store.saveRun({ ...run, status: 'completed', finishedAt: Date.now(), reviewRound: null });
|
|
795
|
+
if (reply) this.store.saveItem({ ...reply, type: 'assistant', text: result.summary });
|
|
796
|
+
const review = this.store.task(task.id).aiReview;
|
|
797
|
+
this.store.updateTask(task.id, { status: this.store.runs().some(r => r.taskId === task.id && r.status === 'queued') ? 'queued' : 'reviewing', error: null, aiReview: { ...review, key: snapshot.key, notes: (review?.notes || 0) + result.notes.length }, review: { state: 'done', round: null, counts: {}, unsent: 0, comments: 0, files: [] } });
|
|
798
|
+
});
|
|
799
|
+
} catch (error) {
|
|
800
|
+
this.store.updateTask(task.id, { status: 'failed', error: errorText(error) });
|
|
801
|
+
}
|
|
802
|
+
this.schedule();
|
|
803
|
+
}
|
|
664
804
|
async refresh(taskId: string) {
|
|
665
805
|
let release: (() => void) | undefined;
|
|
666
806
|
try {
|
|
667
807
|
const task = this.store.task(taskId);
|
|
808
|
+
if (task.mode === 'review') return;
|
|
668
809
|
release = this.enterProjectRequest(task.projectId);
|
|
669
810
|
if (task.status === 'deleting') return;
|
|
670
811
|
const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
|
|
@@ -672,9 +813,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
672
813
|
const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
|
|
673
814
|
const deferred = summary.deferred === true || task.deferred;
|
|
674
815
|
const delivery = summary.delivery ?? task.delivery;
|
|
675
|
-
const
|
|
816
|
+
const reconciliation = summary.reconciliation ?? null;
|
|
817
|
+
const files = await acceptedFiles(project.path, task.name, deferred ? reconciliation?.base ?? (delivery && delivery.mode !== 'branch' ? delivery.accepted : undefined) : task.committed?.accepted);
|
|
676
818
|
const current = this.store.task(taskId);
|
|
677
|
-
if (current.status !== 'deleting') this.store.updateTask(taskId, { deferred, delivery, review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
|
|
819
|
+
if (current.status !== 'deleting') this.store.updateTask(taskId, { deferred, delivery, reconciliation, review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
|
|
820
|
+
// Recover an application interrupted after the engine prepared its
|
|
821
|
+
// proposal but before Papilot persisted the agent run.
|
|
822
|
+
if (reconciliation?.ready && (summary.round ?? 0) <= reconciliation.round && current.status === 'reviewing' && !this.committing.has(task.projectId) && !this.reviewWrites.has(taskId)
|
|
823
|
+
&& !current.pendingReview && !this.store.runByKey('merge-review-' + reconciliation.id)
|
|
824
|
+
&& !this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
|
|
825
|
+
await this.queueMergeSuggestion(taskId, reconciliation);
|
|
678
826
|
} catch { /* The task remains visible even if its project is temporarily unavailable. */ }
|
|
679
827
|
finally { release?.(); }
|
|
680
828
|
}
|
|
@@ -693,7 +841,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
693
841
|
private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
|
|
694
842
|
const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
|
|
695
843
|
const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
|
|
696
|
-
if (!id) throw new Error(`Sign in to
|
|
844
|
+
if (!id) throw new Error(`Sign in to an agent, such as Codex, to ${task}.`);
|
|
697
845
|
const models = this.runtimes[id].models;
|
|
698
846
|
return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
|
|
699
847
|
}
|
|
@@ -707,6 +855,14 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
707
855
|
// reply is returned and no chat shows it.
|
|
708
856
|
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 }) {
|
|
709
857
|
if (this.closing) throw new Error('The server is shutting down.');
|
|
858
|
+
// A read-only helper (a title, a commit message) has everything in its
|
|
859
|
+
// prompt. It runs in an empty folder, so an agent that does not enforce a
|
|
860
|
+
// read-only sandbox (Claude, Cursor, the ACP agents) cannot change the paper.
|
|
861
|
+
const scratch = options.sandbox === 'read-only' ? fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-helper-')) : null;
|
|
862
|
+
try { return await this.helperTurn(id, { ...options, cwd: scratch || options.cwd }); }
|
|
863
|
+
finally { if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
864
|
+
}
|
|
865
|
+
private async helperTurn(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
|
|
710
866
|
const agent = this.agents[id], name = AGENT_NAMES[id];
|
|
711
867
|
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 } : {}) }, options.timeout);
|
|
712
868
|
if (this.closing) throw new Error('The server is shutting down.');
|
|
@@ -804,7 +960,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
804
960
|
try {
|
|
805
961
|
const task = this.store.task(taskId), files = task.review?.files || [];
|
|
806
962
|
if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
|
|
807
|
-
if (task.deferred)
|
|
963
|
+
if (task.deferred) return;
|
|
808
964
|
const project = this.store.project(task.projectId), git = await gitStatus(project.path);
|
|
809
965
|
const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
|
|
810
966
|
if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
|
|
@@ -812,15 +968,17 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
812
968
|
}
|
|
813
969
|
private async taskCommitChanges(task: Task) {
|
|
814
970
|
const root = this.store.project(task.projectId).path;
|
|
815
|
-
const
|
|
971
|
+
const delivered = task.reconciliation?.base ?? (task.delivery?.mode !== 'branch' ? task.delivery?.accepted : undefined);
|
|
972
|
+
const before = delivered ?? `refs/latex-review/${task.name}/base`;
|
|
816
973
|
const after = `refs/latex-review/${task.name}/accepted`;
|
|
817
|
-
const paths = await acceptedFiles(root, task.name,
|
|
974
|
+
const paths = await acceptedFiles(root, task.name, delivered);
|
|
818
975
|
if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
|
|
819
976
|
const { stdout: patch } = await execute('git', ['-C', root, 'diff', '--no-ext-diff', '--no-textconv', before, after, '--'], { maxBuffer: 8 * 1024 * 1024 });
|
|
820
977
|
return { files: paths.map(path => ({ path, status: 'M', from: undefined })), patch: patch.slice(0, 80000), recent: [] as string[] };
|
|
821
978
|
}
|
|
822
979
|
async deliverTask(taskId: string, mode: 'workspace' | 'branch', message = '') {
|
|
823
980
|
const task = this.store.task(taskId), project = this.store.project(task.projectId);
|
|
981
|
+
if (task.mode === 'review') throw new Error('AI review has no manuscript changes to deliver.');
|
|
824
982
|
if (!task.deferred) throw new Error('This task already applies accepted changes to the working directory.');
|
|
825
983
|
if (task.status !== 'reviewing' || this.active.has(taskId) || this.publishing.has(taskId) || task.pendingReview || this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
|
|
826
984
|
throw new Error('Wait for the task and review actions to finish before choosing a destination.');
|
|
@@ -834,6 +992,38 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
834
992
|
} finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); this.schedule(); }
|
|
835
993
|
});
|
|
836
994
|
}
|
|
995
|
+
async applyTask(taskId: string, keepCurrent = false, commit?: { message: string; confirmation?: string }) {
|
|
996
|
+
const task = this.store.task(taskId), project = this.store.project(task.projectId);
|
|
997
|
+
if (task.mode === 'review') throw new Error('AI review has no manuscript changes to apply.');
|
|
998
|
+
if (!task.deferred) throw new Error('Accepted changes are already in the manuscript.');
|
|
999
|
+
if (task.status !== 'reviewing' || this.active.has(taskId) || this.publishing.has(taskId) || task.pendingReview || this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
|
|
1000
|
+
throw new Error('Wait for the task and review actions to finish before applying changes.');
|
|
1001
|
+
const result = await this.exclusiveCommit(project.id, async () => {
|
|
1002
|
+
this.reviewWrites.add(taskId);
|
|
1003
|
+
try {
|
|
1004
|
+
const client = await this.connectReview(project.path);
|
|
1005
|
+
const result = await client.post<{ delivery?: NonNullable<Task['delivery']>; reconciliation?: TaskReconciliation; commitReview?: TaskCommitReview }>(`${taskRoute(task.name)}/apply`, { keepCurrent, mode: commit ? 'commit' : 'workspace', ...(commit ? { message: commit.message, confirmation: commit.confirmation } : {}) });
|
|
1006
|
+
if (result.delivery) this.store.updateTask(taskId, { delivery: result.delivery, reconciliation: null });
|
|
1007
|
+
if (result.reconciliation) this.store.updateTask(taskId, { reconciliation: result.reconciliation });
|
|
1008
|
+
return { ...result, git: await gitStatus(project.path) };
|
|
1009
|
+
} finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); }
|
|
1010
|
+
}).catch(async error => {
|
|
1011
|
+
// A lost response is not a failed application. refresh above checks the
|
|
1012
|
+
// engine's persisted result before we retry or start the suggestion.
|
|
1013
|
+
const confirmed = this.store.task(taskId);
|
|
1014
|
+
if (confirmed.reconciliation?.ready && confirmed.reconciliation.id !== task.reconciliation?.id)
|
|
1015
|
+
return { reconciliation: confirmed.reconciliation, delivery: undefined, commitReview: undefined, git: await gitStatus(project.path) };
|
|
1016
|
+
if (confirmed.delivery && confirmed.delivery.mode !== 'branch' && !confirmed.delivery.pending && (confirmed.delivery.mode !== task.delivery?.mode || confirmed.delivery.accepted !== task.delivery?.accepted))
|
|
1017
|
+
return { delivery: confirmed.delivery, reconciliation: undefined, commitReview: undefined, git: await gitStatus(project.path) };
|
|
1018
|
+
throw error;
|
|
1019
|
+
});
|
|
1020
|
+
if (result.reconciliation?.ready) await this.queueMergeSuggestion(taskId, result.reconciliation);
|
|
1021
|
+
this.schedule();
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
1024
|
+
private queueMergeSuggestion(taskId: string, merge: TaskReconciliation) {
|
|
1025
|
+
return this.enqueue(taskId, 'The manuscript has changed in overlapping passages. Prepare a merge suggestion for me to review; keep the manuscript unchanged.', 'merge-review-' + merge.id, false, { merge });
|
|
1026
|
+
}
|
|
837
1027
|
// Resolves a merge's conflicts in the manuscript checkout itself: the agent
|
|
838
1028
|
// edits only the conflicted files, at the model's default reasoning, and
|
|
839
1029
|
// leaves committing the merge to the author once they have read the diffs.
|
|
@@ -858,10 +1048,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
858
1048
|
async commitTask(taskId: string, message: string, push = false) {
|
|
859
1049
|
const task = this.store.task(taskId);
|
|
860
1050
|
if (task.deferred) {
|
|
861
|
-
if (push) throw new Error('
|
|
862
|
-
const result = await this.
|
|
863
|
-
|
|
864
|
-
return { ...result, commit: result.commit, pushError: undefined as string | undefined };
|
|
1051
|
+
if (push) throw new Error('Commit the task first, then push from Git.');
|
|
1052
|
+
const result = await this.applyTask(taskId, false, { message });
|
|
1053
|
+
return { ...result, commit: result.delivery?.commit, pushError: undefined };
|
|
865
1054
|
}
|
|
866
1055
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
867
1056
|
if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
|
|
@@ -872,11 +1061,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
872
1061
|
const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
|
|
873
1062
|
if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
|
|
874
1063
|
const hash = await gitCommit(project.path, message, paths);
|
|
875
|
-
|
|
876
|
-
|
|
1064
|
+
const committed = { hash, accepted: accepted!, at: Date.now() };
|
|
1065
|
+
this.store.updateTask(taskId, { committed });
|
|
877
1066
|
// The commit stands even when the push fails; the author can push again.
|
|
878
1067
|
let pushError: string | undefined;
|
|
879
|
-
if (push) try {
|
|
1068
|
+
if (push) try {
|
|
1069
|
+
await gitRemote(project.path, 'push');
|
|
1070
|
+
this.store.updateTask(taskId, { committed: { ...committed, pushed: true } });
|
|
1071
|
+
} catch (error) { pushError = errorText(error); }
|
|
1072
|
+
await this.refresh(taskId);
|
|
880
1073
|
return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
|
|
881
1074
|
});
|
|
882
1075
|
}
|
|
@@ -893,11 +1086,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
893
1086
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
894
1087
|
return this.store.updateTask(taskId, { title, titleSource: 'manual', titleRevision: (task.titleRevision || 0) + 1, titleGeneration: null });
|
|
895
1088
|
}
|
|
896
|
-
|
|
1089
|
+
// Older tabs send finished=true when navigating away. Ignore those requests;
|
|
1090
|
+
// only an explicit archive action should change the task list.
|
|
1091
|
+
archiveTask(taskId: string, archived: boolean, finished = false) {
|
|
897
1092
|
if (typeof archived !== 'boolean') throw new Error('Choose whether to archive or restore the task.');
|
|
898
1093
|
const task = this.store.task(taskId);
|
|
899
1094
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
900
|
-
if (Boolean(task.archivedAt) === archived) return task;
|
|
1095
|
+
if (Boolean(task.archivedAt) === archived || (archived && finished)) return task;
|
|
901
1096
|
// Archiving only organizes the list; the conversation, review and any run remain intact.
|
|
902
1097
|
return this.store.updateTask(taskId, { archivedAt: archived ? Date.now() : null });
|
|
903
1098
|
}
|