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,662 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { loadProject, updateProject } from './project-store.js';
|
|
3
|
+
import { createAgentRun, updateAgentRun } from './project-resources.js';
|
|
4
|
+
import { composeMockSuggestions } from './agent.js';
|
|
5
|
+
import { buildLibraryContext, composeMockParagraph } from './library-engine.js';
|
|
6
|
+
import { DEFAULT_REVIEW_RUBRIC, generateMockPeerReview } from './review-panel.js';
|
|
7
|
+
import { composeMockPaper } from './revise-workflow.js';
|
|
8
|
+
import { runAcademicReviewAgent, runPaperGenerationAgent, runWritingAgent } from './agent-adapters.js';
|
|
9
|
+
import { agentFailureAudit } from './agent-errors.js';
|
|
10
|
+
|
|
11
|
+
export const ORCHESTRATION_CAPABILITIES = ['suggest', 'review', 'paragraph', 'generate'];
|
|
12
|
+
export const ORCHESTRATION_PROVIDERS = ['mock', 'codex', 'claude-code', 'opencode', 'pi'];
|
|
13
|
+
export const ORCHESTRATION_NODE_KINDS = ['agent', 'gate'];
|
|
14
|
+
export const ORCHESTRATION_NODE_STATUSES = ['idle', 'queued', 'running', 'complete', 'failed', 'waiting', 'skipped'];
|
|
15
|
+
const REVIEW_ROLES = ['methodology', 'statistics', 'writing', 'domain', 'reproducibility'];
|
|
16
|
+
const MAX_CONCURRENCY = 4;
|
|
17
|
+
const MAX_NODES = 50;
|
|
18
|
+
const MAX_EDGES = 200;
|
|
19
|
+
const MAX_UPSTREAM_CHARS = 20_000;
|
|
20
|
+
|
|
21
|
+
function now() { return new Date().toISOString(); }
|
|
22
|
+
function id(prefix) { return `${prefix}_${randomUUID()}`; }
|
|
23
|
+
function clean(value) { return typeof value === 'string' ? value.trim() : ''; }
|
|
24
|
+
function problem(message, status = 400, code = '') {
|
|
25
|
+
const error = new Error(message);
|
|
26
|
+
error.status = status;
|
|
27
|
+
if (code) error.code = code;
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
function summarize(value, limit = 240) {
|
|
31
|
+
const text = clean(value);
|
|
32
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Node / edge normalization for create and update
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function normalizeReviewer(input, previous = null) {
|
|
40
|
+
if (input == null && previous == null) return null;
|
|
41
|
+
if (input == null) return previous;
|
|
42
|
+
if (input.role !== undefined && !REVIEW_ROLES.includes(input.role)) throw problem('reviewer.role is invalid');
|
|
43
|
+
return {
|
|
44
|
+
name: clean(input?.name) || previous?.name || 'Domain reviewer',
|
|
45
|
+
role: REVIEW_ROLES.includes(input?.role) ? input.role : (REVIEW_ROLES.includes(previous?.role) ? previous.role : 'domain'),
|
|
46
|
+
focus: clean(input?.focus) || previous?.focus || 'General academic quality and correctness.',
|
|
47
|
+
prompt: typeof input?.prompt === 'string' ? input.prompt : (previous?.prompt || ''),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRubric(input, previous = null) {
|
|
52
|
+
const source = Array.isArray(input) ? input : (Array.isArray(previous) ? previous : []);
|
|
53
|
+
return source.map((criterion, index) => ({
|
|
54
|
+
id: clean(criterion?.id) || `rubric_${index + 1}`,
|
|
55
|
+
title: clean(criterion?.title) || `Criterion ${index + 1}`,
|
|
56
|
+
instruction: typeof criterion?.instruction === 'string' ? criterion.instruction : '',
|
|
57
|
+
weight: Number.isFinite(criterion?.weight) && criterion.weight > 0 ? criterion.weight : 1,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildDefaultNode({ kind = 'agent', x = 0, y = 0 } = {}) {
|
|
62
|
+
const node = {
|
|
63
|
+
id: id('node'),
|
|
64
|
+
kind: kind === 'gate' ? 'gate' : 'agent',
|
|
65
|
+
label: kind === 'gate' ? 'Approval gate' : 'Agent node',
|
|
66
|
+
x, y,
|
|
67
|
+
prompt: '',
|
|
68
|
+
source: { type: 'manual', nodeId: '', text: '' },
|
|
69
|
+
reviewer: null,
|
|
70
|
+
rubric: [],
|
|
71
|
+
status: 'idle',
|
|
72
|
+
note: '',
|
|
73
|
+
output: null,
|
|
74
|
+
runId: '',
|
|
75
|
+
error: '',
|
|
76
|
+
startedAt: '',
|
|
77
|
+
finishedAt: '',
|
|
78
|
+
};
|
|
79
|
+
if (node.kind === 'agent') {
|
|
80
|
+
node.provider = 'mock';
|
|
81
|
+
node.capability = 'suggest';
|
|
82
|
+
} else {
|
|
83
|
+
node.decision = 'pending';
|
|
84
|
+
}
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeOrchestrationInput(input, existing = null) {
|
|
89
|
+
const existingById = new Map((existing?.nodes || []).map((node) => [node.id, node]));
|
|
90
|
+
const name = clean(input?.name) || existing?.name || 'Multi-agent workflow';
|
|
91
|
+
if (name.length > 120) throw problem('name must be at most 120 characters');
|
|
92
|
+
|
|
93
|
+
const nodeInputs = Array.isArray(input?.nodes) ? input.nodes : (existing?.nodes || []);
|
|
94
|
+
if (nodeInputs.length > MAX_NODES) throw problem(`nodes must contain at most ${MAX_NODES} nodes`);
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const nodes = nodeInputs.map((raw, index) => {
|
|
97
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw problem(`nodes[${index}] must be an object`);
|
|
98
|
+
const previous = existingById.get(raw.id);
|
|
99
|
+
const kind = raw.kind === 'gate' ? 'gate' : 'agent';
|
|
100
|
+
const node = {
|
|
101
|
+
id: clean(raw.id) || id('node'),
|
|
102
|
+
kind,
|
|
103
|
+
label: clean(raw.label) || (kind === 'gate' ? 'Approval gate' : `Agent ${index + 1}`),
|
|
104
|
+
x: Number.isFinite(raw.x) ? raw.x : (previous?.x ?? 0),
|
|
105
|
+
y: Number.isFinite(raw.y) ? raw.y : (previous?.y ?? 0),
|
|
106
|
+
prompt: typeof raw.prompt === 'string' ? raw.prompt : (previous?.prompt || ''),
|
|
107
|
+
source: {
|
|
108
|
+
type: raw.source?.type === 'upstream' ? 'upstream' : 'manual',
|
|
109
|
+
nodeId: clean(raw.source?.nodeId) || (previous?.source?.nodeId || ''),
|
|
110
|
+
text: typeof raw.source?.text === 'string' ? raw.source.text : (previous?.source?.text || ''),
|
|
111
|
+
},
|
|
112
|
+
reviewer: normalizeReviewer(raw.reviewer, previous?.reviewer),
|
|
113
|
+
rubric: normalizeRubric(raw.rubric, previous?.rubric),
|
|
114
|
+
status: previous?.status || 'idle',
|
|
115
|
+
note: kind === 'gate' ? (typeof raw.note === 'string' ? raw.note : (previous?.note || '')) : '',
|
|
116
|
+
output: previous?.output ?? null,
|
|
117
|
+
runId: previous?.runId || '',
|
|
118
|
+
error: previous?.error || '',
|
|
119
|
+
startedAt: previous?.startedAt || '',
|
|
120
|
+
finishedAt: previous?.finishedAt || '',
|
|
121
|
+
};
|
|
122
|
+
if (kind === 'agent') {
|
|
123
|
+
if (raw.provider !== undefined && !ORCHESTRATION_PROVIDERS.includes(raw.provider)) throw problem(`nodes[${index}].provider is invalid`);
|
|
124
|
+
if (raw.capability !== undefined && !ORCHESTRATION_CAPABILITIES.includes(raw.capability)) throw problem(`nodes[${index}].capability is invalid`);
|
|
125
|
+
node.provider = ORCHESTRATION_PROVIDERS.includes(raw.provider) ? raw.provider
|
|
126
|
+
: (ORCHESTRATION_PROVIDERS.includes(previous?.provider) ? previous.provider : 'mock');
|
|
127
|
+
node.capability = ORCHESTRATION_CAPABILITIES.includes(raw.capability) ? raw.capability
|
|
128
|
+
: (ORCHESTRATION_CAPABILITIES.includes(previous?.capability) ? previous.capability : 'suggest');
|
|
129
|
+
} else {
|
|
130
|
+
if (raw.decision !== undefined && !['pending', 'approved', 'rejected'].includes(raw.decision)) throw problem(`nodes[${index}].decision is invalid`);
|
|
131
|
+
node.decision = ['pending', 'approved', 'rejected'].includes(raw.decision) ? raw.decision : (previous?.decision || 'pending');
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(node.id)) throw problem(`nodes[${index}].id is duplicated`);
|
|
134
|
+
seen.add(node.id);
|
|
135
|
+
return node;
|
|
136
|
+
});
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if (node.source.type === 'upstream' && node.source.nodeId && !seen.has(node.source.nodeId)) {
|
|
139
|
+
throw problem(`nodes source.nodeId does not reference another node: ${node.source.nodeId}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const edgeInputs = Array.isArray(input?.edges) ? input.edges : (existing?.edges || []);
|
|
144
|
+
if (edgeInputs.length > MAX_EDGES) throw problem(`edges must contain at most ${MAX_EDGES} edges`);
|
|
145
|
+
const pairs = new Set();
|
|
146
|
+
const edges = edgeInputs.map((raw, index) => {
|
|
147
|
+
const source = clean(raw?.source);
|
|
148
|
+
const target = clean(raw?.target);
|
|
149
|
+
if (!source || !target) throw problem(`edges[${index}] needs source and target`);
|
|
150
|
+
if (source !== 'start' && !seen.has(source)) throw problem(`edges[${index}].source does not reference a node`);
|
|
151
|
+
if (target !== 'end' && !seen.has(target)) throw problem(`edges[${index}].target does not reference a node`);
|
|
152
|
+
if (target === 'start' || source === 'end') throw problem(`edges[${index}] has an invalid endpoint`);
|
|
153
|
+
if (source === target && source !== 'start' && source !== 'end') throw problem(`edges[${index}] cannot be a self loop`);
|
|
154
|
+
const pair = `${source}->${target}`;
|
|
155
|
+
if (pairs.has(pair)) throw problem(`edges[${index}] duplicates an existing edge`);
|
|
156
|
+
pairs.add(pair);
|
|
157
|
+
return {
|
|
158
|
+
id: clean(raw?.id) || id('edge'),
|
|
159
|
+
source,
|
|
160
|
+
target,
|
|
161
|
+
summary: typeof raw?.summary === 'string' ? raw.summary : '',
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
return { name, nodes, edges };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function structuralKey(node) {
|
|
168
|
+
return JSON.stringify({
|
|
169
|
+
id: node.id, kind: node.kind, provider: node.provider, capability: node.capability,
|
|
170
|
+
label: node.label, prompt: node.prompt, source: node.source, reviewer: node.reviewer, rubric: node.rubric,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function graphStructureKey(orchestration) {
|
|
175
|
+
return [
|
|
176
|
+
orchestration.nodes.map(structuralKey).join('|'),
|
|
177
|
+
orchestration.edges.map((edge) => `${edge.source}->${edge.target}`).join('|'),
|
|
178
|
+
].join('||');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// CRUD
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
export async function listOrchestrations(workspaceRoot) {
|
|
186
|
+
return (await loadProject(workspaceRoot)).orchestrations;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function getOrchestration(workspaceRoot, orchestrationId) {
|
|
190
|
+
const orchestration = (await loadProject(workspaceRoot)).orchestrations.find((item) => item.id === orchestrationId);
|
|
191
|
+
if (!orchestration) throw problem('Orchestration not found', 404);
|
|
192
|
+
return orchestration;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function createOrchestration(workspaceRoot, input = {}) {
|
|
196
|
+
const timestamp = now();
|
|
197
|
+
const normalized = normalizeOrchestrationInput(input, null);
|
|
198
|
+
const orchestration = {
|
|
199
|
+
id: id('orchestration'),
|
|
200
|
+
name: normalized.name,
|
|
201
|
+
status: 'draft',
|
|
202
|
+
nodes: normalized.nodes.length ? normalized.nodes : [buildDefaultNode()],
|
|
203
|
+
edges: normalized.edges,
|
|
204
|
+
createdAt: timestamp,
|
|
205
|
+
updatedAt: timestamp,
|
|
206
|
+
};
|
|
207
|
+
await updateProject(workspaceRoot, (draft) => draft.orchestrations.push(orchestration));
|
|
208
|
+
return structuredClone(orchestration);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function updateOrchestration(workspaceRoot, orchestrationId, input = {}) {
|
|
212
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
213
|
+
const index = draft.orchestrations.findIndex((item) => item.id === orchestrationId);
|
|
214
|
+
if (index === -1) throw problem('Orchestration not found', 404);
|
|
215
|
+
const current = draft.orchestrations[index];
|
|
216
|
+
if (current.status === 'running') throw problem('Cannot edit an orchestration while it is running', 409);
|
|
217
|
+
const previousStructure = graphStructureKey(current);
|
|
218
|
+
const normalized = normalizeOrchestrationInput(input, current);
|
|
219
|
+
const structuralChange = previousStructure !== graphStructureKey({ ...current, ...normalized });
|
|
220
|
+
const updated = { ...current, ...normalized, updatedAt: now() };
|
|
221
|
+
if (structuralChange) {
|
|
222
|
+
updated.status = 'draft';
|
|
223
|
+
for (const node of updated.nodes) {
|
|
224
|
+
node.status = 'idle';
|
|
225
|
+
node.output = null;
|
|
226
|
+
node.runId = '';
|
|
227
|
+
node.error = '';
|
|
228
|
+
node.startedAt = '';
|
|
229
|
+
node.finishedAt = '';
|
|
230
|
+
if (node.kind === 'gate') { node.decision = 'pending'; node.note = ''; }
|
|
231
|
+
}
|
|
232
|
+
for (const edge of updated.edges) edge.summary = '';
|
|
233
|
+
}
|
|
234
|
+
draft.orchestrations[index] = updated;
|
|
235
|
+
return structuredClone(updated);
|
|
236
|
+
});
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function deleteOrchestration(workspaceRoot, orchestrationId) {
|
|
241
|
+
await updateProject(workspaceRoot, (draft) => {
|
|
242
|
+
const index = draft.orchestrations.findIndex((item) => item.id === orchestrationId);
|
|
243
|
+
if (index === -1) throw problem('Orchestration not found', 404);
|
|
244
|
+
if (draft.orchestrations[index].status === 'running') throw problem('Cannot delete an orchestration while it is running', 409);
|
|
245
|
+
draft.orchestrations.splice(index, 1);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function resetOrchestration(workspaceRoot, orchestrationId) {
|
|
250
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
251
|
+
const current = draft.orchestrations.find((item) => item.id === orchestrationId);
|
|
252
|
+
if (!current) throw problem('Orchestration not found', 404);
|
|
253
|
+
if (current.status === 'running') throw problem('Cannot reset an orchestration while it is running', 409);
|
|
254
|
+
current.status = 'draft';
|
|
255
|
+
for (const node of current.nodes) {
|
|
256
|
+
node.status = 'idle';
|
|
257
|
+
node.output = null;
|
|
258
|
+
node.runId = '';
|
|
259
|
+
node.error = '';
|
|
260
|
+
node.startedAt = '';
|
|
261
|
+
node.finishedAt = '';
|
|
262
|
+
if (node.kind === 'gate') { node.decision = 'pending'; node.note = ''; }
|
|
263
|
+
}
|
|
264
|
+
for (const edge of current.edges) edge.summary = '';
|
|
265
|
+
current.updatedAt = now();
|
|
266
|
+
return structuredClone(current);
|
|
267
|
+
});
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Graph analysis
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function findGraphCycle(nodes, edges) {
|
|
276
|
+
const adjacency = new Map(nodes.map((node) => [node.id, []]));
|
|
277
|
+
for (const edge of edges) {
|
|
278
|
+
if (edge.source !== 'start' && edge.target !== 'end'
|
|
279
|
+
&& adjacency.has(edge.source) && adjacency.has(edge.target)) {
|
|
280
|
+
adjacency.get(edge.source).push(edge.target);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const color = new Map(nodes.map((node) => [node.id, 0])); // 0 white, 1 gray, 2 black
|
|
284
|
+
const stack = [];
|
|
285
|
+
const visit = (nodeId) => {
|
|
286
|
+
color.set(nodeId, 1);
|
|
287
|
+
stack.push(nodeId);
|
|
288
|
+
for (const next of adjacency.get(nodeId) || []) {
|
|
289
|
+
if (color.get(next) === 1) return stack.slice(stack.indexOf(next)).concat(next);
|
|
290
|
+
if (color.get(next) === 0) {
|
|
291
|
+
const cycle = visit(next);
|
|
292
|
+
if (cycle) return cycle;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
stack.pop();
|
|
296
|
+
color.set(nodeId, 2);
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
for (const node of nodes) {
|
|
300
|
+
if (color.get(node.id) === 0) {
|
|
301
|
+
const cycle = visit(node.id);
|
|
302
|
+
if (cycle) return cycle;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function incomingSatisfied(node, edges, nodeById) {
|
|
309
|
+
const incoming = edges.filter((edge) => edge.target === node.id);
|
|
310
|
+
if (!incoming.length) return true;
|
|
311
|
+
return incoming.every((edge) => edge.source === 'start' || nodeById.get(edge.source)?.status === 'complete');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveNodeInput(node, nodes) {
|
|
315
|
+
if (node.source?.type === 'upstream' && node.source.nodeId) {
|
|
316
|
+
const upstream = nodes.find((item) => item.id === node.source.nodeId);
|
|
317
|
+
const output = upstream?.output;
|
|
318
|
+
if (output?.data) {
|
|
319
|
+
const data = typeof output.data === 'string' ? output.data : JSON.stringify(output.data);
|
|
320
|
+
return `[Upstream "${upstream.label}" output]\n${output.summary || ''}\n${data.slice(0, MAX_UPSTREAM_CHARS)}`;
|
|
321
|
+
}
|
|
322
|
+
if (output?.summary) return output.summary;
|
|
323
|
+
}
|
|
324
|
+
return node.source?.text || '';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Node executors (mock is deterministic; external goes through the adapters)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function defaultReviewer(node) {
|
|
332
|
+
return {
|
|
333
|
+
name: clean(node?.reviewer?.name) || 'Domain reviewer',
|
|
334
|
+
role: REVIEW_ROLES.includes(node?.reviewer?.role) ? node.reviewer.role : 'domain',
|
|
335
|
+
focus: clean(node?.reviewer?.focus) || 'General academic quality and correctness.',
|
|
336
|
+
prompt: clean(node?.reviewer?.prompt),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function nodeRubric(node) {
|
|
341
|
+
return Array.isArray(node?.rubric) && node.rubric.length ? node.rubric : DEFAULT_REVIEW_RUBRIC;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function runMockNode(node, project, inputText) {
|
|
345
|
+
const capability = node.capability;
|
|
346
|
+
if (capability === 'suggest') {
|
|
347
|
+
const suggestions = composeMockSuggestions(inputText || ' ', node.prompt || '');
|
|
348
|
+
return { summary: `${suggestions.length} suggestion(s) generated from the supplied input.`, data: JSON.stringify(suggestions), contentType: 'suggestions' };
|
|
349
|
+
}
|
|
350
|
+
if (capability === 'review') {
|
|
351
|
+
const reviewer = defaultReviewer(node);
|
|
352
|
+
const rubric = nodeRubric(node);
|
|
353
|
+
const result = generateMockPeerReview(inputText || ' ', reviewer, rubric);
|
|
354
|
+
return { summary: result.summary, data: JSON.stringify(result), contentType: 'review' };
|
|
355
|
+
}
|
|
356
|
+
if (capability === 'paragraph') {
|
|
357
|
+
const libraries = project.libraries;
|
|
358
|
+
const context = buildLibraryContext(libraries, { query: [node.prompt, inputText].filter(Boolean).join(' ') });
|
|
359
|
+
const composed = composeMockParagraph(libraries, context, node.prompt || 'Draft a paragraph that fulfills the writing context.');
|
|
360
|
+
return { summary: summarize(composed.draft, 300), data: JSON.stringify(composed), contentType: 'paragraph' };
|
|
361
|
+
}
|
|
362
|
+
if (capability === 'generate') {
|
|
363
|
+
const document = project.documents[0];
|
|
364
|
+
const libraries = project.libraries;
|
|
365
|
+
const context = buildLibraryContext(libraries, {
|
|
366
|
+
query: [node.prompt, project.project.corePrompt, document?.corePrompt].filter(Boolean).join(' '),
|
|
367
|
+
});
|
|
368
|
+
const generated = composeMockPaper(project.project, document, libraries, context, node.prompt || '');
|
|
369
|
+
return { summary: generated.summary, data: JSON.stringify(generated), contentType: 'generated-paper' };
|
|
370
|
+
}
|
|
371
|
+
throw problem(`Unknown capability: ${capability}`, 400);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function runExternalNode(node, inputText, options) {
|
|
375
|
+
const capability = node.capability;
|
|
376
|
+
if (capability === 'suggest') {
|
|
377
|
+
const result = await runWritingAgent(node.provider, {
|
|
378
|
+
content: inputText || ' ',
|
|
379
|
+
prompt: node.prompt || 'Improve the supplied academic text.',
|
|
380
|
+
resourceContext: '', resourceIds: [],
|
|
381
|
+
}, options);
|
|
382
|
+
return { summary: summarize(result.summary, 300), data: JSON.stringify({ ...result, agentMeta: result.agentMeta }), contentType: 'suggestions' };
|
|
383
|
+
}
|
|
384
|
+
if (capability === 'review') {
|
|
385
|
+
const reviewer = defaultReviewer(node);
|
|
386
|
+
const rubric = nodeRubric(node);
|
|
387
|
+
const result = await runAcademicReviewAgent(node.provider, { content: inputText || ' ', reviewer, rubric }, options);
|
|
388
|
+
return { summary: summarize(result.summary, 300), data: JSON.stringify({ ...result, agentMeta: result.agentMeta }), contentType: 'review' };
|
|
389
|
+
}
|
|
390
|
+
if (capability === 'paragraph') {
|
|
391
|
+
const sentinel = '[[PAPERGOD_PARAGRAPH_DRAFT]]';
|
|
392
|
+
const result = await runWritingAgent(node.provider, {
|
|
393
|
+
content: sentinel,
|
|
394
|
+
prompt: `${inputText ? `Upstream context:\n${inputText.slice(0, MAX_UPSTREAM_CHARS)}\n\n` : ''}${node.prompt || 'Draft a paragraph.'}\nReturn exactly one suggestion that replaces the entire text ${sentinel} with a single cohesive academic paragraph.`,
|
|
395
|
+
resourceContext: '', resourceIds: [],
|
|
396
|
+
}, options);
|
|
397
|
+
const draft = result.suggestions?.find((item) => item.originalText === sentinel)?.suggestedText || result.suggestions?.[0]?.suggestedText || '';
|
|
398
|
+
if (!draft.trim()) throw problem('Agent did not return a paragraph draft', 502);
|
|
399
|
+
return { summary: summarize(draft, 300), data: JSON.stringify({ draft, summary: result.summary, agentMeta: result.agentMeta }), contentType: 'paragraph' };
|
|
400
|
+
}
|
|
401
|
+
if (capability === 'generate') {
|
|
402
|
+
const result = await runPaperGenerationAgent(node.provider, {
|
|
403
|
+
instruction: [node.prompt, inputText ? `Upstream context:\n${inputText.slice(0, MAX_UPSTREAM_CHARS)}` : ''].filter(Boolean).join('\n') || 'Generate a complete academic paper.',
|
|
404
|
+
projectContext: '', outlineContext: '', resourceContext: '', resourceIds: [],
|
|
405
|
+
}, options);
|
|
406
|
+
return { summary: summarize(result.summary, 300), data: JSON.stringify({ ...result, agentMeta: result.agentMeta }), contentType: 'generated-paper' };
|
|
407
|
+
}
|
|
408
|
+
throw problem(`Unknown capability: ${capability}`, 400);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Persistence helpers used by the scheduler
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
async function patchOrchestration(workspaceRoot, orchestrationId, mutate) {
|
|
416
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
417
|
+
const orchestration = draft.orchestrations.find((item) => item.id === orchestrationId);
|
|
418
|
+
if (!orchestration) return null;
|
|
419
|
+
mutate(orchestration);
|
|
420
|
+
orchestration.updatedAt = now();
|
|
421
|
+
return structuredClone(orchestration);
|
|
422
|
+
});
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function setNodeFields(workspaceRoot, orchestrationId, nodeId, fields) {
|
|
427
|
+
return patchOrchestration(workspaceRoot, orchestrationId, (orchestration) => {
|
|
428
|
+
const node = orchestration.nodes.find((item) => item.id === nodeId);
|
|
429
|
+
if (node) Object.assign(node, fields);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function propagateEdgeSummaries(workspaceRoot, orchestrationId, nodeId, output) {
|
|
434
|
+
const summary = summarize(output?.summary || output?.data, 240);
|
|
435
|
+
await patchOrchestration(workspaceRoot, orchestrationId, (orchestration) => {
|
|
436
|
+
for (const edge of orchestration.edges) {
|
|
437
|
+
if (edge.source === nodeId) edge.summary = summary;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function executeAgentNode(workspaceRoot, orchestrationId, nodeId, options) {
|
|
443
|
+
let project = await loadProject(workspaceRoot);
|
|
444
|
+
let orchestration = project.orchestrations.find((item) => item.id === orchestrationId);
|
|
445
|
+
let node = orchestration?.nodes.find((item) => item.id === nodeId);
|
|
446
|
+
if (!node) return { ok: false, error: 'Node not found' };
|
|
447
|
+
const inputText = resolveNodeInput(node, orchestration.nodes);
|
|
448
|
+
const startedAt = now();
|
|
449
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
450
|
+
provider: node.provider,
|
|
451
|
+
operation: `orchestrate:${node.capability}`,
|
|
452
|
+
status: node.provider === 'mock' ? 'queued' : 'running',
|
|
453
|
+
prompt: node.prompt || '',
|
|
454
|
+
input: JSON.stringify({ characters: inputText.length, source: node.source }),
|
|
455
|
+
output: '', error: '', startedAt, finishedAt: '',
|
|
456
|
+
});
|
|
457
|
+
await setNodeFields(workspaceRoot, orchestrationId, nodeId, { status: 'running', runId: run.id, startedAt, error: '' });
|
|
458
|
+
try {
|
|
459
|
+
// Re-read so the executor works against the freshest project metadata.
|
|
460
|
+
project = await loadProject(workspaceRoot);
|
|
461
|
+
orchestration = project.orchestrations.find((item) => item.id === orchestrationId);
|
|
462
|
+
node = orchestration?.nodes.find((item) => item.id === nodeId);
|
|
463
|
+
const output = node.provider === 'mock'
|
|
464
|
+
? await runMockNode(node, project, inputText)
|
|
465
|
+
: await runExternalNode(node, inputText, {
|
|
466
|
+
workspaceRoot, commands: options.commands || {}, signal: options.signal,
|
|
467
|
+
});
|
|
468
|
+
const finishedAt = now();
|
|
469
|
+
await setNodeFields(workspaceRoot, orchestrationId, nodeId, { status: 'complete', output, finishedAt });
|
|
470
|
+
let agentMeta;
|
|
471
|
+
try { agentMeta = JSON.parse(output.data)?.agentMeta; } catch {}
|
|
472
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'complete', output: JSON.stringify({ summary: output.summary, characters: output.data.length, agentMeta }), finishedAt });
|
|
473
|
+
await propagateEdgeSummaries(workspaceRoot, orchestrationId, nodeId, output);
|
|
474
|
+
return { ok: true, nodeId };
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const finishedAt = now();
|
|
477
|
+
const message = String(error?.message || 'Agent node failed').slice(0, 4000);
|
|
478
|
+
await setNodeFields(workspaceRoot, orchestrationId, nodeId, { status: 'failed', error: message, finishedAt });
|
|
479
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'failed', error: agentFailureAudit(error), finishedAt });
|
|
480
|
+
return { ok: false, nodeId, error: message };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// Run manager: in-memory execution state per app instance
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
function sleepOrWake(state, ms) {
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
const timer = setTimeout(() => { state.wake = null; resolve(); }, ms);
|
|
491
|
+
state.wake = () => { clearTimeout(timer); resolve(); };
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function wakeState(state) {
|
|
496
|
+
if (state.wake) {
|
|
497
|
+
const wake = state.wake;
|
|
498
|
+
state.wake = null;
|
|
499
|
+
wake();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function createOrchestrationManager() {
|
|
504
|
+
const activeRuns = new Map();
|
|
505
|
+
|
|
506
|
+
const isRunning = (orchestrationId) => activeRuns.has(orchestrationId);
|
|
507
|
+
const anyRunning = () => activeRuns.size > 0;
|
|
508
|
+
|
|
509
|
+
async function settleRun(workspaceRoot, orchestrationId, status) {
|
|
510
|
+
await patchOrchestration(workspaceRoot, orchestrationId, (orchestration) => {
|
|
511
|
+
if (orchestration.status !== 'running') return;
|
|
512
|
+
orchestration.status = status;
|
|
513
|
+
if (status === 'failed' || status === 'cancelled') {
|
|
514
|
+
for (const node of orchestration.nodes) {
|
|
515
|
+
if (node.status === 'idle' || node.status === 'waiting') node.status = 'skipped';
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function scheduler(workspaceRoot, orchestrationId, state, options) {
|
|
522
|
+
try {
|
|
523
|
+
while (true) {
|
|
524
|
+
if (state.controller.signal.aborted || state.cancelled) {
|
|
525
|
+
await settleRun(workspaceRoot, orchestrationId, 'cancelled');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const project = await loadProject(workspaceRoot);
|
|
529
|
+
const orchestration = project.orchestrations.find((item) => item.id === orchestrationId);
|
|
530
|
+
if (!orchestration || orchestration.status !== 'running') return;
|
|
531
|
+
const nodeById = new Map(orchestration.nodes.map((node) => [node.id, node]));
|
|
532
|
+
const runnable = orchestration.nodes.filter((node) => node.status === 'idle' && incomingSatisfied(node, orchestration.edges, nodeById));
|
|
533
|
+
const running = orchestration.nodes.filter((node) => node.status === 'running');
|
|
534
|
+
const waiting = orchestration.nodes.filter((node) => node.status === 'waiting');
|
|
535
|
+
if (!runnable.length) {
|
|
536
|
+
if (!running.length && !waiting.length) {
|
|
537
|
+
await settleRun(workspaceRoot, orchestrationId, 'complete');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
await sleepOrWake(state, 300);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
for (const gate of runnable.filter((node) => node.kind === 'gate')) {
|
|
544
|
+
await setNodeFields(workspaceRoot, orchestrationId, gate.id, { status: 'waiting', startedAt: now() });
|
|
545
|
+
}
|
|
546
|
+
const agents = runnable.filter((node) => node.kind === 'agent').slice(0, MAX_CONCURRENCY);
|
|
547
|
+
if (agents.length) {
|
|
548
|
+
const results = await Promise.all(agents.map((node) => executeAgentNode(workspaceRoot, orchestrationId, node.id, options)));
|
|
549
|
+
if (results.some((result) => !result?.ok)) {
|
|
550
|
+
await settleRun(workspaceRoot, orchestrationId, 'failed');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
await settleRun(workspaceRoot, orchestrationId, 'failed');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function runOrchestration(workspaceRoot, orchestrationId, options = {}) {
|
|
561
|
+
if (activeRuns.has(orchestrationId)) throw problem('Orchestration is already running', 409, 'ORCHESTRATION_BUSY');
|
|
562
|
+
const project = await loadProject(workspaceRoot);
|
|
563
|
+
const orchestration = project.orchestrations.find((item) => item.id === orchestrationId);
|
|
564
|
+
if (!orchestration) throw problem('Orchestration not found', 404);
|
|
565
|
+
if (orchestration.status === 'running') throw problem('Orchestration is already running', 409, 'ORCHESTRATION_BUSY');
|
|
566
|
+
if (!orchestration.nodes.length) throw problem('The orchestration has no nodes', 400);
|
|
567
|
+
const cycle = findGraphCycle(orchestration.nodes, orchestration.edges);
|
|
568
|
+
if (cycle) throw problem(`The orchestration graph contains a cycle: ${cycle.join(' → ')}`, 400, 'CYCLIC_GRAPH');
|
|
569
|
+
await patchOrchestration(workspaceRoot, orchestrationId, (current) => {
|
|
570
|
+
current.status = 'running';
|
|
571
|
+
for (const node of current.nodes) {
|
|
572
|
+
node.status = 'idle';
|
|
573
|
+
node.output = null;
|
|
574
|
+
node.runId = '';
|
|
575
|
+
node.error = '';
|
|
576
|
+
node.startedAt = '';
|
|
577
|
+
node.finishedAt = '';
|
|
578
|
+
if (node.kind === 'gate') { node.decision = 'pending'; node.note = ''; }
|
|
579
|
+
}
|
|
580
|
+
for (const edge of current.edges) edge.summary = '';
|
|
581
|
+
});
|
|
582
|
+
const controller = new AbortController();
|
|
583
|
+
options.signal?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
584
|
+
const state = { controller, wake: null, cancelled: false };
|
|
585
|
+
activeRuns.set(orchestrationId, state);
|
|
586
|
+
setImmediate(() => scheduler(workspaceRoot, orchestrationId, state, options).finally(() => activeRuns.delete(orchestrationId)));
|
|
587
|
+
return { started: true, orchestrationId, status: 'running' };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function cancelOrchestration(orchestrationId) {
|
|
591
|
+
const state = activeRuns.get(orchestrationId);
|
|
592
|
+
if (!state) throw problem('Orchestration is not running', 409, 'ORCHESTRATION_NOT_RUNNING');
|
|
593
|
+
state.controller.abort();
|
|
594
|
+
wakeState(state);
|
|
595
|
+
return { ok: true };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function decideGate(workspaceRoot, orchestrationId, nodeId, decision, note = '') {
|
|
599
|
+
const state = activeRuns.get(orchestrationId);
|
|
600
|
+
if (!state) throw problem('Orchestration is not running', 409, 'ORCHESTRATION_NOT_RUNNING');
|
|
601
|
+
if (!['approved', 'rejected'].includes(decision)) throw problem('decision must be approved or rejected', 400);
|
|
602
|
+
const result = await patchOrchestration(workspaceRoot, orchestrationId, (orchestration) => {
|
|
603
|
+
const node = orchestration.nodes.find((item) => item.id === nodeId);
|
|
604
|
+
if (!node) throw problem('Gate node not found', 404);
|
|
605
|
+
if (node.kind !== 'gate') throw problem('Node is not a gate', 400);
|
|
606
|
+
if (node.status !== 'waiting') throw problem('Gate is not waiting for a decision', 409);
|
|
607
|
+
node.decision = decision;
|
|
608
|
+
node.note = typeof note === 'string' ? note.slice(0, 2000) : '';
|
|
609
|
+
node.status = 'complete';
|
|
610
|
+
node.finishedAt = now();
|
|
611
|
+
if (decision === 'rejected') {
|
|
612
|
+
const nodeIds = new Set(orchestration.nodes.map((item) => item.id));
|
|
613
|
+
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, []]));
|
|
614
|
+
for (const edge of orchestration.edges) {
|
|
615
|
+
if (edge.source !== 'start' && edge.target !== 'end' && adjacency.has(edge.source) && adjacency.has(edge.target)) {
|
|
616
|
+
adjacency.get(edge.source).push(edge.target);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const downstream = new Set();
|
|
620
|
+
const queue = [nodeId];
|
|
621
|
+
while (queue.length) {
|
|
622
|
+
const current = queue.shift();
|
|
623
|
+
for (const next of adjacency.get(current) || []) {
|
|
624
|
+
if (next !== nodeId && !downstream.has(next)) { downstream.add(next); queue.push(next); }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const item of orchestration.nodes) {
|
|
628
|
+
if (downstream.has(item.id) && (item.status === 'idle' || item.status === 'waiting')) item.status = 'skipped';
|
|
629
|
+
}
|
|
630
|
+
orchestration.status = 'cancelled';
|
|
631
|
+
state.cancelled = true;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
wakeState(state);
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function normalizeStaleRuns(workspaceRoot) {
|
|
639
|
+
const project = await loadProject(workspaceRoot);
|
|
640
|
+
const stale = project.orchestrations.filter((item) => item.status === 'running' && !activeRuns.has(item.id));
|
|
641
|
+
if (!stale.length) return 0;
|
|
642
|
+
let count = 0;
|
|
643
|
+
await updateProject(workspaceRoot, (draft) => {
|
|
644
|
+
for (const orchestration of draft.orchestrations) {
|
|
645
|
+
if (orchestration.status === 'running' && !activeRuns.has(orchestration.id)) {
|
|
646
|
+
orchestration.status = 'failed';
|
|
647
|
+
orchestration.updatedAt = now();
|
|
648
|
+
for (const node of orchestration.nodes) {
|
|
649
|
+
if (['running', 'queued', 'waiting'].includes(node.status)) {
|
|
650
|
+
node.status = node.status === 'waiting' ? 'skipped' : 'failed';
|
|
651
|
+
if (!node.error) node.error = 'Interrupted by server restart';
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
count += 1;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
return count;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return { runOrchestration, cancelOrchestration, decideGate, isRunning, anyRunning, normalizeStaleRuns };
|
|
662
|
+
}
|