papergod 0.1.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/LICENSE +21 -0
- package/README.md +244 -0
- package/ROADMAP.md +171 -0
- package/example/main.tex +360 -0
- package/frontend/src/components/ui/badge.jsx +5 -0
- package/frontend/src/components/ui/button.jsx +24 -0
- package/frontend/src/components/workbench.jsx +182 -0
- package/frontend/src/lib/utils.js +6 -0
- package/frontend/src/main.jsx +19 -0
- package/frontend/src/theme.css +256 -0
- package/frontend/vite.config.js +23 -0
- package/package.json +73 -0
- package/papergod-demo.png +0 -0
- package/public/app.js +5480 -0
- package/public/brand/papergod-logo.png +0 -0
- package/public/i18n.js +95 -0
- package/public/index.html +480 -0
- package/public/pdf-sentence-mapping.js +142 -0
- package/public/react/app.js +209 -0
- package/public/react/assets/addon-fit-YJmn1quW.js +12 -0
- package/public/react/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/public/react/assets/main.css +32 -0
- package/public/react/assets/xterm-BqvuqXEL.js +27 -0
- package/public/style.css +1462 -0
- package/src/cli.js +128 -0
- package/src/server/agent-adapters.js +1240 -0
- package/src/server/agent-errors.js +105 -0
- package/src/server/agent-runtime.js +81 -0
- package/src/server/agent.js +173 -0
- package/src/server/app-version.js +86 -0
- package/src/server/change-history.js +114 -0
- package/src/server/document-structure.js +174 -0
- package/src/server/index.js +1442 -0
- package/src/server/latex-structure.js +344 -0
- package/src/server/latex.js +67 -0
- package/src/server/library-engine.js +193 -0
- package/src/server/library-files.js +134 -0
- package/src/server/literature-review.js +122 -0
- package/src/server/orchestration-engine.js +662 -0
- package/src/server/paragraph-analysis.js +300 -0
- package/src/server/project-resources.js +290 -0
- package/src/server/project-store.js +808 -0
- package/src/server/prompt-manifest.js +300 -0
- package/src/server/references.js +425 -0
- package/src/server/review-panel.js +263 -0
- package/src/server/revise-workflow.js +278 -0
- package/src/server/revision-engine.js +607 -0
- package/src/server/security.js +16 -0
- package/src/server/text-extraction.js +149 -0
- package/src/server/workspace-browser.js +49 -0
- package/src/server/workspace-registry.js +143 -0
- package/src/server/workspace-terminal.js +99 -0
- package/src/server/workspace.js +223 -0
- package/src/server/zotero.js +98 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { basename, dirname, join, sep } from 'path';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'fs/promises';
|
|
4
|
+
import { sanitizePath } from './security.js';
|
|
5
|
+
import { loadProject, updateProject } from './project-store.js';
|
|
6
|
+
import { syncDocumentStructure } from './document-structure.js';
|
|
7
|
+
import { getHistoricalRevisionSource } from './change-history.js';
|
|
8
|
+
import { createAgentRun, updateAgentRun } from './project-resources.js';
|
|
9
|
+
import { runReviewOrchestrationAgent } from './agent-adapters.js';
|
|
10
|
+
import { agentFailureAudit } from './agent-errors.js';
|
|
11
|
+
import { materializeLibraries } from './library-files.js';
|
|
12
|
+
|
|
13
|
+
const revisionQueues = new Map();
|
|
14
|
+
|
|
15
|
+
function now() {
|
|
16
|
+
return new Date().toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function id(prefix) {
|
|
20
|
+
return `${prefix}_${randomUUID()}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function problem(message, status = 400, code = 'REVISION_ERROR') {
|
|
24
|
+
const error = new Error(message);
|
|
25
|
+
error.status = status;
|
|
26
|
+
error.code = code;
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hash(content) {
|
|
31
|
+
return createHash('sha256').update(content).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flattenNodes(document) {
|
|
35
|
+
const result = [];
|
|
36
|
+
const visit = (nodes) => {
|
|
37
|
+
for (const node of nodes || []) {
|
|
38
|
+
result.push(node);
|
|
39
|
+
visit(node.children);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
visit(document.sections);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tokenize(value) {
|
|
47
|
+
return new Set(String(value || '').toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) || []);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function overlapScore(a, b) {
|
|
51
|
+
const left = tokenize(a);
|
|
52
|
+
const right = tokenize(b);
|
|
53
|
+
if (!left.size || !right.size) return 0;
|
|
54
|
+
let shared = 0;
|
|
55
|
+
left.forEach((token) => { if (right.has(token)) shared += 1; });
|
|
56
|
+
return shared / Math.max(1, Math.min(left.size, right.size));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nodeForRange(document, start, end) {
|
|
60
|
+
return flattenNodes(document)
|
|
61
|
+
.filter((node) => node.sourceRange && node.sourceRange.start <= start && node.sourceRange.end >= end)
|
|
62
|
+
.sort((a, b) => (a.sourceRange.end - a.sourceRange.start) - (b.sourceRange.end - b.sourceRange.start))[0] || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bestNodeForOpinion(document, body) {
|
|
66
|
+
return flattenNodes(document)
|
|
67
|
+
.filter((node) => node.type === 'sentence' || node.type === 'paragraph')
|
|
68
|
+
.map((node) => ({ node, score: overlapScore(body, node.text) }))
|
|
69
|
+
.sort((a, b) => b.score - a.score)[0]?.score >= 0.18
|
|
70
|
+
? flattenNodes(document)
|
|
71
|
+
.filter((node) => node.type === 'sentence' || node.type === 'paragraph')
|
|
72
|
+
.map((node) => ({ node, score: overlapScore(body, node.text) }))
|
|
73
|
+
.sort((a, b) => b.score - a.score)[0].node
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function splitAtomicOpinions(input) {
|
|
78
|
+
if (typeof input !== 'string') throw problem('Review text must be a string');
|
|
79
|
+
const lines = input.replace(/\r/g, '').split('\n');
|
|
80
|
+
const opinions = [];
|
|
81
|
+
let current = '';
|
|
82
|
+
const flush = () => {
|
|
83
|
+
const value = current.trim();
|
|
84
|
+
if (value) opinions.push(value);
|
|
85
|
+
current = '';
|
|
86
|
+
};
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const match = line.match(/^\s*(?:[-*•]|\d+[.)]|(?:comment|point)\s+\d+[:.)])\s*(.+)$/i);
|
|
89
|
+
if (match) {
|
|
90
|
+
flush();
|
|
91
|
+
current = match[1];
|
|
92
|
+
} else if (!line.trim()) {
|
|
93
|
+
flush();
|
|
94
|
+
} else {
|
|
95
|
+
current += `${current ? ' ' : ''}${line.trim()}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
flush();
|
|
99
|
+
if (opinions.length === 1 && opinions[0].length > 500) {
|
|
100
|
+
return opinions[0].split(/(?<=[.!?])\s+(?=[A-Z])/).map((item) => item.trim()).filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
return opinions;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function classify(body) {
|
|
106
|
+
const value = body.toLowerCase();
|
|
107
|
+
const category = /cit(e|ation)|reference|bibliograph|引用|参考文献/.test(value) ? 'citation'
|
|
108
|
+
: /method|algorithm|experiment|protocol|方法|算法|实验/.test(value) ? 'method'
|
|
109
|
+
: /evidence|result|support|claim|证据|结果|支撑|论据/.test(value) ? 'evidence'
|
|
110
|
+
: /structure|section|organize|flow|结构|章节|组织|逻辑/.test(value) ? 'structure'
|
|
111
|
+
: /grammar|typo|tense|spelling|语法|错别字|时态|拼写/.test(value) ? 'grammar'
|
|
112
|
+
: /style|wording|tone|clarity|concise|风格|措辞|语气|清晰|精简/.test(value) ? 'style' : 'content';
|
|
113
|
+
const severity = /fatal|critical|invalid|must address|致命|严重错误|必须修改/.test(value) ? 'critical'
|
|
114
|
+
: /major|substantial|missing|unsupported|主要问题|重大|缺少|不充分/.test(value) ? 'major'
|
|
115
|
+
: /minor|small|typo|grammar|次要|小问题|语法/.test(value) ? 'minor' : 'info';
|
|
116
|
+
return { category, severity };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function replacement(body) {
|
|
120
|
+
const match = body.match(/\b(?:replace|change)\s+["“']([^"”']+)["”']\s+(?:with|to)\s+["“']([^"”']+)["”']/i)
|
|
121
|
+
|| body.match(/["“']([^"”']+)["”']\s*(?:->|→)\s*["“']([^"”']+)["”']/)
|
|
122
|
+
|| body.match(/(?:把|将)\s*["“‘『「]([^"”’』」]+)["”’』」]\s*(?:改为|改成|替换为|换成)\s*["“‘『「]([^"”’』」]+)["”’』」]/);
|
|
123
|
+
return match ? { before: match[1], after: match[2] } : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function annotationFromOpinion(document, content, body, order, actor) {
|
|
127
|
+
const edit = replacement(body);
|
|
128
|
+
let targetNode = null;
|
|
129
|
+
let start = 0;
|
|
130
|
+
let end = 0;
|
|
131
|
+
let quote = '';
|
|
132
|
+
if (edit) {
|
|
133
|
+
start = content.indexOf(edit.before);
|
|
134
|
+
if (start !== -1) {
|
|
135
|
+
end = start + edit.before.length;
|
|
136
|
+
quote = edit.before;
|
|
137
|
+
targetNode = nodeForRange(document, start, end);
|
|
138
|
+
} else {
|
|
139
|
+
start = 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!targetNode) {
|
|
143
|
+
targetNode = bestNodeForOpinion(document, body);
|
|
144
|
+
if (targetNode?.sourceRange) {
|
|
145
|
+
start = targetNode.sourceRange.start;
|
|
146
|
+
end = targetNode.sourceRange.end;
|
|
147
|
+
quote = content.slice(start, end);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const classification = classify(body);
|
|
151
|
+
const timestamp = now();
|
|
152
|
+
return {
|
|
153
|
+
id: id('annotation'), documentId: document.id, order,
|
|
154
|
+
target: {
|
|
155
|
+
type: edit && quote ? 'range' : targetNode?.type || 'document',
|
|
156
|
+
id: targetNode?.id || document.id, start, end, quote,
|
|
157
|
+
},
|
|
158
|
+
...classification, body, suggestedFix: edit?.after || '', status: 'open',
|
|
159
|
+
source: { type: 'import', actor: actor || 'review import' },
|
|
160
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function importReviewOpinions(workspaceRoot, { documentId, text, actor = '' }) {
|
|
165
|
+
const project = await loadProject(workspaceRoot);
|
|
166
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
167
|
+
if (!document) throw problem('Document not found', 404);
|
|
168
|
+
const safe = sanitizePath(document.file, workspaceRoot);
|
|
169
|
+
if (!safe) throw problem('Access denied', 403);
|
|
170
|
+
const content = await readFile(safe, 'utf-8');
|
|
171
|
+
if (!document.sourceHash || document.sourceHash !== hash(content)) {
|
|
172
|
+
throw problem('Document changed; synchronize structure before importing opinions', 409, 'STALE_STRUCTURE');
|
|
173
|
+
}
|
|
174
|
+
const bodies = splitAtomicOpinions(text);
|
|
175
|
+
if (!bodies.length) throw problem('No review opinions found');
|
|
176
|
+
const annotations = bodies.map((body, index) => annotationFromOpinion(document, content, body, index + 1, actor));
|
|
177
|
+
await updateProject(workspaceRoot, (draft) => draft.annotations.push(...annotations));
|
|
178
|
+
return annotations;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function outlineForAgent(document) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
const visit = (nodes, depth = 0) => (nodes || []).forEach((node) => {
|
|
184
|
+
lines.push(`${' '.repeat(depth)}- ${node.type} ${node.id}: ${node.title || node.summary || String(node.text || '').slice(0, 160)}`);
|
|
185
|
+
visit(node.children, depth + 1);
|
|
186
|
+
});
|
|
187
|
+
visit(document.sections);
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function orchestrateReviewOpinions(workspaceRoot, { documentId, text, actor = '' }, options = {}) {
|
|
192
|
+
const provider = options.provider || 'mock';
|
|
193
|
+
const startedAt = now();
|
|
194
|
+
if (provider === 'mock') {
|
|
195
|
+
const annotations = await importReviewOpinions(workspaceRoot, { documentId, text, actor });
|
|
196
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
197
|
+
provider, operation: 'orchestrate-review', status: 'complete', prompt: text,
|
|
198
|
+
input: JSON.stringify({ documentId, characters: text.length }), output: JSON.stringify({ annotationIds: annotations.map((item) => item.id) }),
|
|
199
|
+
error: '', startedAt, finishedAt: now(),
|
|
200
|
+
});
|
|
201
|
+
return { annotations, runId: run.id, summary: `${annotations.length} atomic opinion(s) extracted by the deterministic orchestrator.` };
|
|
202
|
+
}
|
|
203
|
+
const project = await loadProject(workspaceRoot);
|
|
204
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
205
|
+
if (!document) throw problem('Document not found', 404);
|
|
206
|
+
const safe = sanitizePath(document.file, workspaceRoot); if (!safe) throw problem('Access denied', 403);
|
|
207
|
+
const content = await readFile(safe, 'utf-8');
|
|
208
|
+
if (!document.sourceHash || document.sourceHash !== hash(content)) throw problem('Document changed; synchronize structure before orchestrating opinions', 409, 'STALE_STRUCTURE');
|
|
209
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
210
|
+
provider, operation: 'orchestrate-review', status: 'running', prompt: text,
|
|
211
|
+
input: JSON.stringify({ documentId, feedbackCharacters: text.length, manuscriptCharacters: content.length }), output: '', error: '', startedAt, finishedAt: '',
|
|
212
|
+
});
|
|
213
|
+
try {
|
|
214
|
+
await materializeLibraries(workspaceRoot);
|
|
215
|
+
const result = await runReviewOrchestrationAgent(provider, {
|
|
216
|
+
feedback: text, content, outlineContext: outlineForAgent(document),
|
|
217
|
+
workspace: { file: document.file, start: 0, end: content.length },
|
|
218
|
+
}, {
|
|
219
|
+
workspaceRoot, commands: options.commands || {}, signal: options.signal,
|
|
220
|
+
});
|
|
221
|
+
const timestamp = now();
|
|
222
|
+
const annotations = result.opinions.map((opinion, index) => {
|
|
223
|
+
const start = opinion.quote ? content.indexOf(opinion.quote) : -1;
|
|
224
|
+
const end = start < 0 ? 0 : start + opinion.quote.length;
|
|
225
|
+
const targetNode = start >= 0 ? nodeForRange(document, start, end) : bestNodeForOpinion(document, opinion.body);
|
|
226
|
+
return {
|
|
227
|
+
id: id('annotation'), documentId, order: index + 1,
|
|
228
|
+
target: start >= 0
|
|
229
|
+
? { type: 'range', id: targetNode?.id || document.id, start, end, quote: opinion.quote }
|
|
230
|
+
: { type: targetNode?.type || 'document', id: targetNode?.id || document.id, start: targetNode?.sourceRange?.start || 0, end: targetNode?.sourceRange?.end || 0, quote: targetNode?.sourceRange ? content.slice(targetNode.sourceRange.start, targetNode.sourceRange.end) : '' },
|
|
231
|
+
category: opinion.category, severity: opinion.severity, body: opinion.body, suggestedFix: opinion.suggestedFix,
|
|
232
|
+
status: 'open', source: { type: 'agent', actor: actor || `${provider} orchestrator` }, dependsOn: [],
|
|
233
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
result.opinions.forEach((opinion, index) => {
|
|
237
|
+
annotations[index].dependsOn = opinion.dependsOn.map((order) => annotations[order - 1].id);
|
|
238
|
+
});
|
|
239
|
+
await updateProject(workspaceRoot, (draft) => draft.annotations.push(...annotations));
|
|
240
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'complete', output: JSON.stringify({ ...result, agentMeta: result.agentMeta }), finishedAt: now() });
|
|
241
|
+
return { annotations, runId: run.id, summary: result.summary };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'failed', error: agentFailureAudit(error), finishedAt: now() });
|
|
244
|
+
error.status = error.status || 502; throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function overlap(a, b) {
|
|
249
|
+
return a.target.start < b.target.end && b.target.start < a.target.end;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function createRevisionPlan(workspaceRoot, { documentId, annotationIds, title = 'Revision plan' }) {
|
|
253
|
+
const { result } = await updateProject(workspaceRoot, (project) => {
|
|
254
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
255
|
+
if (!document) throw problem('Document not found', 404);
|
|
256
|
+
const selected = project.annotations.filter((item) => item.documentId === documentId && annotationIds.includes(item.id));
|
|
257
|
+
if (!selected.length) throw problem('Select at least one annotation');
|
|
258
|
+
const changes = selected.map((annotation) => ({
|
|
259
|
+
id: id('change'), annotationId: annotation.id,
|
|
260
|
+
target: structuredClone(annotation.target), before: annotation.target.quote,
|
|
261
|
+
after: annotation.suggestedFix, reason: annotation.body,
|
|
262
|
+
status: 'proposed', executable: Boolean(annotation.target.quote && annotation.suggestedFix && annotation.target.quote !== annotation.suggestedFix),
|
|
263
|
+
dependsOn: [], conflictsWith: [],
|
|
264
|
+
}));
|
|
265
|
+
const edges = [];
|
|
266
|
+
changes.forEach((change, index) => {
|
|
267
|
+
const dependencyIds = [...(selected[index].dependsOn || [])];
|
|
268
|
+
const dependency = selected[index].body.match(/depends?\s+on\s+#?(\d+)/i);
|
|
269
|
+
if (dependency) {
|
|
270
|
+
const dependencyAnnotation = selected[Number(dependency[1]) - 1];
|
|
271
|
+
if (dependencyAnnotation) dependencyIds.push(dependencyAnnotation.id);
|
|
272
|
+
}
|
|
273
|
+
[...new Set(dependencyIds)].forEach((dependencyAnnotationId) => {
|
|
274
|
+
const dependencyChange = changes.find((item) => item.annotationId === dependencyAnnotationId);
|
|
275
|
+
if (dependencyChange) {
|
|
276
|
+
change.dependsOn.push(dependencyChange.id);
|
|
277
|
+
edges.push({ from: dependencyChange.id, to: change.id, type: 'depends-on' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
changes.slice(index + 1).forEach((other) => {
|
|
281
|
+
if (change.target.end > change.target.start && overlap(change, other)) {
|
|
282
|
+
change.conflictsWith.push(other.id);
|
|
283
|
+
other.conflictsWith.push(change.id);
|
|
284
|
+
edges.push({ from: change.id, to: other.id, type: 'conflicts' });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
const timestamp = now();
|
|
289
|
+
const revision = {
|
|
290
|
+
id: id('revision'), documentId, file: document.file, title, summary: `${selected.length} imported opinion(s)`,
|
|
291
|
+
status: 'review', annotationIds: selected.map((item) => item.id), changes,
|
|
292
|
+
graph: { nodes: changes.map((item) => item.id), edges }, recoveryPoint: null,
|
|
293
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
294
|
+
};
|
|
295
|
+
project.revisions.push(revision);
|
|
296
|
+
selected.forEach((annotation) => { annotation.status = 'planned'; annotation.updatedAt = timestamp; });
|
|
297
|
+
return structuredClone(revision);
|
|
298
|
+
});
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function decideRevisionChanges(workspaceRoot, revisionId, decisions) {
|
|
303
|
+
if (!Array.isArray(decisions) || !decisions.length) throw problem('decisions must be a non-empty array');
|
|
304
|
+
const allowed = ['accepted', 'rejected', 'deferred', 'proposed'];
|
|
305
|
+
const { result } = await updateProject(workspaceRoot, (project) => {
|
|
306
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
307
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
308
|
+
if (['applied', 'rolled-back'].includes(revision.status)) throw problem('Applied revisions cannot be edited', 409);
|
|
309
|
+
for (const decision of decisions) {
|
|
310
|
+
const change = revision.changes.find((item) => item.id === decision.changeId);
|
|
311
|
+
if (!change) throw problem(`Change not found: ${decision.changeId}`, 404);
|
|
312
|
+
if (!allowed.includes(decision.status)) throw problem(`Invalid change decision: ${decision.status}`);
|
|
313
|
+
if (decision.after !== undefined) {
|
|
314
|
+
if (typeof decision.after !== 'string') throw problem('after must be a string');
|
|
315
|
+
change.after = decision.after;
|
|
316
|
+
change.executable = typeof change.before === 'string' && typeof change.after === 'string' && change.before !== change.after;
|
|
317
|
+
}
|
|
318
|
+
if (decision.status === 'accepted' && !change.executable) throw problem('A change needs distinct before/after text before it can be accepted');
|
|
319
|
+
change.status = decision.status;
|
|
320
|
+
const annotation = project.annotations.find((item) => item.id === change.annotationId);
|
|
321
|
+
if (annotation) {
|
|
322
|
+
annotation.status = decision.status === 'rejected' ? 'rejected' : decision.status === 'deferred' ? 'deferred' : 'planned';
|
|
323
|
+
annotation.updatedAt = now();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
revision.updatedAt = now();
|
|
327
|
+
return structuredClone(revision);
|
|
328
|
+
});
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function atomicWrite(file, content) {
|
|
333
|
+
const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
334
|
+
await writeFile(temporary, content, 'utf-8');
|
|
335
|
+
await rename(temporary, file);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function withRevisionLock(workspaceRoot, operation) {
|
|
339
|
+
const previous = revisionQueues.get(workspaceRoot) || Promise.resolve();
|
|
340
|
+
const current = previous.catch(() => {}).then(operation);
|
|
341
|
+
revisionQueues.set(workspaceRoot, current);
|
|
342
|
+
try {
|
|
343
|
+
return await current;
|
|
344
|
+
} finally {
|
|
345
|
+
if (revisionQueues.get(workspaceRoot) === current) revisionQueues.delete(workspaceRoot);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function validateAcceptedChanges(content, changes) {
|
|
350
|
+
const sorted = [...changes].sort((a, b) => b.target.start - a.target.start);
|
|
351
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
352
|
+
const change = sorted[index];
|
|
353
|
+
if (!change.executable || typeof change.before !== 'string' || typeof change.after !== 'string' || change.before === change.after) throw problem(`Change ${change.id} is not executable`);
|
|
354
|
+
if (content.slice(change.target.start, change.target.end) !== change.before) {
|
|
355
|
+
throw problem(`Source text changed for ${change.id}; create a new revision plan`, 409, 'STALE_CHANGE');
|
|
356
|
+
}
|
|
357
|
+
const next = sorted[index + 1];
|
|
358
|
+
if (next && next.target.end > change.target.start) throw problem('Accepted changes overlap; reject one conflicting change', 409, 'OVERLAPPING_CHANGES');
|
|
359
|
+
for (const dependency of change.dependsOn || []) {
|
|
360
|
+
if (!changes.some((item) => item.id === dependency)) throw problem(`Accepted change ${change.id} depends on an unaccepted change`, 409, 'UNMET_DEPENDENCY');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return sorted;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function applyRevision(workspaceRoot, revisionId) {
|
|
367
|
+
return withRevisionLock(workspaceRoot, async () => {
|
|
368
|
+
const project = await loadProject(workspaceRoot);
|
|
369
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
370
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
371
|
+
if (revision.status === 'applied') throw problem('Revision is already applied', 409);
|
|
372
|
+
const document = project.documents.find((item) => item.id === revision.documentId);
|
|
373
|
+
if (!document) throw problem('Document not found', 404);
|
|
374
|
+
const safe = sanitizePath(document.file, workspaceRoot);
|
|
375
|
+
if (!safe) throw problem('Access denied', 403);
|
|
376
|
+
const original = await readFile(safe, 'utf-8');
|
|
377
|
+
const accepted = revision.changes.filter((item) => item.status === 'accepted');
|
|
378
|
+
if (!accepted.length) throw problem('No accepted changes to apply');
|
|
379
|
+
const sorted = validateAcceptedChanges(original, accepted);
|
|
380
|
+
let revised = original;
|
|
381
|
+
for (const change of sorted) {
|
|
382
|
+
revised = revised.slice(0, change.target.start) + change.after + revised.slice(change.target.end);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const recoveryId = id('recovery');
|
|
386
|
+
const recoveryDirectory = join(workspaceRoot, '.papergod', 'recovery');
|
|
387
|
+
const recoveryRelative = join('.papergod', 'recovery', `${recoveryId}.tex`).split(sep).join('/');
|
|
388
|
+
const recoveryFile = join(workspaceRoot, recoveryRelative);
|
|
389
|
+
await mkdir(recoveryDirectory, { recursive: true });
|
|
390
|
+
await writeFile(recoveryFile, original, { encoding: 'utf-8', flag: 'wx', mode: 0o600 });
|
|
391
|
+
await atomicWrite(safe, revised);
|
|
392
|
+
try {
|
|
393
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
394
|
+
const currentRevision = draft.revisions.find((item) => item.id === revisionId);
|
|
395
|
+
if (!currentRevision) throw problem('Revision disappeared during apply', 409);
|
|
396
|
+
const timestamp = now();
|
|
397
|
+
currentRevision.status = 'applied';
|
|
398
|
+
currentRevision.appliedAt = timestamp;
|
|
399
|
+
currentRevision.recoveryPoint = {
|
|
400
|
+
id: recoveryId, file: document.file, path: recoveryRelative, sourceHash: hash(original),
|
|
401
|
+
appliedHash: hash(revised), createdAt: timestamp,
|
|
402
|
+
};
|
|
403
|
+
currentRevision.changes.forEach((change) => {
|
|
404
|
+
if (accepted.some((item) => item.id === change.id)) change.status = 'applied';
|
|
405
|
+
});
|
|
406
|
+
currentRevision.annotationIds.forEach((annotationId) => {
|
|
407
|
+
const annotation = draft.annotations.find((item) => item.id === annotationId);
|
|
408
|
+
if (annotation && accepted.some((change) => change.annotationId === annotationId)) {
|
|
409
|
+
annotation.status = 'resolved';
|
|
410
|
+
annotation.updatedAt = timestamp;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return structuredClone(currentRevision);
|
|
414
|
+
});
|
|
415
|
+
await syncDocumentStructure(workspaceRoot, document.file);
|
|
416
|
+
return { revision: result, content: revised, recoveryPoint: result.recoveryPoint };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
await atomicWrite(safe, original);
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function rollbackRevision(workspaceRoot, revisionId) {
|
|
425
|
+
return withRevisionLock(workspaceRoot, async () => {
|
|
426
|
+
const project = await loadProject(workspaceRoot);
|
|
427
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
428
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
429
|
+
if (revision.status !== 'applied' || !revision.recoveryPoint) throw problem('Only an applied revision can be rolled back', 409);
|
|
430
|
+
const document = project.documents.find((item) => item.id === revision.documentId);
|
|
431
|
+
const target = sanitizePath(document.file, workspaceRoot);
|
|
432
|
+
const recovery = sanitizePath(revision.recoveryPoint.path, workspaceRoot);
|
|
433
|
+
if (!target || !recovery || !revision.recoveryPoint.path.startsWith('.papergod/recovery/')) throw problem('Invalid recovery point', 403);
|
|
434
|
+
const current = await readFile(target, 'utf-8');
|
|
435
|
+
if (hash(current) !== revision.recoveryPoint.appliedHash) {
|
|
436
|
+
throw problem('Document changed after revision; automatic rollback would discard later work', 409, 'ROLLBACK_CONFLICT');
|
|
437
|
+
}
|
|
438
|
+
const original = await readFile(recovery, 'utf-8');
|
|
439
|
+
if (hash(original) !== revision.recoveryPoint.sourceHash) throw problem('Recovery point checksum failed', 409);
|
|
440
|
+
await atomicWrite(target, original);
|
|
441
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
442
|
+
const currentRevision = draft.revisions.find((item) => item.id === revisionId);
|
|
443
|
+
currentRevision.status = 'rolled-back';
|
|
444
|
+
currentRevision.rolledBackAt = now();
|
|
445
|
+
currentRevision.changes.forEach((change) => { if (change.status === 'applied') change.status = 'reverted'; });
|
|
446
|
+
currentRevision.annotationIds.forEach((annotationId) => {
|
|
447
|
+
const annotation = draft.annotations.find((item) => item.id === annotationId);
|
|
448
|
+
if (annotation?.status === 'resolved') { annotation.status = 'open'; annotation.updatedAt = now(); }
|
|
449
|
+
});
|
|
450
|
+
return structuredClone(currentRevision);
|
|
451
|
+
});
|
|
452
|
+
await syncDocumentStructure(workspaceRoot, document.file);
|
|
453
|
+
return { revision: result, content: original };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function restoreRevisionVersion(workspaceRoot, revisionId) {
|
|
458
|
+
const historical = await getHistoricalRevisionSource(workspaceRoot, revisionId);
|
|
459
|
+
const safe = sanitizePath(historical.document.file, workspaceRoot);
|
|
460
|
+
if (!safe) throw problem('Access denied', 403);
|
|
461
|
+
const current = await readFile(safe, 'utf-8');
|
|
462
|
+
if (current === historical.source) throw problem('This version is already current', 409);
|
|
463
|
+
const timestamp = now();
|
|
464
|
+
const changeId = id('change');
|
|
465
|
+
const revision = {
|
|
466
|
+
id: id('revision'), documentId: historical.document.id, file: historical.document.file,
|
|
467
|
+
title: `Restore version · ${historical.revision.title || historical.revision.id}`,
|
|
468
|
+
summary: `Restore the paper to version ${historical.revision.id} while preserving the current paper as a recovery point.`,
|
|
469
|
+
status: 'review', annotationIds: [],
|
|
470
|
+
changes: [{
|
|
471
|
+
id: changeId, target: { type: 'range', id: historical.document.id, start: 0, end: current.length, quote: current },
|
|
472
|
+
before: current, after: historical.source, reason: `Restore historical version ${historical.revision.id}.`,
|
|
473
|
+
status: 'accepted', executable: true, dependsOn: [], conflictsWith: [],
|
|
474
|
+
}],
|
|
475
|
+
graph: { nodes: [changeId], edges: [] }, recoveryPoint: null, origin: 'history-restore',
|
|
476
|
+
restoredRevisionId: historical.revision.id, createdAt: timestamp, updatedAt: timestamp,
|
|
477
|
+
};
|
|
478
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
479
|
+
return applyRevision(workspaceRoot, revision.id);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function applySuggestionAsRevision(workspaceRoot, file, suggestion) {
|
|
483
|
+
if (!suggestion || typeof suggestion.originalText !== 'string' || typeof suggestion.suggestedText !== 'string') {
|
|
484
|
+
throw problem('Suggestion not found', 404);
|
|
485
|
+
}
|
|
486
|
+
const document = await syncDocumentStructure(workspaceRoot, file);
|
|
487
|
+
const safe = sanitizePath(file, workspaceRoot); if (!safe) throw problem('Access denied', 403);
|
|
488
|
+
const content = await readFile(safe, 'utf-8');
|
|
489
|
+
let start = suggestion.sourceRange?.start;
|
|
490
|
+
let end = suggestion.sourceRange?.end;
|
|
491
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
|
492
|
+
start = content.indexOf(suggestion.originalText); end = start + suggestion.originalText.length;
|
|
493
|
+
}
|
|
494
|
+
if (start < 0 || content.slice(start, end) !== suggestion.originalText) throw problem('Source text changed; generate a new suggestion', 409, 'STALE_CHANGE');
|
|
495
|
+
if (suggestion.file && suggestion.file !== file) throw problem('Suggestion belongs to a different file', 409);
|
|
496
|
+
const timestamp = now(); const changeId = id('change');
|
|
497
|
+
const revision = {
|
|
498
|
+
id: id('revision'), documentId: document.id, file, title: `Agent suggestion · ${suggestion.category || 'edit'}`,
|
|
499
|
+
summary: suggestion.description || suggestion.reason || 'Accepted Agent writing suggestion.', status: 'review', annotationIds: [],
|
|
500
|
+
changes: [{
|
|
501
|
+
id: changeId, target: { type: 'range', id: suggestion.nodeId || document.id, start, end, quote: suggestion.originalText },
|
|
502
|
+
before: suggestion.originalText, after: suggestion.suggestedText, reason: suggestion.reason || suggestion.description || 'Agent suggestion',
|
|
503
|
+
status: 'accepted', executable: suggestion.originalText !== suggestion.suggestedText, dependsOn: [], conflictsWith: [],
|
|
504
|
+
}],
|
|
505
|
+
graph: { nodes: [changeId], edges: [] }, recoveryPoint: null, origin: 'agent-suggestion',
|
|
506
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
507
|
+
};
|
|
508
|
+
if (!revision.changes[0].executable) throw problem('Suggestion does not change the source');
|
|
509
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
510
|
+
return applyRevision(workspaceRoot, revision.id);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export async function applySuggestionsAsRevision(workspaceRoot, file, suggestions) {
|
|
514
|
+
if (!Array.isArray(suggestions) || !suggestions.length) throw problem('No suggestions to apply');
|
|
515
|
+
const document = await syncDocumentStructure(workspaceRoot, file);
|
|
516
|
+
const safe = sanitizePath(file, workspaceRoot); if (!safe) throw problem('Access denied', 403);
|
|
517
|
+
const content = await readFile(safe, 'utf-8');
|
|
518
|
+
const candidateChanges = suggestions.map((suggestion) => {
|
|
519
|
+
if (!suggestion || typeof suggestion.originalText !== 'string' || typeof suggestion.suggestedText !== 'string') throw problem('Suggestion not found', 404);
|
|
520
|
+
if (suggestion.file && suggestion.file !== file) throw problem('Suggestion belongs to a different file', 409);
|
|
521
|
+
let start = suggestion.sourceRange?.start;
|
|
522
|
+
let end = suggestion.sourceRange?.end;
|
|
523
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
|
524
|
+
start = content.indexOf(suggestion.originalText); end = start + suggestion.originalText.length;
|
|
525
|
+
}
|
|
526
|
+
if (start < 0 || content.slice(start, end) !== suggestion.originalText) throw problem('Source text changed; generate new suggestions', 409, 'STALE_CHANGE');
|
|
527
|
+
return {
|
|
528
|
+
id: id('change'), target: { type: 'range', id: suggestion.nodeId || document.id, start, end, quote: suggestion.originalText },
|
|
529
|
+
before: suggestion.originalText, after: suggestion.suggestedText,
|
|
530
|
+
reason: suggestion.reason || suggestion.description || 'Agent suggestion', status: 'accepted', executable: suggestion.originalText !== suggestion.suggestedText,
|
|
531
|
+
taskId: suggestion.taskId || '', suggestionId: suggestion.id || '', dependsOn: [], conflictsWith: [],
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
const changes = [];
|
|
535
|
+
for (const candidate of candidateChanges) {
|
|
536
|
+
if (candidate.executable && !changes.some((accepted) => overlap(candidate, accepted))) changes.push(candidate);
|
|
537
|
+
}
|
|
538
|
+
if (!changes.length) throw problem('No non-overlapping source changes to apply');
|
|
539
|
+
const timestamp = now();
|
|
540
|
+
const revision = {
|
|
541
|
+
id: id('revision'), documentId: document.id, file, title: `AI revision · ${changes.length} change${changes.length === 1 ? '' : 's'}`,
|
|
542
|
+
summary: `Applied ${changes.length} Agent suggestion${changes.length === 1 ? '' : 's'} as one atomic revision.`, status: 'review', annotationIds: [], changes,
|
|
543
|
+
graph: { nodes: changes.map((item) => item.id), edges: [] }, recoveryPoint: null, origin: 'agent-batch', createdAt: timestamp, updatedAt: timestamp,
|
|
544
|
+
};
|
|
545
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
546
|
+
return applyRevision(workspaceRoot, revision.id);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function recordRejectedSuggestion(workspaceRoot, file, suggestion) {
|
|
550
|
+
if (!suggestion) throw problem('Suggestion not found', 404);
|
|
551
|
+
const document = await syncDocumentStructure(workspaceRoot, file);
|
|
552
|
+
const safe = sanitizePath(file, workspaceRoot); if (!safe) throw problem('Access denied', 403);
|
|
553
|
+
const content = await readFile(safe, 'utf-8');
|
|
554
|
+
const start = Number.isInteger(suggestion.sourceRange?.start) ? suggestion.sourceRange.start : Math.max(0, content.indexOf(suggestion.originalText));
|
|
555
|
+
const end = start + suggestion.originalText.length;
|
|
556
|
+
const timestamp = now(); const changeId = id('change');
|
|
557
|
+
const revision = {
|
|
558
|
+
id: id('revision'), documentId: document.id, file, title: `Rejected Agent suggestion · ${suggestion.category || 'edit'}`,
|
|
559
|
+
summary: suggestion.description || 'User rejected the Agent suggestion.', status: 'cancelled', annotationIds: [],
|
|
560
|
+
changes: [{
|
|
561
|
+
id: changeId, target: { type: 'range', id: suggestion.nodeId || document.id, start, end, quote: content.slice(start, end) === suggestion.originalText ? suggestion.originalText : '' },
|
|
562
|
+
before: suggestion.originalText, after: suggestion.suggestedText, reason: suggestion.reason || suggestion.description || 'Agent suggestion',
|
|
563
|
+
status: 'rejected', executable: suggestion.originalText !== suggestion.suggestedText, dependsOn: [], conflictsWith: [],
|
|
564
|
+
}],
|
|
565
|
+
graph: { nodes: [changeId], edges: [] }, recoveryPoint: null, origin: 'agent-suggestion',
|
|
566
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
567
|
+
};
|
|
568
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
569
|
+
return revision;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function insertGeneratedParagraph(workspaceRoot, { documentId, index, text, prompt = '', runId = '' }) {
|
|
573
|
+
if (!Number.isInteger(index) || index < 0) throw problem('index must be a non-negative integer');
|
|
574
|
+
if (typeof text !== 'string' || !text.trim()) throw problem('paragraph text is required');
|
|
575
|
+
const project = await loadProject(workspaceRoot);
|
|
576
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
577
|
+
if (!document) throw problem('Document not found', 404);
|
|
578
|
+
const generationRun = runId ? project.agentRuns.find((run) => run.id === runId && ['generate-paragraph', 'literature-review'].includes(run.operation) && run.status === 'complete') : null;
|
|
579
|
+
if (runId && !generationRun) {
|
|
580
|
+
throw problem('Paragraph generation run not found', 404);
|
|
581
|
+
}
|
|
582
|
+
await syncDocumentStructure(workspaceRoot, document.file);
|
|
583
|
+
const safe = sanitizePath(document.file, workspaceRoot); if (!safe) throw problem('Access denied', 403);
|
|
584
|
+
const content = await readFile(safe, 'utf-8');
|
|
585
|
+
if (index > content.length) throw problem('Insertion index is outside the document');
|
|
586
|
+
const prefix = index > 0 && !/\n\s*\n$/.test(content.slice(0, index)) ? '\n\n' : '';
|
|
587
|
+
const insertion = prefix + text;
|
|
588
|
+
const timestamp = now(); const changeId = id('change');
|
|
589
|
+
const revision = {
|
|
590
|
+
id: id('revision'), documentId, file: document.file, title: 'Generated paragraph insertion',
|
|
591
|
+
summary: prompt || 'Insert the user-approved Agent paragraph draft.', status: 'review', annotationIds: [],
|
|
592
|
+
changes: [{
|
|
593
|
+
id: changeId, target: { type: 'range', id: document.id, start: index, end: index, quote: '' },
|
|
594
|
+
before: '', after: insertion, reason: prompt || 'User approved the generated paragraph draft.',
|
|
595
|
+
status: 'accepted', executable: true, dependsOn: [], conflictsWith: [],
|
|
596
|
+
}],
|
|
597
|
+
graph: { nodes: [changeId], edges: [] }, recoveryPoint: null, origin: 'paragraph-generation',
|
|
598
|
+
generation: runId ? {
|
|
599
|
+
runId, instruction: prompt,
|
|
600
|
+
providedResourceIds: (() => { try { return (JSON.parse(generationRun.input).providedResources || []).map((item) => item.id); } catch { return []; } })(),
|
|
601
|
+
usedResourceIds: (() => { try { return JSON.parse(generationRun.output).usedResourceIds || []; } catch { return []; } })(),
|
|
602
|
+
} : undefined,
|
|
603
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
604
|
+
};
|
|
605
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
606
|
+
return applyRevision(workspaceRoot, revision.id);
|
|
607
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolve, sep } from 'path';
|
|
2
|
+
|
|
3
|
+
export function sanitizePath(input, root) {
|
|
4
|
+
if (!input || typeof input !== 'string') return null;
|
|
5
|
+
if (input.includes('\0')) return null;
|
|
6
|
+
const resolved = resolve(root, input);
|
|
7
|
+
if (!resolved.startsWith(root + sep) && resolved !== root) return null;
|
|
8
|
+
return resolved;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function securityHeaders(req, res, next) {
|
|
12
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
13
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
14
|
+
res.removeHeader('X-Powered-By');
|
|
15
|
+
next();
|
|
16
|
+
}
|