papilot 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/bin/doctor.mjs +11 -0
- package/dist/assets/index-BB0zC0wj.css +1 -0
- package/dist/assets/index-DTScOfwx.js +206 -0
- package/dist/assets/{math-Ebyrobf9.js → math-C3k2egGJ.js} +7 -7
- package/dist/assets/{pdf-view-BhJZr4qw.js → pdf-view-Df0q8hSk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/commit.ts +23 -0
- package/src/conversation.ts +12 -1
- package/src/notes.ts +1 -1
- package/src/server/access.ts +170 -0
- package/src/server/accounts.ts +226 -0
- package/src/server/app.ts +298 -0
- package/src/server/frontend.ts +1 -1
- package/src/server/main.ts +21 -194
- package/src/server/manuscript.ts +95 -14
- package/src/server/paths.ts +4 -0
- package/src/server/review.ts +2 -1
- package/src/server/security.ts +20 -0
- package/src/server/store.ts +5 -0
- package/src/server/workspace.ts +192 -25
- package/src/shared.ts +23 -5
- package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
- package/vendor/latex-review/dist/web/assets/{index-DqkrTw0t.js → index-xLeqYBJt.js} +3 -3
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/render/pdf.ts +35 -1
- package/vendor/latex-review/src/web/review.tsx +18 -8
- package/vendor/latex-review/src/web/style.css +24 -0
- package/vendor/latex-review/src/web/zh-CN.ts +2 -0
- package/dist/assets/index-CqhlgnOu.css +0 -1
- package/dist/assets/index-Czf1eCno.js +0 -185
- package/vendor/latex-review/dist/web/assets/index-Db7pq4Cj.css +0 -1
package/src/server/manuscript.ts
CHANGED
|
@@ -7,8 +7,9 @@ import path from 'node:path';
|
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
import { ENGINE } from './paths.ts';
|
|
10
|
-
import type { Annotation, GitStatus, ManuscriptBuild } from '../shared.ts';
|
|
10
|
+
import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
|
|
11
11
|
import { notesInstructions } from '../notes.ts';
|
|
12
|
+
import { ignorePattern } from '../commit.ts';
|
|
12
13
|
|
|
13
14
|
type Rect = [number, number, number, number];
|
|
14
15
|
|
|
@@ -57,8 +58,26 @@ export async function gitStatus(root: string): Promise<GitStatus> {
|
|
|
57
58
|
git(root, ['remote']).catch(() => ''),
|
|
58
59
|
]);
|
|
59
60
|
const [hash, subject, time] = head.trim().split('\0');
|
|
60
|
-
|
|
61
|
+
const parsed = parseStatus(status, numstat), dir = gitDir(root);
|
|
62
|
+
// A merge in progress names the files whose conflict markers remain.
|
|
63
|
+
const merging = dir && fs.existsSync(path.join(dir, 'MERGE_HEAD'))
|
|
64
|
+
? { message: readText(path.join(dir, 'MERGE_MSG')).split('\n')[0] || 'Merge', conflicts: parsed.files.filter(file => file.status === 'U' && conflictMarkers(root, file.path)).map(file => file.path) }
|
|
65
|
+
: null;
|
|
66
|
+
return { ...parsed, head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()), merging };
|
|
61
67
|
}
|
|
68
|
+
const readText = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
|
|
69
|
+
// The repository's Git directory: .git, or where a .git file points.
|
|
70
|
+
function gitDir(root: string) {
|
|
71
|
+
try {
|
|
72
|
+
const dot = path.join(root, '.git');
|
|
73
|
+
return fs.statSync(dot).isFile() ? path.resolve(root, readText(dot).replace(/^gitdir:\s*/, '').trim()) : dot;
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
export function mergeInProgress(root: string) {
|
|
77
|
+
const dir = gitDir(root);
|
|
78
|
+
return Boolean(dir && fs.existsSync(path.join(dir, 'MERGE_HEAD')));
|
|
79
|
+
}
|
|
80
|
+
export const conflictMarkers = (root: string, file: string) => /^(?:<{7}|>{7})(?: |$)/m.test(readText(path.join(root, file)));
|
|
62
81
|
|
|
63
82
|
// Paths from the browser are matched against the changed files, and Git reads
|
|
64
83
|
// them literally, so a name cannot act as a pathspec pattern.
|
|
@@ -69,17 +88,51 @@ const DIFF_LIMIT = 400_000;
|
|
|
69
88
|
export async function gitDiff(root: string, file: string) {
|
|
70
89
|
const entry = (await gitStatus(root)).files.find(item => item.path === file);
|
|
71
90
|
if (!entry) throw Object.assign(new Error('That file has no uncommitted changes.'), { statusCode: 404 });
|
|
72
|
-
const diff =
|
|
73
|
-
// `git diff --no-index` exits with 1 when the files differ.
|
|
74
|
-
? await exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
|
|
75
|
-
: await git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), file], literal);
|
|
91
|
+
const diff = await fileDiff(root, entry);
|
|
76
92
|
return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
|
|
77
93
|
}
|
|
94
|
+
function fileDiff(root: string, entry: GitFile) {
|
|
95
|
+
return entry.status === '?'
|
|
96
|
+
// `git diff --no-index` exits with 1 when the files differ.
|
|
97
|
+
? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
|
|
98
|
+
: git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What a commit message is written from: the chosen files' diffs, each
|
|
102
|
+
// shortened so that a large change still fits, and the repository's recent
|
|
103
|
+
// commit subjects, whose style and language the message follows.
|
|
104
|
+
export async function commitChanges(root: string, paths: string[]) {
|
|
105
|
+
const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
|
|
106
|
+
if (!files.length) throw new Error('Select at least one changed file.');
|
|
107
|
+
let patch = '';
|
|
108
|
+
for (const [index, file] of files.entries()) {
|
|
109
|
+
if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
|
|
110
|
+
const diff = await fileDiff(root, file);
|
|
111
|
+
patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
|
|
112
|
+
}
|
|
113
|
+
const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
|
|
114
|
+
return { files, patch, recent };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Adds the patterns for the chosen untracked build files to the repository's
|
|
118
|
+
// .gitignore, one per kind of file and each only once, so they leave the list
|
|
119
|
+
// of changes. The .gitignore is then a change to commit like any other.
|
|
120
|
+
export async function gitIgnore(root: string, paths: string[]) {
|
|
121
|
+
const patterns = [...new Set((await gitStatus(root)).files.filter(item => item.status === '?' && paths.includes(item.path)).map(item => ignorePattern(item.path)).filter((pattern): pattern is string => Boolean(pattern)))];
|
|
122
|
+
if (!patterns.length) throw new Error('None of the chosen files is build output that can be ignored.');
|
|
123
|
+
const file = path.join(root, '.gitignore');
|
|
124
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
125
|
+
const present = new Set(current.split(/\r?\n/).map(line => line.trim()));
|
|
126
|
+
const missing = patterns.filter(pattern => !present.has(pattern) && !present.has('/' + pattern));
|
|
127
|
+
if (missing.length) fs.writeFileSync(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${current.trim() ? '\n' : ''}# Build output\n${missing.join('\n')}\n`);
|
|
128
|
+
return gitStatus(root);
|
|
129
|
+
}
|
|
78
130
|
|
|
79
131
|
// Commit exactly the chosen files as they are on disk. Changes staged
|
|
80
132
|
// elsewhere for other files stay staged and out of this commit.
|
|
81
133
|
export async function gitCommit(root: string, message: string, paths: string[]) {
|
|
82
|
-
const
|
|
134
|
+
const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
|
|
135
|
+
if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
|
|
83
136
|
if (!files.length) throw new Error('Select at least one changed file to commit.');
|
|
84
137
|
if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
|
|
85
138
|
// A rename is already staged, so only its new path is added; the commit
|
|
@@ -91,13 +144,17 @@ export async function gitCommit(root: string, message: string, paths: string[])
|
|
|
91
144
|
|
|
92
145
|
// Fetch, fast-forward pull and push use the host's Git credentials. Nothing
|
|
93
146
|
// may prompt, so a missing credential fails instead of hanging.
|
|
94
|
-
export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push') {
|
|
147
|
+
export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'merge' | 'push') {
|
|
95
148
|
const status = await gitStatus(root);
|
|
96
149
|
if (!status.remote) throw new Error('This repository has no remote.');
|
|
150
|
+
if (status.merging && action !== 'fetch') throw new Error('Finish or abort the merge first.');
|
|
97
151
|
if (action === 'fetch') await gitAction(root, ['fetch', '--prune'], 120_000);
|
|
98
|
-
else if (action === 'pull') {
|
|
152
|
+
else if (action === 'pull' || action === 'merge') {
|
|
99
153
|
if (!status.upstream) throw new Error('This branch does not track a remote branch yet.');
|
|
100
|
-
|
|
154
|
+
// Pull only fast-forwards. Merge also joins a branch that has diverged
|
|
155
|
+
// from its upstream; conflicts leave the merge in progress to resolve.
|
|
156
|
+
if (action === 'pull') await gitAction(root, ['pull', '--ff-only'], 120_000);
|
|
157
|
+
else await gitAction(root, ['pull', '--no-rebase', '--ff', '--no-edit'], 120_000).catch(error => { if (!mergeInProgress(root)) throw error; });
|
|
101
158
|
} else if (status.upstream) await gitAction(root, ['push'], 120_000);
|
|
102
159
|
else {
|
|
103
160
|
if (!status.branch) throw new Error('Check out a branch before pushing.');
|
|
@@ -106,6 +163,30 @@ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push')
|
|
|
106
163
|
}
|
|
107
164
|
}
|
|
108
165
|
|
|
166
|
+
// A merge in progress ends with its commit once no conflict markers remain,
|
|
167
|
+
// or is abandoned, restoring the files as they were before it.
|
|
168
|
+
export async function gitFinishMerge(root: string) {
|
|
169
|
+
const status = await gitStatus(root);
|
|
170
|
+
if (!status.merging) throw new Error('No merge is in progress.');
|
|
171
|
+
if (status.merging.conflicts.length) throw new Error(`Resolve the conflicts in ${status.merging.conflicts.join(', ')} first.`);
|
|
172
|
+
const resolved = status.files.filter(item => item.status === 'U').map(item => item.path);
|
|
173
|
+
if (resolved.length) await gitAction(root, ['add', '-A', '--', ...resolved]);
|
|
174
|
+
await gitAction(root, ['commit', '--no-edit']);
|
|
175
|
+
return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
|
|
176
|
+
}
|
|
177
|
+
export async function gitAbortMerge(root: string) {
|
|
178
|
+
if (!mergeInProgress(root)) throw new Error('No merge is in progress.');
|
|
179
|
+
await gitAction(root, ['merge', '--abort']);
|
|
180
|
+
}
|
|
181
|
+
// What an agent needs to resolve a merge's conflicts: the files with markers
|
|
182
|
+
// and the commits on each side.
|
|
183
|
+
export async function mergeConflicts(root: string) {
|
|
184
|
+
const status = await gitStatus(root);
|
|
185
|
+
if (!status.merging) throw new Error('No merge is in progress.');
|
|
186
|
+
const subjects = async (range: string) => (await git(root, ['log', '-12', '--format=%s', range]).catch(() => '')).split('\n').filter(Boolean);
|
|
187
|
+
return { files: status.merging.conflicts, branch: status.branch || 'HEAD', upstream: status.upstream || 'the remote branch', ours: await subjects('MERGE_HEAD..HEAD'), theirs: await subjects('HEAD..MERGE_HEAD') };
|
|
188
|
+
}
|
|
189
|
+
|
|
109
190
|
// Git's own message, without the usage noise, becomes the error the user sees.
|
|
110
191
|
async function gitAction(root: string, args: string[], timeout = 60_000) {
|
|
111
192
|
try { return (await exec('git', args, { cwd: root, timeout, env: { ...process.env, ...literal, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 64 * 1024 * 1024 })).stdout; }
|
|
@@ -129,13 +210,13 @@ export async function workingTree(root: string) {
|
|
|
129
210
|
}
|
|
130
211
|
|
|
131
212
|
async function engine() {
|
|
132
|
-
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
|
|
213
|
+
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
|
|
133
214
|
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
134
215
|
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
135
216
|
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
136
217
|
import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
|
|
137
218
|
]);
|
|
138
|
-
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
|
|
219
|
+
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
|
|
139
220
|
}
|
|
140
221
|
|
|
141
222
|
// `synctex edit` maps a PDF position (big points from the top-left corner) to
|
|
@@ -212,7 +293,7 @@ export function sourceExcerpt(file: string, [start, end]: [number, number], limi
|
|
|
212
293
|
// the page's top left. pdf.js lays a whole line out in a stand-in font and
|
|
213
294
|
// drifts from the printed words mid-line; per-word runs cannot drift.
|
|
214
295
|
export async function pdfWords(pdf: string, page: number) {
|
|
215
|
-
const { PdfDocument } = await engine();
|
|
296
|
+
const { PdfDocument, walkText } = await engine();
|
|
216
297
|
const document = new PdfDocument(pdf);
|
|
217
298
|
try {
|
|
218
299
|
if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
|
|
@@ -220,7 +301,7 @@ export async function pdfWords(pdf: string, page: number) {
|
|
|
220
301
|
const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
|
|
221
302
|
let word: (typeof words)[number] | null = null;
|
|
222
303
|
const text = raw.toStructuredText('preserve-whitespace');
|
|
223
|
-
text
|
|
304
|
+
walkText(text, {
|
|
224
305
|
beginLine: () => { word = null; },
|
|
225
306
|
onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
|
|
226
307
|
if (/\s/u.test(c)) { word = null; return; }
|
package/src/server/paths.ts
CHANGED
|
@@ -8,4 +8,8 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa
|
|
|
8
8
|
export const PORT = Number(process.env.PAPILOT_PORT || 7274);
|
|
9
9
|
export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
|
|
10
10
|
export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
|
|
11
|
+
// The public URL where invited people sign in with passkeys, and the local
|
|
12
|
+
// port its proxy (such as Tailscale Funnel) forwards to.
|
|
13
|
+
export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
|
|
14
|
+
export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
|
|
11
15
|
export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
|
package/src/server/review.ts
CHANGED
|
@@ -34,7 +34,8 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
|
|
|
34
34
|
else if (task.status === 'building') decisionLock = 'Wait for the PDF build before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
35
35
|
else if (['running', 'waiting_input', 'queued'].includes(task.status)) decisionLock = 'Pause this task in Papilot before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
36
36
|
else if (task.pendingReview) decisionLock = 'Wait for the current review action before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
37
|
-
|
|
37
|
+
// Changes the task has committed are in the manuscript too.
|
|
38
|
+
return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed ? 1 : 0) } } : {}) };
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function allowedReviewRoute(route: string, method: string): boolean {
|
package/src/server/security.ts
CHANGED
|
@@ -13,3 +13,23 @@ export function authorize(headers: IncomingHttpHeaders, remoteAddress: string |
|
|
|
13
13
|
if (headers.origin && headers.origin !== publicOrigin) return 'The request origin does not match.';
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
// The public sign-in URL needs its own port, an exact HTTPS origin (plain HTTP
|
|
18
|
+
// only on localhost, for testing), and must not be the tailnet endpoint, whose
|
|
19
|
+
// requests are trusted as the owner.
|
|
20
|
+
export function accountProblem(accountOrigin: string, accountPort: number, port: number, publicOrigin: string): string | null {
|
|
21
|
+
if (!accountOrigin) return null;
|
|
22
|
+
let url: URL;
|
|
23
|
+
try { url = new URL(accountOrigin); } catch { return `PAPILOT_ACCOUNT_ORIGIN is not a URL: ${accountOrigin}`; }
|
|
24
|
+
if (url.origin !== accountOrigin) return `PAPILOT_ACCOUNT_ORIGIN must be an origin without a path, such as ${url.origin}.`;
|
|
25
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) return 'PAPILOT_ACCOUNT_ORIGIN must use https://.';
|
|
26
|
+
if (!Number.isInteger(accountPort) || accountPort < 1 || accountPort > 65535 || accountPort === port) return `PAPILOT_ACCOUNT_PORT must be a free port other than ${port}.`;
|
|
27
|
+
if (publicOrigin && new URL(publicOrigin).host === url.host) return 'PAPILOT_ACCOUNT_ORIGIN must differ from PAPILOT_PUBLIC_ORIGIN, the tailnet address; use another port, such as :10000.';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// HSTS on the public address covers every port of its host name, so it is
|
|
31
|
+
// left off when the tailnet endpoint on that host uses plain HTTP.
|
|
32
|
+
export function accountHsts(accountOrigin: string, publicOrigin: string) {
|
|
33
|
+
const account = new URL(accountOrigin);
|
|
34
|
+
return account.protocol === 'https:' && !(publicOrigin.startsWith('http:') && new URL(publicOrigin).hostname === account.hostname);
|
|
35
|
+
}
|
package/src/server/store.ts
CHANGED
|
@@ -17,6 +17,11 @@ export class Store extends EventEmitter {
|
|
|
17
17
|
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
|
|
18
18
|
CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
|
|
19
19
|
CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
|
|
20
25
|
CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
|
|
21
26
|
CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
|
|
22
27
|
}
|
package/src/server/workspace.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import fs from 'node:fs';
|
|
@@ -10,8 +10,10 @@ import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from '.
|
|
|
10
10
|
import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
|
|
11
11
|
import { VERSION } from './paths.ts';
|
|
12
12
|
import { findSkill, type SkillFile } from './skills.ts';
|
|
13
|
-
import { reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
|
|
14
|
-
import
|
|
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, Author } from '../shared.ts';
|
|
15
17
|
|
|
16
18
|
const execute = promisify(execFile);
|
|
17
19
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -35,8 +37,20 @@ export function accountUsage(response: any): Usage | null {
|
|
|
35
37
|
return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, credits, reached: Boolean(limits.rateLimitReachedType) || response.ordinaryUsageAllowed === false };
|
|
36
38
|
}
|
|
37
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-';
|
|
38
44
|
|
|
39
45
|
const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
|
|
46
|
+
// The small model an agent offers for short text such as a commit message,
|
|
47
|
+
// by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
|
|
48
|
+
const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
|
|
49
|
+
export const lightModel = (models: AgentModel[]) => models.find(model => LIGHT.test(model.id) || LIGHT.test(model.name));
|
|
50
|
+
// A commit message's agent sees the changes in its prompt and needs no tools.
|
|
51
|
+
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.';
|
|
52
|
+
// A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
|
|
53
|
+
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.';
|
|
40
54
|
const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
|
|
41
55
|
|
|
42
56
|
export class Workspace extends EventEmitter {
|
|
@@ -49,6 +63,8 @@ export class Workspace extends EventEmitter {
|
|
|
49
63
|
private loaded = new Set<string>();
|
|
50
64
|
private preparing = new Set<string>();
|
|
51
65
|
private reviewWrites = new Set<string>();
|
|
66
|
+
// Papers with a commit in progress: no review write may change their files meanwhile.
|
|
67
|
+
private committing = new Set<string>();
|
|
52
68
|
private commentWrites = new Map<string, number>();
|
|
53
69
|
private deletions = new Set<string>();
|
|
54
70
|
private publishing = new Set<string>();
|
|
@@ -60,6 +76,9 @@ export class Workspace extends EventEmitter {
|
|
|
60
76
|
private flushTimer: NodeJS.Timeout | null = null;
|
|
61
77
|
private connecting: Promise<void> | null = null;
|
|
62
78
|
private usageTimers = new Map<AgentId, NodeJS.Timeout>();
|
|
79
|
+
// Commit messages by agent, model and prompt, kept or still being written,
|
|
80
|
+
// so one written ahead for a finished review is there when the dialog opens.
|
|
81
|
+
private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
|
|
63
82
|
constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
|
|
64
83
|
super();
|
|
65
84
|
this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
|
|
@@ -125,10 +144,12 @@ export class Workspace extends EventEmitter {
|
|
|
125
144
|
this.store.saveProject(project); return project;
|
|
126
145
|
}
|
|
127
146
|
// A task can start from a skill; its instructions are then the author's own words, possibly none.
|
|
128
|
-
async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
|
|
147
|
+
async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string; author?: Author }) {
|
|
129
148
|
if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
|
|
130
149
|
if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
|
|
131
|
-
|
|
150
|
+
// A retried request returns its task; an ID from another paper is refused.
|
|
151
|
+
const existing = this.store.tasks().find(t => t.id === input.id);
|
|
152
|
+
if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
|
|
132
153
|
if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
|
|
133
154
|
this.preparing.add(input.id);
|
|
134
155
|
try {
|
|
@@ -141,13 +162,14 @@ export class Workspace extends EventEmitter {
|
|
|
141
162
|
worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
|
|
142
163
|
createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
|
|
143
164
|
this.store.saveTask(task);
|
|
144
|
-
await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
|
|
165
|
+
await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, author: input.author });
|
|
145
166
|
return this.store.task(task.id);
|
|
146
167
|
} finally { this.preparing.delete(input.id); }
|
|
147
168
|
}
|
|
148
|
-
// The author's message, a skill run with or without one,
|
|
149
|
-
// feedback alone
|
|
150
|
-
|
|
169
|
+
// The author's message, a skill run with or without one, the review
|
|
170
|
+
// feedback alone, or a build the agent broke. The chat keeps what happened;
|
|
171
|
+
// the agent gets the prompt.
|
|
172
|
+
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
|
|
151
173
|
const task = this.store.task(taskId);
|
|
152
174
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
153
175
|
const existing = this.store.runByKey(requestKey);
|
|
@@ -155,7 +177,7 @@ export class Workspace extends EventEmitter {
|
|
|
155
177
|
if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
|
|
156
178
|
const pauseVersion = task.pauseVersion || 0;
|
|
157
179
|
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.');
|
|
158
|
-
let prompt = options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
|
|
180
|
+
let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
|
|
159
181
|
let summary: FeedbackSummary | undefined;
|
|
160
182
|
if (includeFeedback) {
|
|
161
183
|
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
@@ -166,7 +188,7 @@ export class Workspace extends EventEmitter {
|
|
|
166
188
|
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.';
|
|
167
189
|
}
|
|
168
190
|
const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
|
|
169
|
-
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}) };
|
|
191
|
+
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 } : {}), ...(options.author ? { author: options.author } : {}) };
|
|
170
192
|
const saved = this.store.transaction(() => {
|
|
171
193
|
// Feedback loading yields to other requests. Recheck identity, queue, and
|
|
172
194
|
// task status together before committing; none of the earlier reads own a lock.
|
|
@@ -182,7 +204,7 @@ export class Workspace extends EventEmitter {
|
|
|
182
204
|
if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
|
|
183
205
|
this.store.saveRun(run);
|
|
184
206
|
if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
|
|
185
|
-
this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
|
|
207
|
+
this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
|
|
186
208
|
return run;
|
|
187
209
|
});
|
|
188
210
|
this.schedule();
|
|
@@ -205,12 +227,20 @@ export class Workspace extends EventEmitter {
|
|
|
205
227
|
void this.startRun(run).catch(e => this.failRun(run, e));
|
|
206
228
|
}
|
|
207
229
|
}
|
|
208
|
-
|
|
230
|
+
// The paths a task's review covers, or none when it covers the whole worktree.
|
|
231
|
+
private async reviewScope(task: Task): Promise<string[]> {
|
|
232
|
+
try {
|
|
233
|
+
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
234
|
+
const files: string[] = (await client.get(taskRoute(task.name))).scope?.files || [];
|
|
235
|
+
return files.some(file => file === '' || file === '.') ? [] : files;
|
|
236
|
+
} catch { return []; }
|
|
237
|
+
}
|
|
238
|
+
private instructions(task: Task, scope: string[] = []) {
|
|
209
239
|
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.
|
|
210
240
|
Your task is already created. Your editable working directory is ${task.worktree}.
|
|
211
241
|
Original task: ${task.instructions}
|
|
212
242
|
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.
|
|
213
|
-
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.
|
|
243
|
+
${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.
|
|
214
244
|
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.
|
|
215
245
|
${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.`;
|
|
216
246
|
}
|
|
@@ -221,7 +251,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
221
251
|
this.store.saveRun({ ...run, status: 'running' });
|
|
222
252
|
this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
|
|
223
253
|
if (!task.threadId || !this.loaded.has(task.threadId)) {
|
|
224
|
-
const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task), ...(task.model ? { model: task.model } : {}) };
|
|
254
|
+
const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
|
|
225
255
|
const result = task.threadId
|
|
226
256
|
? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
|
|
227
257
|
: await agent.request('thread/start', options);
|
|
@@ -327,7 +357,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
327
357
|
this.store.saveRun({ ...run, status, finishedAt: Date.now() });
|
|
328
358
|
this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
|
|
329
359
|
this.schedule();
|
|
330
|
-
if (status === 'completed') await this.publish(task.id,
|
|
360
|
+
if (status === 'completed') await this.publish(task.id, run);
|
|
331
361
|
} else if (method === 'error' && !p.willRetry) {
|
|
332
362
|
if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
|
|
333
363
|
this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
|
|
@@ -438,6 +468,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
438
468
|
}
|
|
439
469
|
}
|
|
440
470
|
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.');
|
|
471
|
+
if (this.committing.has(task.projectId)) throw new Error('A commit is in progress. Try again when it finishes.');
|
|
472
|
+
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.');
|
|
441
473
|
if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
|
|
442
474
|
this.reviewWrites.add(taskId);
|
|
443
475
|
if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
|
|
@@ -451,17 +483,19 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
451
483
|
}
|
|
452
484
|
finally { this.reviewWrites.delete(taskId); this.schedule(); }
|
|
453
485
|
}
|
|
454
|
-
|
|
455
|
-
|
|
486
|
+
// A build is automatic after the agent's run `after` completes. When its
|
|
487
|
+
// edits break the build, the agent gets the error in a new turn, once.
|
|
488
|
+
publish(taskId: string, after?: Run): Promise<void> {
|
|
489
|
+
const task = this.store.task(taskId), automatic = Boolean(after);
|
|
456
490
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
457
491
|
if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
|
|
458
492
|
if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
|
|
459
493
|
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.');
|
|
460
494
|
this.publishing.add(taskId);
|
|
461
495
|
this.store.updateTask(taskId, { status: 'building', error: null });
|
|
462
|
-
return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
|
|
496
|
+
return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX))).finally(() => { this.publishing.delete(taskId); this.schedule(); });
|
|
463
497
|
}
|
|
464
|
-
private async buildReview(task: Task) {
|
|
498
|
+
private async buildReview(task: Task, sendBack: boolean) {
|
|
465
499
|
const taskId = task.id;
|
|
466
500
|
try {
|
|
467
501
|
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
@@ -472,7 +506,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
472
506
|
const summary = await client.get(taskRoute(task.name));
|
|
473
507
|
this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
|
|
474
508
|
if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
|
|
475
|
-
if (current.status === 'failed')
|
|
509
|
+
if (current.status === 'failed') {
|
|
510
|
+
const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
|
|
511
|
+
// Instructions the author queued meanwhile go first; the build after them sends any error that remains.
|
|
512
|
+
if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + job.job, false, { build: failure }).then(() => true, () => false)) {
|
|
513
|
+
this.store.updateTask(taskId, { status: 'queued', error: null });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
throw new Error([failure.error, ...failure.details].join('\n'));
|
|
517
|
+
}
|
|
476
518
|
await delay(1500);
|
|
477
519
|
}
|
|
478
520
|
this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
|
|
@@ -486,7 +528,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
486
528
|
const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
|
|
487
529
|
const summary = await client.get(taskRoute(task.name));
|
|
488
530
|
const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
|
|
489
|
-
const files = await acceptedFiles(project.path, task.name);
|
|
531
|
+
const files = await acceptedFiles(project.path, task.name, task.committed?.accepted);
|
|
490
532
|
const current = this.store.task(taskId);
|
|
491
533
|
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 } });
|
|
492
534
|
} catch { /* The task remains visible even if its project is temporarily unavailable. */ }
|
|
@@ -500,6 +542,126 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
500
542
|
const items = feedback.feedback || [];
|
|
501
543
|
return { unsent: items.length, comments: items.filter(item => item.kind === 'comment' || item.kind === 'note').length };
|
|
502
544
|
}
|
|
545
|
+
// The agent that writes a commit message or resolves a merge: the one the
|
|
546
|
+
// author chose, or else the first that is signed in, with the chosen model
|
|
547
|
+
// or the default one; light asks for the agent's small model when it has one.
|
|
548
|
+
private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
|
|
549
|
+
const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
|
|
550
|
+
const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
|
|
551
|
+
if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
|
|
552
|
+
const models = this.runtimes[id].models;
|
|
553
|
+
return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
|
|
554
|
+
}
|
|
555
|
+
// One turn on a thread the agent does not keep, outside any task: its
|
|
556
|
+
// reply is returned and no chat shows it.
|
|
557
|
+
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 }) {
|
|
558
|
+
const agent = this.agents[id], name = AGENT_NAMES[id];
|
|
559
|
+
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 } : {}) });
|
|
560
|
+
const threadId = thread.thread.id;
|
|
561
|
+
return new Promise<string>((resolve, reject) => {
|
|
562
|
+
let text = '', turnId = '';
|
|
563
|
+
const listen = (message: any) => {
|
|
564
|
+
const p = message.params || {};
|
|
565
|
+
if (p.threadId !== threadId) return;
|
|
566
|
+
if (message.method === 'item/completed' && p.item?.type === 'agentMessage') text = p.item.text || text;
|
|
567
|
+
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}.`));
|
|
568
|
+
};
|
|
569
|
+
const timer = setTimeout(() => {
|
|
570
|
+
if (turnId) agent.request('turn/interrupt', { threadId, turnId }).catch(() => undefined);
|
|
571
|
+
finish(new Error(`${name} did not ${options.what} in time. Try again.`));
|
|
572
|
+
}, options.timeout);
|
|
573
|
+
const finish = (error: Error | null) => { clearTimeout(timer); agent.off('notification', listen); if (error) reject(error); else resolve(text); };
|
|
574
|
+
agent.on('notification', listen);
|
|
575
|
+
agent.request('turn/start', { threadId, input: [{ type: 'text', text: options.prompt }], cwd: options.cwd, ...(options.effort ? { effort: options.effort } : {}) })
|
|
576
|
+
.then(result => { turnId = result?.turn?.id || ''; }, error => finish(error instanceof Error ? error : new Error(String(error))));
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// Writes a commit message for the chosen manuscript files, like Cursor's
|
|
580
|
+
// generate button: one read-only turn on the agent's small model, with the
|
|
581
|
+
// lightest reasoning it offers. The same changes get the message already
|
|
582
|
+
// written, or being written, for them unless the author asks for a fresh one.
|
|
583
|
+
async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean } = {}) {
|
|
584
|
+
const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
|
|
585
|
+
const { files, patch, recent } = await commitChanges(project.path, paths);
|
|
586
|
+
const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
|
|
587
|
+
const prompt = [
|
|
588
|
+
recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
|
|
589
|
+
tasks.length ? `Papilot tasks whose accepted changes are in these files:\n${tasks.map(title => `- ${title}`).join('\n')}` : '',
|
|
590
|
+
`Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
|
|
591
|
+
`Diff:\n${patch}`,
|
|
592
|
+
].filter(Boolean).join('\n\n');
|
|
593
|
+
const key = createHash('sha256').update(`${id}\0${model?.id ?? ''}\0${prompt}`).digest('hex'), kept = this.messages.get(key);
|
|
594
|
+
if (kept && !choice.fresh) return kept;
|
|
595
|
+
const written = this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' }).then(reply => {
|
|
596
|
+
const message = commitMessageFrom(reply);
|
|
597
|
+
if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
|
|
598
|
+
return { message, agent: id };
|
|
599
|
+
});
|
|
600
|
+
// Only the latest few messages are kept, and never a failed one.
|
|
601
|
+
this.messages.delete(key); this.messages.set(key, written);
|
|
602
|
+
for (const old of this.messages.keys()) { if (this.messages.size <= 20) break; this.messages.delete(old); }
|
|
603
|
+
written.catch(() => { if (this.messages.get(key) === written) this.messages.delete(key); });
|
|
604
|
+
return written;
|
|
605
|
+
}
|
|
606
|
+
// A finished review offers to commit what it accepted, so after the
|
|
607
|
+
// author's last decision its commit message is written ahead.
|
|
608
|
+
async prepareCommitMessage(taskId: string) {
|
|
609
|
+
try {
|
|
610
|
+
const task = this.store.task(taskId), files = task.review?.files || [];
|
|
611
|
+
if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
|
|
612
|
+
const project = this.store.project(task.projectId), git = await gitStatus(project.path);
|
|
613
|
+
const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
|
|
614
|
+
if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
|
|
615
|
+
} catch { /* The commit dialog writes the message when it opens. */ }
|
|
616
|
+
}
|
|
617
|
+
// Resolves a merge's conflicts in the manuscript checkout itself: the agent
|
|
618
|
+
// edits only the conflicted files, at the model's default reasoning, and
|
|
619
|
+
// leaves committing the merge to the author once they have read the diffs.
|
|
620
|
+
async resolveConflicts(projectId: string, choice: { agent?: AgentId; model?: string } = {}) {
|
|
621
|
+
const project = this.store.project(projectId), { id, model } = this.helper(choice, 'resolve conflicts');
|
|
622
|
+
const { files, branch, upstream, ours, theirs } = await mergeConflicts(project.path);
|
|
623
|
+
if (!files.length) throw new Error('No file has conflict markers left. Commit the merge.');
|
|
624
|
+
const list = (items: string[]) => items.length ? items.map(item => `- ${item}`).join('\n') : '- (none listed)';
|
|
625
|
+
const prompt = [
|
|
626
|
+
`${branch} is being merged with ${upstream} in ${project.path}, and these files have conflicts:\n${list(files)}`,
|
|
627
|
+
`Commits on ${branch} since the two diverged:\n${list(ours)}`,
|
|
628
|
+
`Commits from ${upstream} being merged in:\n${list(theirs)}`,
|
|
629
|
+
'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.',
|
|
630
|
+
].join('\n\n');
|
|
631
|
+
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' });
|
|
632
|
+
return { summary: summary.trim(), remaining: files.filter(file => conflictMarkers(project.path, file)), agent: id, git: await gitStatus(project.path) };
|
|
633
|
+
}
|
|
634
|
+
// Commits what the author accepted from this task since its last commit,
|
|
635
|
+
// with the message they confirmed, and pushes it if they asked. Other
|
|
636
|
+
// uncommitted files stay as they are; a file another task also changed is
|
|
637
|
+
// committed as it is on disk.
|
|
638
|
+
async commitTask(taskId: string, message: string, push = false) {
|
|
639
|
+
const task = this.store.task(taskId);
|
|
640
|
+
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
641
|
+
if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
|
|
642
|
+
const project = this.store.project(task.projectId);
|
|
643
|
+
return this.exclusiveCommit(project.id, async () => {
|
|
644
|
+
const accepted = await acceptedCommit(project.path, task.name);
|
|
645
|
+
const files = accepted ? await acceptedFiles(project.path, task.name, this.store.task(taskId).committed?.accepted) : [];
|
|
646
|
+
const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
|
|
647
|
+
if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
|
|
648
|
+
const hash = await gitCommit(project.path, message, paths);
|
|
649
|
+
this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
|
|
650
|
+
void this.refresh(taskId);
|
|
651
|
+
// The commit stands even when the push fails; the author can push again.
|
|
652
|
+
let pushError: string | undefined;
|
|
653
|
+
if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
|
|
654
|
+
return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// A commit reads the working tree that accepting, rejecting and undoing
|
|
658
|
+
// write, so neither starts while the other runs on the same paper.
|
|
659
|
+
async exclusiveCommit<T>(projectId: string, work: () => Promise<T>): Promise<T> {
|
|
660
|
+
if (this.committing.has(projectId)) throw new Error('A commit is already in progress. Wait for it to finish.');
|
|
661
|
+
if ([...this.reviewWrites].some(id => this.store.task(id).projectId === projectId)) throw new Error('Wait for the current review action before committing.');
|
|
662
|
+
this.committing.add(projectId);
|
|
663
|
+
try { return await work(); } finally { this.committing.delete(projectId); }
|
|
664
|
+
}
|
|
503
665
|
renameTask(taskId: string, title: string) {
|
|
504
666
|
if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
|
|
505
667
|
return this.store.updateTask(taskId, { title });
|
|
@@ -544,10 +706,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
544
706
|
|
|
545
707
|
// The review engine records each task's accepted text as a commit beside the
|
|
546
708
|
// one it started from; their difference is what the task put in the
|
|
547
|
-
// manuscript
|
|
548
|
-
|
|
709
|
+
// manuscript, and the difference from the accepted text at the task's last
|
|
710
|
+
// commit is what it has added since. A rename counts as both of its paths.
|
|
711
|
+
export async function acceptedFiles(root: string, name: string, since?: string) {
|
|
549
712
|
try {
|
|
550
|
-
const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
|
|
713
|
+
const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', since || `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
|
|
551
714
|
return stdout.split('\0').filter(Boolean);
|
|
552
715
|
} catch { return []; }
|
|
553
716
|
}
|
|
717
|
+
export async function acceptedCommit(root: string, name: string) {
|
|
718
|
+
try { return (await execute('git', ['-C', root, 'rev-parse', '--verify', '-q', `refs/latex-review/${name}/accepted^{commit}`])).stdout.trim() || undefined; }
|
|
719
|
+
catch { return undefined; }
|
|
720
|
+
}
|