papilot 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/assets/index-BkowXmKr.js +206 -0
- package/dist/assets/index-Dq3s-vhH.css +1 -0
- package/dist/assets/{math-Ebyrobf9.js → math-BTm2hvOz.js} +1 -1
- package/dist/assets/{pdf-view-BhJZr4qw.js → pdf-view-BEn1EztO.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/commit.ts +23 -0
- package/src/conversation.ts +12 -1
- package/src/server/main.ts +32 -3
- package/src/server/manuscript.ts +91 -10
- package/src/server/review.ts +2 -1
- package/src/server/workspace.ts +158 -21
- package/src/shared.ts +9 -3
- 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/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/dist/index.html
CHANGED
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
9
9
|
document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
|
|
10
10
|
})();
|
|
11
|
-
</script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
</script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BkowXmKr.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Dq3s-vhH.css">
|
|
13
13
|
</head><body><div id="root"></div></body></html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"latex",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"src/shared.ts",
|
|
37
37
|
"src/notes.ts",
|
|
38
38
|
"src/conversation.ts",
|
|
39
|
+
"src/commit.ts",
|
|
39
40
|
"skills",
|
|
40
41
|
"dist",
|
|
41
42
|
"scripts/demo.ts",
|
package/src/commit.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What the Git panel leaves out of a commit unless chosen, and offers to add
|
|
2
|
+
// to .gitignore: LaTeX's build output, minted's cache and macOS folder files.
|
|
3
|
+
// Each file maps to the pattern that ignores every file of its kind.
|
|
4
|
+
const BUILD = /\.(aux|log|out|toc|lof|lot|lol|fls|fdb_latexmk|synctex(?:\.gz)?|bbl|blg|bcf|run\.xml|nav|snm|vrb|xdv|dvi|idx|ilg|ind|glo|gls|glg|ist|thm|loa)$/;
|
|
5
|
+
export function ignorePattern(file: string): string | null {
|
|
6
|
+
const name = file.slice(file.lastIndexOf('/') + 1);
|
|
7
|
+
if (name === '.DS_Store') return '.DS_Store';
|
|
8
|
+
if (/(?:^|\/)_minted[^/]*\//.test(file)) return '_minted*/';
|
|
9
|
+
const build = BUILD.exec(name);
|
|
10
|
+
return build ? `*.${build[1]}` : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// An agent's reply as a commit message: without a code fence, a label or
|
|
14
|
+
// quotes, a subject without a final period, and any body after a blank line.
|
|
15
|
+
export function commitMessageFrom(reply: string) {
|
|
16
|
+
const text = reply.trim().replace(/^```[\w-]*\n([\s\S]*?)\n```$/, '$1').trim()
|
|
17
|
+
.replace(/^(?:\*\*)?commit message(?:\*\*)?\s*:\s*(?:\*\*)?[ \t]*/i, '');
|
|
18
|
+
const lines = text.split('\n'), first = lines.findIndex(line => line.trim());
|
|
19
|
+
if (first < 0) return '';
|
|
20
|
+
const subject = lines[first].trim().replace(/^#+\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').replace(/^(["'`])(.+)\1$/, '$2').replace(/\.$/, '').trim();
|
|
21
|
+
const body = lines.slice(first + 1).join('\n').trim();
|
|
22
|
+
return body ? `${subject}\n\n${body}` : subject;
|
|
23
|
+
}
|
package/src/conversation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FeedbackSummary } from './shared.ts';
|
|
1
|
+
import type { BuildFailure, FeedbackSummary } from './shared.ts';
|
|
2
2
|
|
|
3
3
|
// What the agent receives when the author sends review feedback without a
|
|
4
4
|
// message of their own. The chat shows the feedback, never this prompt.
|
|
@@ -6,6 +6,17 @@ export const reviewInstructions = 'Continue this task using the decisions and co
|
|
|
6
6
|
// Earlier versions stored the prompt itself as the author's message.
|
|
7
7
|
export const isFeedbackPrompt = (text: string) => text === reviewInstructions || text === 'Address the comments from this review round. Preserve accepted changes and do not reintroduce rejected edits.';
|
|
8
8
|
|
|
9
|
+
// What the agent receives when its own edits stop the review build. The chat
|
|
10
|
+
// shows the error, never this prompt.
|
|
11
|
+
export function buildFixPrompt(failure: BuildFailure) {
|
|
12
|
+
return [
|
|
13
|
+
'Papilot could not build the PDF review after your last turn:',
|
|
14
|
+
[failure.error, ...failure.details].join('\n'),
|
|
15
|
+
failure.log ? `The compiler log is at ${failure.log}.` : '',
|
|
16
|
+
'Fix this in the task worktree, then end your turn; Papilot builds the review again. Restore any file outside the review\'s scope to how it was when the task started, and mention in your summary any update that file still needs.',
|
|
17
|
+
].filter(Boolean).join('\n\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
// A skill is a named set of instructions; the author's own words, if any, say where and how.
|
|
10
21
|
export function skillPrompt(skill: { name: string; body: string; folder?: string }, request: string) {
|
|
11
22
|
return [
|
package/src/server/main.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION } from './paths.ts';
|
|
|
10
10
|
import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
|
|
11
11
|
import { authorize } from './security.ts';
|
|
12
12
|
import { fullPaperPdf } from './pdf.ts';
|
|
13
|
-
import { Manuscripts, annotationTask, gitCommit, gitDiff, gitRemote, gitStatus } from './manuscript.ts';
|
|
13
|
+
import { Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStatus } from './manuscript.ts';
|
|
14
14
|
import { findSkill, listedSkill, loadSkills } from './skills.ts';
|
|
15
15
|
import type { AgentId } from '../shared.ts';
|
|
16
16
|
|
|
@@ -72,6 +72,11 @@ app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', async request =>
|
|
|
72
72
|
const text = feedback || skill ? optional(b.text, 'Revision instructions') : string(b.text, 'Revision instructions');
|
|
73
73
|
return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback });
|
|
74
74
|
});
|
|
75
|
+
// Commits what the author accepted from this task, with the message they confirmed.
|
|
76
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', request => {
|
|
77
|
+
const task = store.task(request.params.id), b = request.body as any;
|
|
78
|
+
return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
|
|
79
|
+
});
|
|
75
80
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
|
|
76
81
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
|
|
77
82
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', request => {
|
|
@@ -97,11 +102,35 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projec
|
|
|
97
102
|
app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', request => {
|
|
98
103
|
const project = store.project(request.params.id), b = request.body as any;
|
|
99
104
|
const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
|
|
100
|
-
return serialGit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) }));
|
|
105
|
+
return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
|
|
106
|
+
});
|
|
107
|
+
const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
|
|
108
|
+
// An agent writes a commit message for the chosen files; nothing is written.
|
|
109
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', request => {
|
|
110
|
+
const b = request.body as any;
|
|
111
|
+
return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined });
|
|
112
|
+
});
|
|
113
|
+
// A merge in progress is resolved by an agent, committed, or abandoned.
|
|
114
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', request => {
|
|
115
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
116
|
+
return serialGit(project.id, () => workspace.resolveConflicts(project.id, { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined }));
|
|
117
|
+
});
|
|
118
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/finish', request => {
|
|
119
|
+
const project = store.project(request.params.id);
|
|
120
|
+
return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitFinishMerge(project.path), git: await gitStatus(project.path) })));
|
|
121
|
+
});
|
|
122
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/abort', request => {
|
|
123
|
+
const project = store.project(request.params.id);
|
|
124
|
+
return serialGit(project.id, async () => { await gitAbortMerge(project.path); return { git: await gitStatus(project.path) }; });
|
|
125
|
+
});
|
|
126
|
+
// Chosen untracked build files go into .gitignore by kind.
|
|
127
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/ignore', request => {
|
|
128
|
+
const project = store.project(request.params.id);
|
|
129
|
+
return serialGit(project.id, async () => ({ git: await gitIgnore(project.path, paths((request.body as any).paths)) }));
|
|
101
130
|
});
|
|
102
131
|
app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', request => {
|
|
103
132
|
const project = store.project(request.params.id), action = request.params.action;
|
|
104
|
-
if (action !== 'fetch' && action !== 'pull' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
|
|
133
|
+
if (action !== 'fetch' && action !== 'pull' && action !== 'merge' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
|
|
105
134
|
return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
|
|
106
135
|
});
|
|
107
136
|
app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path, true)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
|
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; }
|
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/workspace.ts
CHANGED
|
@@ -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 } 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,16 @@ 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
|
+
// A commit message's agent sees the changes in its prompt and needs no tools.
|
|
47
|
+
const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
|
|
48
|
+
// A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
|
|
49
|
+
const mergeInstructions = 'You resolve Git merge conflicts in the repository of a LaTeX paper. Edit only the files listed as conflicted, and in each one remove every conflict marker (<<<<<<<, ||||||| , =======, >>>>>>>) so that the text reads as one coherent version. Keep the changes from both sides wherever they are compatible; where both sides rewrote the same passage, combine their intent, and where they truly contradict each other, prefer the version that is more complete and correct and say so. Keep the LaTeX valid: balanced environments and braces, labels, references and citations. You may read any file to understand the context. Do not edit other files, and do not run Git commands that change the repository, such as add, commit, merge, checkout, reset, restore or stash: the author reviews the result and commits the merge.';
|
|
40
50
|
const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
|
|
41
51
|
|
|
42
52
|
export class Workspace extends EventEmitter {
|
|
@@ -49,6 +59,8 @@ export class Workspace extends EventEmitter {
|
|
|
49
59
|
private loaded = new Set<string>();
|
|
50
60
|
private preparing = new Set<string>();
|
|
51
61
|
private reviewWrites = new Set<string>();
|
|
62
|
+
// Papers with a commit in progress: no review write may change their files meanwhile.
|
|
63
|
+
private committing = new Set<string>();
|
|
52
64
|
private commentWrites = new Map<string, number>();
|
|
53
65
|
private deletions = new Set<string>();
|
|
54
66
|
private publishing = new Set<string>();
|
|
@@ -145,9 +157,10 @@ export class Workspace extends EventEmitter {
|
|
|
145
157
|
return this.store.task(task.id);
|
|
146
158
|
} finally { this.preparing.delete(input.id); }
|
|
147
159
|
}
|
|
148
|
-
// The author's message, a skill run with or without one,
|
|
149
|
-
// feedback alone
|
|
150
|
-
|
|
160
|
+
// The author's message, a skill run with or without one, the review
|
|
161
|
+
// feedback alone, or a build the agent broke. The chat keeps what happened;
|
|
162
|
+
// the agent gets the prompt.
|
|
163
|
+
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
|
|
151
164
|
const task = this.store.task(taskId);
|
|
152
165
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
153
166
|
const existing = this.store.runByKey(requestKey);
|
|
@@ -155,7 +168,7 @@ export class Workspace extends EventEmitter {
|
|
|
155
168
|
if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
|
|
156
169
|
const pauseVersion = task.pauseVersion || 0;
|
|
157
170
|
if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
|
|
158
|
-
let prompt = options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
|
|
171
|
+
let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
|
|
159
172
|
let summary: FeedbackSummary | undefined;
|
|
160
173
|
if (includeFeedback) {
|
|
161
174
|
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
@@ -166,7 +179,7 @@ export class Workspace extends EventEmitter {
|
|
|
166
179
|
if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
|
|
167
180
|
}
|
|
168
181
|
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 } : {}) };
|
|
182
|
+
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
|
|
170
183
|
const saved = this.store.transaction(() => {
|
|
171
184
|
// Feedback loading yields to other requests. Recheck identity, queue, and
|
|
172
185
|
// task status together before committing; none of the earlier reads own a lock.
|
|
@@ -182,7 +195,7 @@ export class Workspace extends EventEmitter {
|
|
|
182
195
|
if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
|
|
183
196
|
this.store.saveRun(run);
|
|
184
197
|
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 } : {}) });
|
|
198
|
+
this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
|
|
186
199
|
return run;
|
|
187
200
|
});
|
|
188
201
|
this.schedule();
|
|
@@ -205,12 +218,20 @@ export class Workspace extends EventEmitter {
|
|
|
205
218
|
void this.startRun(run).catch(e => this.failRun(run, e));
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
|
-
|
|
221
|
+
// The paths a task's review covers, or none when it covers the whole worktree.
|
|
222
|
+
private async reviewScope(task: Task): Promise<string[]> {
|
|
223
|
+
try {
|
|
224
|
+
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
225
|
+
const files: string[] = (await client.get(taskRoute(task.name))).scope?.files || [];
|
|
226
|
+
return files.some(file => file === '' || file === '.') ? [] : files;
|
|
227
|
+
} catch { return []; }
|
|
228
|
+
}
|
|
229
|
+
private instructions(task: Task, scope: string[] = []) {
|
|
209
230
|
return `You are ${this.agentOf(task) === 'codex' ? 'the official Codex agent' : AGENT_NAMES[this.agentOf(task)]} running inside Papilot, a paper editing and review workspace.
|
|
210
231
|
Your task is already created. Your editable working directory is ${task.worktree}.
|
|
211
232
|
Original task: ${task.instructions}
|
|
212
233
|
Only edit this isolated task worktree. Never edit the manuscript checkout, other task worktrees, or Papilot's own source/state. Never commit, push, stash, or accept/reject/undo review decisions.
|
|
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.
|
|
234
|
+
${scope.length ? `The review covers only ${scope.join(', ')} in this worktree, and an edit to any other file stops the PDF build. Leave other files unchanged even when project instructions ask for a matching update; mention that update in your summary instead.\n` : ''}Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow.
|
|
214
235
|
Preserve meaning and mathematical correctness. Read relevant project instructions. Treat reviewer comments as instructions tied to their round, file and changes. Rejected edits must not be reintroduced unless explicitly requested. If there is a conflict with accepted text, explain it and propose a revision for a new review round; never overwrite the accepted manuscript.
|
|
215
236
|
${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`;
|
|
216
237
|
}
|
|
@@ -221,7 +242,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
221
242
|
this.store.saveRun({ ...run, status: 'running' });
|
|
222
243
|
this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
|
|
223
244
|
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 } : {}) };
|
|
245
|
+
const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
|
|
225
246
|
const result = task.threadId
|
|
226
247
|
? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
|
|
227
248
|
: await agent.request('thread/start', options);
|
|
@@ -327,7 +348,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
327
348
|
this.store.saveRun({ ...run, status, finishedAt: Date.now() });
|
|
328
349
|
this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
|
|
329
350
|
this.schedule();
|
|
330
|
-
if (status === 'completed') await this.publish(task.id,
|
|
351
|
+
if (status === 'completed') await this.publish(task.id, run);
|
|
331
352
|
} else if (method === 'error' && !p.willRetry) {
|
|
332
353
|
if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
|
|
333
354
|
this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
|
|
@@ -438,6 +459,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
438
459
|
}
|
|
439
460
|
}
|
|
440
461
|
if (this.active.has(taskId) || this.publishing.has(taskId) || BUSY.has(task.status) || task.status === 'queued') throw new Error('Pause the task before accepting, rejecting, or undoing changes. Wait for any PDF build to finish.');
|
|
462
|
+
if (this.committing.has(task.projectId)) throw new Error('A commit is in progress. Try again when it finishes.');
|
|
463
|
+
if (mergeInProgress(this.store.project(task.projectId).path)) throw new Error('Finish or abort the merge in the Git panel before accepting, rejecting, or undoing changes.');
|
|
441
464
|
if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
|
|
442
465
|
this.reviewWrites.add(taskId);
|
|
443
466
|
if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
|
|
@@ -451,17 +474,19 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
451
474
|
}
|
|
452
475
|
finally { this.reviewWrites.delete(taskId); this.schedule(); }
|
|
453
476
|
}
|
|
454
|
-
|
|
455
|
-
|
|
477
|
+
// A build is automatic after the agent's run `after` completes. When its
|
|
478
|
+
// edits break the build, the agent gets the error in a new turn, once.
|
|
479
|
+
publish(taskId: string, after?: Run): Promise<void> {
|
|
480
|
+
const task = this.store.task(taskId), automatic = Boolean(after);
|
|
456
481
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
457
482
|
if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
|
|
458
483
|
if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
|
|
459
484
|
if (this.publishing.has(taskId) || (!automatic && (BUSY.has(task.status) || task.status === 'queued'))) throw new Error('This task is busy. Wait for it to finish or pause it first.');
|
|
460
485
|
this.publishing.add(taskId);
|
|
461
486
|
this.store.updateTask(taskId, { status: 'building', error: null });
|
|
462
|
-
return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
|
|
487
|
+
return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX))).finally(() => { this.publishing.delete(taskId); this.schedule(); });
|
|
463
488
|
}
|
|
464
|
-
private async buildReview(task: Task) {
|
|
489
|
+
private async buildReview(task: Task, sendBack: boolean) {
|
|
465
490
|
const taskId = task.id;
|
|
466
491
|
try {
|
|
467
492
|
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
@@ -472,7 +497,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
472
497
|
const summary = await client.get(taskRoute(task.name));
|
|
473
498
|
this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
|
|
474
499
|
if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
|
|
475
|
-
if (current.status === 'failed')
|
|
500
|
+
if (current.status === 'failed') {
|
|
501
|
+
const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
|
|
502
|
+
// Instructions the author queued meanwhile go first; the build after them sends any error that remains.
|
|
503
|
+
if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + job.job, false, { build: failure }).then(() => true, () => false)) {
|
|
504
|
+
this.store.updateTask(taskId, { status: 'queued', error: null });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
throw new Error([failure.error, ...failure.details].join('\n'));
|
|
508
|
+
}
|
|
476
509
|
await delay(1500);
|
|
477
510
|
}
|
|
478
511
|
this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
|
|
@@ -486,7 +519,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
486
519
|
const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
|
|
487
520
|
const summary = await client.get(taskRoute(task.name));
|
|
488
521
|
const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
|
|
489
|
-
const files = await acceptedFiles(project.path, task.name);
|
|
522
|
+
const files = await acceptedFiles(project.path, task.name, task.committed?.accepted);
|
|
490
523
|
const current = this.store.task(taskId);
|
|
491
524
|
if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
|
|
492
525
|
} catch { /* The task remains visible even if its project is temporarily unavailable. */ }
|
|
@@ -500,6 +533,105 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
500
533
|
const items = feedback.feedback || [];
|
|
501
534
|
return { unsent: items.length, comments: items.filter(item => item.kind === 'comment' || item.kind === 'note').length };
|
|
502
535
|
}
|
|
536
|
+
// The agent that writes a commit message or resolves a merge: the one the
|
|
537
|
+
// author chose, or else the first that is signed in, with the chosen model
|
|
538
|
+
// or the default one.
|
|
539
|
+
private helper(choice: { agent?: AgentId; model?: string }, task: string) {
|
|
540
|
+
const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
|
|
541
|
+
const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
|
|
542
|
+
if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
|
|
543
|
+
const models = this.runtimes[id].models;
|
|
544
|
+
return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
|
|
545
|
+
}
|
|
546
|
+
// One turn on a thread the agent does not keep, outside any task: its
|
|
547
|
+
// reply is returned and no chat shows it.
|
|
548
|
+
private async oneOff(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
|
|
549
|
+
const agent = this.agents[id], name = AGENT_NAMES[id];
|
|
550
|
+
const thread = await agent.request('thread/start', { cwd: options.cwd, approvalPolicy: 'never', sandbox: options.sandbox, ephemeral: true, developerInstructions: options.instructions, ...(options.model ? { model: options.model.id } : {}) });
|
|
551
|
+
const threadId = thread.thread.id;
|
|
552
|
+
return new Promise<string>((resolve, reject) => {
|
|
553
|
+
let text = '', turnId = '';
|
|
554
|
+
const listen = (message: any) => {
|
|
555
|
+
const p = message.params || {};
|
|
556
|
+
if (p.threadId !== threadId) return;
|
|
557
|
+
if (message.method === 'item/completed' && p.item?.type === 'agentMessage') text = p.item.text || text;
|
|
558
|
+
else if (message.method === 'turn/completed') finish(p.turn?.status === 'completed' ? null : new Error(p.turn?.error?.message || `${name} stopped before it could ${options.what}.`));
|
|
559
|
+
};
|
|
560
|
+
const timer = setTimeout(() => {
|
|
561
|
+
if (turnId) agent.request('turn/interrupt', { threadId, turnId }).catch(() => undefined);
|
|
562
|
+
finish(new Error(`${name} did not ${options.what} in time. Try again.`));
|
|
563
|
+
}, options.timeout);
|
|
564
|
+
const finish = (error: Error | null) => { clearTimeout(timer); agent.off('notification', listen); if (error) reject(error); else resolve(text); };
|
|
565
|
+
agent.on('notification', listen);
|
|
566
|
+
agent.request('turn/start', { threadId, input: [{ type: 'text', text: options.prompt }], cwd: options.cwd, ...(options.effort ? { effort: options.effort } : {}) })
|
|
567
|
+
.then(result => { turnId = result?.turn?.id || ''; }, error => finish(error instanceof Error ? error : new Error(String(error))));
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// Writes a commit message for the chosen manuscript files, like Cursor's
|
|
571
|
+
// generate button: one read-only turn with the lightest reasoning the model offers.
|
|
572
|
+
async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; model?: string } = {}) {
|
|
573
|
+
const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message');
|
|
574
|
+
const { files, patch, recent } = await commitChanges(project.path, paths);
|
|
575
|
+
const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
|
|
576
|
+
const prompt = [
|
|
577
|
+
recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
|
|
578
|
+
tasks.length ? `Papilot tasks whose accepted changes are in these files:\n${tasks.map(title => `- ${title}`).join('\n')}` : '',
|
|
579
|
+
`Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
|
|
580
|
+
`Diff:\n${patch}`,
|
|
581
|
+
].filter(Boolean).join('\n\n');
|
|
582
|
+
const reply = await this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' });
|
|
583
|
+
const message = commitMessageFrom(reply);
|
|
584
|
+
if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
|
|
585
|
+
return { message, agent: id };
|
|
586
|
+
}
|
|
587
|
+
// Resolves a merge's conflicts in the manuscript checkout itself: the agent
|
|
588
|
+
// edits only the conflicted files, at the model's default reasoning, and
|
|
589
|
+
// leaves committing the merge to the author once they have read the diffs.
|
|
590
|
+
async resolveConflicts(projectId: string, choice: { agent?: AgentId; model?: string } = {}) {
|
|
591
|
+
const project = this.store.project(projectId), { id, model } = this.helper(choice, 'resolve conflicts');
|
|
592
|
+
const { files, branch, upstream, ours, theirs } = await mergeConflicts(project.path);
|
|
593
|
+
if (!files.length) throw new Error('No file has conflict markers left. Commit the merge.');
|
|
594
|
+
const list = (items: string[]) => items.length ? items.map(item => `- ${item}`).join('\n') : '- (none listed)';
|
|
595
|
+
const prompt = [
|
|
596
|
+
`${branch} is being merged with ${upstream} in ${project.path}, and these files have conflicts:\n${list(files)}`,
|
|
597
|
+
`Commits on ${branch} since the two diverged:\n${list(ours)}`,
|
|
598
|
+
`Commits from ${upstream} being merged in:\n${list(theirs)}`,
|
|
599
|
+
'Resolve every conflict in those files, then reply with a short summary for the author: how you resolved each conflict, and any place where you had to choose one side.',
|
|
600
|
+
].join('\n\n');
|
|
601
|
+
const summary = await this.oneOff(id, { cwd: project.path, sandbox: 'workspace-write', instructions: mergeInstructions, prompt, model, effort: model?.defaultEffort, timeout: 600000, what: 'resolve the conflicts' });
|
|
602
|
+
return { summary: summary.trim(), remaining: files.filter(file => conflictMarkers(project.path, file)), agent: id, git: await gitStatus(project.path) };
|
|
603
|
+
}
|
|
604
|
+
// Commits what the author accepted from this task since its last commit,
|
|
605
|
+
// with the message they confirmed, and pushes it if they asked. Other
|
|
606
|
+
// uncommitted files stay as they are; a file another task also changed is
|
|
607
|
+
// committed as it is on disk.
|
|
608
|
+
async commitTask(taskId: string, message: string, push = false) {
|
|
609
|
+
const task = this.store.task(taskId);
|
|
610
|
+
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
611
|
+
if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
|
|
612
|
+
const project = this.store.project(task.projectId);
|
|
613
|
+
return this.exclusiveCommit(project.id, async () => {
|
|
614
|
+
const accepted = await acceptedCommit(project.path, task.name);
|
|
615
|
+
const files = accepted ? await acceptedFiles(project.path, task.name, this.store.task(taskId).committed?.accepted) : [];
|
|
616
|
+
const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
|
|
617
|
+
if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
|
|
618
|
+
const hash = await gitCommit(project.path, message, paths);
|
|
619
|
+
this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
|
|
620
|
+
void this.refresh(taskId);
|
|
621
|
+
// The commit stands even when the push fails; the author can push again.
|
|
622
|
+
let pushError: string | undefined;
|
|
623
|
+
if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
|
|
624
|
+
return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
// A commit reads the working tree that accepting, rejecting and undoing
|
|
628
|
+
// write, so neither starts while the other runs on the same paper.
|
|
629
|
+
async exclusiveCommit<T>(projectId: string, work: () => Promise<T>): Promise<T> {
|
|
630
|
+
if (this.committing.has(projectId)) throw new Error('A commit is already in progress. Wait for it to finish.');
|
|
631
|
+
if ([...this.reviewWrites].some(id => this.store.task(id).projectId === projectId)) throw new Error('Wait for the current review action before committing.');
|
|
632
|
+
this.committing.add(projectId);
|
|
633
|
+
try { return await work(); } finally { this.committing.delete(projectId); }
|
|
634
|
+
}
|
|
503
635
|
renameTask(taskId: string, title: string) {
|
|
504
636
|
if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
|
|
505
637
|
return this.store.updateTask(taskId, { title });
|
|
@@ -544,10 +676,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
544
676
|
|
|
545
677
|
// The review engine records each task's accepted text as a commit beside the
|
|
546
678
|
// one it started from; their difference is what the task put in the
|
|
547
|
-
// manuscript
|
|
548
|
-
|
|
679
|
+
// manuscript, and the difference from the accepted text at the task's last
|
|
680
|
+
// commit is what it has added since. A rename counts as both of its paths.
|
|
681
|
+
export async function acceptedFiles(root: string, name: string, since?: string) {
|
|
549
682
|
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`, '--']);
|
|
683
|
+
const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', since || `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
|
|
551
684
|
return stdout.split('\0').filter(Boolean);
|
|
552
685
|
} catch { return []; }
|
|
553
686
|
}
|
|
687
|
+
export async function acceptedCommit(root: string, name: string) {
|
|
688
|
+
try { return (await execute('git', ['-C', root, 'rev-parse', '--verify', '-q', `refs/latex-review/${name}/accepted^{commit}`])).stdout.trim() || undefined; }
|
|
689
|
+
catch { return undefined; }
|
|
690
|
+
}
|