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,1442 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { resolve, join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile, stat } from 'fs/promises';
|
|
5
|
+
import { createHash, randomUUID } from 'crypto';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { sanitizePath, securityHeaders } from './security.js';
|
|
8
|
+
import { detectEngines, compile } from './latex.js';
|
|
9
|
+
import { generateSuggestions, registerSuggestions, attachSuggestionContext, getSuggestion, removeSuggestion } from './agent.js';
|
|
10
|
+
import { loadProject, saveProject, updateProject } from './project-store.js';
|
|
11
|
+
import {
|
|
12
|
+
getLibraries, createLibraryResource, updateLibraryResource, deleteLibraryResource,
|
|
13
|
+
listAnnotations, createAnnotation, updateAnnotation, deleteAnnotation,
|
|
14
|
+
listRevisions, createRevision, updateRevision, deleteRevision,
|
|
15
|
+
createAgentRun, updateAgentRun, listAgentRuns,
|
|
16
|
+
} from './project-resources.js';
|
|
17
|
+
import { AGENT_PROVIDERS, buildSuggestionPayload, detectAgentProviders, runWritingAgent } from './agent-adapters.js';
|
|
18
|
+
import { alignSuggestionsToManifest, loadPromptManifest, materializePromptManifest } from './prompt-manifest.js';
|
|
19
|
+
import { agentFailureAudit, redactAgentDiagnostic } from './agent-errors.js';
|
|
20
|
+
import {
|
|
21
|
+
syncDocumentStructure, getDocumentStructure, updateDocumentMetadata,
|
|
22
|
+
updateNodeMetadata, getNodeSourceContext,
|
|
23
|
+
} from './document-structure.js';
|
|
24
|
+
import { buildLibraryContext, composeMockParagraph, extractLibraryCandidates, renderSentencePattern, searchLibraries } from './library-engine.js';
|
|
25
|
+
import { findStructureNode } from './latex-structure.js';
|
|
26
|
+
import { getChangeHistoryEntry, getHistoricalRevisionSource, getRecentChangeHistory } from './change-history.js';
|
|
27
|
+
import {
|
|
28
|
+
applyRevision, applySuggestionAsRevision, applySuggestionsAsRevision, createRevisionPlan, decideRevisionChanges, importReviewOpinions, insertGeneratedParagraph, orchestrateReviewOpinions, recordRejectedSuggestion, restoreRevisionVersion, rollbackRevision,
|
|
29
|
+
} from './revision-engine.js';
|
|
30
|
+
import {
|
|
31
|
+
createReviewRound, getReviewerProfileCatalog, listReviewRounds, runReviewRound, sendReviewItemsToRevision,
|
|
32
|
+
} from './review-panel.js';
|
|
33
|
+
import {
|
|
34
|
+
buildWorkflowExport, generatePaperRevision, generateRevisionPackage, getWorkflowHistory,
|
|
35
|
+
updateRevisionResponseLetter, verifyAppliedRevision,
|
|
36
|
+
} from './revise-workflow.js';
|
|
37
|
+
import { initializeWorkspace } from './workspace.js';
|
|
38
|
+
import { createWorkspaceRegistry } from './workspace-registry.js';
|
|
39
|
+
import { browseWorkspaceDirectories } from './workspace-browser.js';
|
|
40
|
+
import { createWorkspaceTerminalManager } from './workspace-terminal.js';
|
|
41
|
+
import {
|
|
42
|
+
addReferenceFolder, buildCitationContext, checkWorkspaceCitations, configureReferences, findUnknownAgentCitations, importReferences, parseBibTeX,
|
|
43
|
+
loadReferenceState, removeReferenceFolder, resolveStoredReference, scanAllReferenceFolders,
|
|
44
|
+
updateReference, writeBibliography,
|
|
45
|
+
} from './references.js';
|
|
46
|
+
import {
|
|
47
|
+
enrichZoteroAttachment, exportBetterBibTeX, getZoteroFullText, getZoteroStatus, listZoteroCollections, searchZoteroItems,
|
|
48
|
+
} from './zotero.js';
|
|
49
|
+
import { generateLiteratureReview } from './literature-review.js';
|
|
50
|
+
import { extractTextCandidates } from './text-extraction.js';
|
|
51
|
+
import { materializeLibraries } from './library-files.js';
|
|
52
|
+
import {
|
|
53
|
+
createOrchestration, createOrchestrationManager, deleteOrchestration, getOrchestration,
|
|
54
|
+
listOrchestrations, resetOrchestration, updateOrchestration,
|
|
55
|
+
} from './orchestration-engine.js';
|
|
56
|
+
import { analyzeStructure, ANALYSIS_FORMULAS } from './paragraph-analysis.js';
|
|
57
|
+
import { getAppVersionInfo } from './app-version.js';
|
|
58
|
+
|
|
59
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
61
|
+
const DEFAULT_WORKSPACE = join(PROJECT_ROOT, 'example');
|
|
62
|
+
const EXTERNAL_AGENT_PROVIDERS = AGENT_PROVIDERS.filter((item) => item !== 'mock');
|
|
63
|
+
|
|
64
|
+
function publicErrorMessage(error) {
|
|
65
|
+
const message = String(error?.message || 'Unexpected error');
|
|
66
|
+
if (error?.code === 'ENOENT') return 'The configured Agent CLI command was not found.';
|
|
67
|
+
if (error?.code === 'AGENT_TIMEOUT') return 'The Agent did not respond before the timeout.';
|
|
68
|
+
if (error?.code === 'AGENT_CONTENT_FILTERED') return 'The model output was interrupted by the provider content filter. No partial result was applied. Try a shorter or differently worded editing instruction.';
|
|
69
|
+
if (error?.code === 'AGENT_REFUSED') return 'The model refused this request. No partial result was applied.';
|
|
70
|
+
if (error?.code === 'AGENT_OUTPUT_TRUNCATED') return 'The model output ended before the structured result was complete. No partial result was applied.';
|
|
71
|
+
if (error?.code === 'AGENT_EMPTY_RESPONSE') return 'The Agent returned no usable structured result.';
|
|
72
|
+
if (error?.code === 'AGENT_CLI_INCOMPATIBLE') return 'The installed Agent CLI does not support Papergod’s required structured-output protocol. Update the CLI and retry.';
|
|
73
|
+
if (error?.code === 'AGENT_COOLDOWN') return `${message} Retry in ${Math.max(1, Math.ceil((error.retryAfterMs || 0) / 1000))} seconds or choose another configured provider.`;
|
|
74
|
+
if (/invalid_json_schema/i.test(message)) return 'The Agent rejected Papergod’s structured output schema. Update Papergod or choose another provider.';
|
|
75
|
+
if (/auth|sign.?in|log.?in|unauthorized|forbidden|\b401\b|\b403\b/i.test(message)) return 'The Agent CLI is installed but its model provider is not authenticated.';
|
|
76
|
+
if (/rate.?limit|quota|too many requests|\b429\b/i.test(message)) return 'The Agent provider rate limit or quota was reached. Try again later or choose another provider.';
|
|
77
|
+
if (message.length > 800 || message.split('\n').length > 8) return `${message.split('\n').find((line) => /error/i.test(line)) || message.split('\n')[0]}`.slice(0, 800);
|
|
78
|
+
return message;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resourceResponse(res, operation, successStatus = 200) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await operation();
|
|
84
|
+
res.status(successStatus).json(result);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.code === 'INVALID_PROJECT') {
|
|
87
|
+
return res.status(400).json({ error: error.message, details: error.details });
|
|
88
|
+
}
|
|
89
|
+
res.status(error.status || 500).json({ error: publicErrorMessage(error), code: error.code });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createApp(initialWorkspaceRoot = DEFAULT_WORKSPACE, options = {}) {
|
|
94
|
+
let workspaceRoot = resolve(initialWorkspaceRoot);
|
|
95
|
+
let provider = options.provider || 'mock';
|
|
96
|
+
const baseAgentCommands = { ...(options.agentCommands || {}) };
|
|
97
|
+
const agentCommands = { ...baseAgentCommands };
|
|
98
|
+
const agentProbeJobs = new Map();
|
|
99
|
+
const agentActivityJobs = new Map();
|
|
100
|
+
const agentConnectionState = new Map();
|
|
101
|
+
const recordAgentConnection = (id, update = {}) => {
|
|
102
|
+
const previous = agentConnectionState.get(id) || {};
|
|
103
|
+
const next = { ...previous, ...update, checkedAt: new Date().toISOString() };
|
|
104
|
+
agentConnectionState.set(id, next);
|
|
105
|
+
return next;
|
|
106
|
+
};
|
|
107
|
+
let activeWorkspaceRequests = 0;
|
|
108
|
+
const workspaceRegistry = createWorkspaceRegistry({ file: options.workspaceRegistryFile });
|
|
109
|
+
const terminalManager = options.terminalManager || createWorkspaceTerminalManager();
|
|
110
|
+
const orchestrationManager = createOrchestrationManager();
|
|
111
|
+
const app = express();
|
|
112
|
+
app.locals.cleanup = () => terminalManager.closeAll();
|
|
113
|
+
app.locals.config = { workspaceRoot, provider };
|
|
114
|
+
app.locals.orchestrations = orchestrationManager;
|
|
115
|
+
app.use(express.json({ limit: '10mb' }));
|
|
116
|
+
app.use(securityHeaders);
|
|
117
|
+
app.use('/vendor/codemirror', express.static(join(PROJECT_ROOT, 'node_modules', 'codemirror'), { dotfiles: 'deny' }));
|
|
118
|
+
app.use('/vendor/pdfjs-dist', express.static(join(PROJECT_ROOT, 'node_modules', 'pdfjs-dist'), { dotfiles: 'deny' }));
|
|
119
|
+
app.use(express.static(join(PROJECT_ROOT, 'public')));
|
|
120
|
+
app.use('/api', (req, res, next) => {
|
|
121
|
+
if (req.path === '/version' || req.path === '/workspaces' || req.path.startsWith('/workspaces/') || req.path === '/terminal' || req.path.startsWith('/terminal/')) return next();
|
|
122
|
+
activeWorkspaceRequests += 1;
|
|
123
|
+
let released = false;
|
|
124
|
+
const release = () => {
|
|
125
|
+
if (released) return;
|
|
126
|
+
released = true;
|
|
127
|
+
activeWorkspaceRequests = Math.max(0, activeWorkspaceRequests - 1);
|
|
128
|
+
};
|
|
129
|
+
res.once('finish', release);
|
|
130
|
+
res.once('close', release);
|
|
131
|
+
next();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function beginAgentActivity(id, activeProvider) {
|
|
135
|
+
if (typeof id !== 'string' || !/^[a-zA-Z0-9-]{8,80}$/.test(id)) return null;
|
|
136
|
+
const activity = { id, provider: activeProvider, status: 'running', output: '', startedAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
|
137
|
+
agentActivityJobs.set(id, activity);
|
|
138
|
+
return activity;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function appendAgentActivity(activity, stream, chunk) {
|
|
142
|
+
if (!activity || typeof chunk !== 'string') return;
|
|
143
|
+
const clean = chunk.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '').replace(/[^\x09\x0a\x0d\x20-\x7e\u0080-\uffff]/g, '');
|
|
144
|
+
if (!clean) return;
|
|
145
|
+
activity.rawOutput = `${activity.rawOutput || ''}${stream === 'stderr' ? '[stderr] ' : ''}${clean}`.slice(-50_000);
|
|
146
|
+
activity.output = redactAgentDiagnostic(activity.rawOutput, 40_000);
|
|
147
|
+
activity.updatedAt = new Date().toISOString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function finishAgentActivity(activity, status, message = '') {
|
|
151
|
+
if (!activity) return;
|
|
152
|
+
if (message) appendAgentActivity(activity, status === 'failed' ? 'stderr' : 'stdout', `${message}\n`);
|
|
153
|
+
activity.status = status;
|
|
154
|
+
activity.updatedAt = new Date().toISOString();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function hydrateAgentCommands(projectData = null, { loadProvider = false } = {}) {
|
|
158
|
+
const project = projectData || await loadProject(workspaceRoot);
|
|
159
|
+
const profiles = project.project.agentProfiles || {};
|
|
160
|
+
for (const id of EXTERNAL_AGENT_PROVIDERS) delete agentCommands[id];
|
|
161
|
+
Object.assign(agentCommands, baseAgentCommands);
|
|
162
|
+
for (const id of EXTERNAL_AGENT_PROVIDERS) {
|
|
163
|
+
const profile = profiles[id];
|
|
164
|
+
if (profile?.command?.trim()) {
|
|
165
|
+
agentCommands[id] = {
|
|
166
|
+
command: profile.command.trim(),
|
|
167
|
+
args: Array.isArray(profile.args) ? profile.args : [],
|
|
168
|
+
model: typeof profile.model === 'string' ? profile.model.trim() : '',
|
|
169
|
+
reasoningEffort: typeof profile.reasoningEffort === 'string' ? profile.reasoningEffort : '',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (loadProvider) {
|
|
174
|
+
provider = AGENT_PROVIDERS.includes(project.project.activeAgentProvider) ? project.project.activeAgentProvider : (options.provider || 'mock');
|
|
175
|
+
app.locals.config.provider = provider;
|
|
176
|
+
}
|
|
177
|
+
return project;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function switchWorkspace(target) {
|
|
181
|
+
const runningProbe = [...agentProbeJobs.values()].some((job) => ['queued', 'running'].includes(job.status));
|
|
182
|
+
const runningActivity = [...agentActivityJobs.values()].some((job) => job.status === 'running');
|
|
183
|
+
if (runningProbe || runningActivity || activeWorkspaceRequests > 0 || orchestrationManager.anyRunning()) {
|
|
184
|
+
throw Object.assign(new Error('Wait for the active paper task to finish before switching workspaces.'), { status: 409, code: 'WORKSPACE_BUSY' });
|
|
185
|
+
}
|
|
186
|
+
const entry = await workspaceRegistry.activate(target);
|
|
187
|
+
const initialization = await initializeWorkspace(entry.path);
|
|
188
|
+
workspaceRoot = entry.path;
|
|
189
|
+
app.locals.config.workspaceRoot = workspaceRoot;
|
|
190
|
+
agentProbeJobs.clear();
|
|
191
|
+
agentActivityJobs.clear();
|
|
192
|
+
await hydrateAgentCommands(initialization.project, { loadProvider: true });
|
|
193
|
+
return { entry, initialization };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function requestSuggestions(content, prompt, req, context = {}) {
|
|
197
|
+
const libraryContext = context.libraryContext || { prompt: '', resources: [], resourceIds: [], mode: 'automatic' };
|
|
198
|
+
const workspace = context.file
|
|
199
|
+
? { file: context.file, start: context.nodeStart ?? 0, end: (context.nodeStart ?? 0) + content.length }
|
|
200
|
+
: null;
|
|
201
|
+
if (context.payloadHash) {
|
|
202
|
+
const payload = buildSuggestionPayload(provider, { content, prompt, resourceContext: libraryContext.prompt, resourceIds: libraryContext.resourceIds, workspace, manifest: context.manifest || null }, { workspaceRoot });
|
|
203
|
+
const actualHash = createHash('sha256').update(payload).update('\0').update(context.manifestSerialized || '').digest('hex');
|
|
204
|
+
if (actualHash !== context.payloadHash) throw Object.assign(new Error('The final Agent payload changed after preview; refresh the prompt preview and try again.'), { status: 409, code: 'PROMPT_PREVIEW_STALE' });
|
|
205
|
+
}
|
|
206
|
+
const runInput = {
|
|
207
|
+
characters: content.length,
|
|
208
|
+
providedResources: libraryContext.resources,
|
|
209
|
+
libraryMode: context.manifest ? 'workspace-manifest' : libraryContext.mode,
|
|
210
|
+
...(context.manifest ? { manifest: context.manifest, manifestPath: context.manifestPath, payloadHash: context.payloadHash } : {}),
|
|
211
|
+
...(context.intentIds?.length ? { intentIds: context.intentIds } : {}),
|
|
212
|
+
};
|
|
213
|
+
const activity = beginAgentActivity(req.body?.activityId, provider);
|
|
214
|
+
appendAgentActivity(activity, 'stdout', `Starting ${provider} Agent in ${workspaceRoot}\n`);
|
|
215
|
+
if (provider === 'mock') {
|
|
216
|
+
let unresolvedTasks = [];
|
|
217
|
+
let suggestions;
|
|
218
|
+
if (context.manifest?.tasks?.length) {
|
|
219
|
+
const proposed = [];
|
|
220
|
+
for (const task of context.manifest.tasks) {
|
|
221
|
+
const quote = task.target.exactQuote;
|
|
222
|
+
const range = task.target.sourceRange;
|
|
223
|
+
const scope = quote || content.slice(range?.start || 0, range?.end ?? content.length);
|
|
224
|
+
const candidate = generateSuggestions(scope, task.instruction)[0];
|
|
225
|
+
if (!candidate || candidate.originalText === candidate.suggestedText) {
|
|
226
|
+
unresolvedTasks.push({ taskId: task.taskId, reason: 'The deterministic Mock Agent had no applicable transformation for this target.' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
proposed.push({
|
|
230
|
+
...candidate, taskId: task.taskId, nodeId: task.target.nodeId,
|
|
231
|
+
originalText: quote || candidate.originalText,
|
|
232
|
+
suggestedText: quote ? quote.replace(candidate.originalText, candidate.suggestedText) : candidate.suggestedText,
|
|
233
|
+
usedTemplateIds: [], usedCitekeys: [],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
suggestions = registerSuggestions(alignSuggestionsToManifest(proposed, context.manifest, unresolvedTasks, content));
|
|
237
|
+
} else suggestions = generateSuggestions(content, prompt);
|
|
238
|
+
attachSuggestionContext(suggestions, { ...context, selectedContent: content });
|
|
239
|
+
const completedAt = new Date().toISOString();
|
|
240
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
241
|
+
provider, operation: 'suggest', status: 'complete', prompt,
|
|
242
|
+
input: JSON.stringify(runInput),
|
|
243
|
+
output: JSON.stringify({ usedResourceIds: [], unresolvedTasks }), error: '', startedAt: completedAt, finishedAt: completedAt,
|
|
244
|
+
});
|
|
245
|
+
finishAgentActivity(activity, 'complete', `Mock Agent produced ${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}.`);
|
|
246
|
+
return {
|
|
247
|
+
provider, runId: run.id, suggestions, unresolvedTasks, summary: unresolvedTasks.length ? `${unresolvedTasks.length} manifest task(s) were left unresolved by the Mock Agent.` : 'Mock Agent completed.',
|
|
248
|
+
library: { mode: libraryContext.mode, providedResources: libraryContext.resources, usedResourceIds: [] },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
await hydrateAgentCommands();
|
|
252
|
+
if (!context.manifest) await materializeLibraries(workspaceRoot);
|
|
253
|
+
const startedAt = new Date().toISOString();
|
|
254
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
255
|
+
provider, operation: 'suggest', status: 'running', prompt,
|
|
256
|
+
input: JSON.stringify(runInput),
|
|
257
|
+
output: '', error: '', startedAt, finishedAt: '',
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
req.once('aborted', () => controller.abort());
|
|
262
|
+
const result = await runWritingAgent(provider, {
|
|
263
|
+
content, prompt, resourceContext: libraryContext.prompt, resourceIds: libraryContext.resourceIds,
|
|
264
|
+
workspace, manifest: context.manifest || null,
|
|
265
|
+
}, {
|
|
266
|
+
workspaceRoot, commands: agentCommands, signal: controller.signal,
|
|
267
|
+
onOutput: (stream, chunk) => appendAgentActivity(activity, stream, chunk),
|
|
268
|
+
});
|
|
269
|
+
const referenceState = await loadReferenceState(workspaceRoot);
|
|
270
|
+
const unknownCitekeys = findUnknownAgentCitations(result.suggestions, content, referenceState.items);
|
|
271
|
+
if (unknownCitekeys.length) throw Object.assign(new Error(`Agent proposed unknown citation keys: ${unknownCitekeys.join(', ')}`), { status: 422, code: 'UNKNOWN_CITATION_KEY' });
|
|
272
|
+
recordAgentConnection(provider, { ok: true, status: 'complete', authenticated: true, authStatus: 'Last real Agent request passed', error: '', code: '' });
|
|
273
|
+
const suggestions = registerSuggestions(alignSuggestionsToManifest(result.suggestions, context.manifest, result.unresolvedTasks, content));
|
|
274
|
+
attachSuggestionContext(suggestions, { ...context, selectedContent: content });
|
|
275
|
+
await updateAgentRun(workspaceRoot, run.id, {
|
|
276
|
+
status: 'complete', output: JSON.stringify({ ...result, agentMeta: result.agentMeta }), finishedAt: new Date().toISOString(),
|
|
277
|
+
});
|
|
278
|
+
finishAgentActivity(activity, 'complete', 'Agent process completed successfully.');
|
|
279
|
+
return {
|
|
280
|
+
provider, agentMeta: result.agentMeta, runId: run.id, summary: result.summary, suggestions, unresolvedTasks: result.unresolvedTasks || [],
|
|
281
|
+
library: {
|
|
282
|
+
mode: libraryContext.mode, providedResources: libraryContext.resources,
|
|
283
|
+
usedResourceIds: result.usedResourceIds,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error.code === 'AGENT_AUTH_REQUIRED') recordAgentConnection(provider, { ok: false, status: 'failed', authenticated: false, authStatus: 'Authentication failed · sign in again', error: publicErrorMessage(error), code: error.code });
|
|
288
|
+
else recordAgentConnection(provider, { ok: false, status: 'failed', error: publicErrorMessage(error), code: error.code || '' });
|
|
289
|
+
finishAgentActivity(activity, 'failed', publicErrorMessage(error));
|
|
290
|
+
await updateAgentRun(workspaceRoot, run.id, {
|
|
291
|
+
status: 'failed', error: agentFailureAudit(error), finishedAt: new Date().toISOString(),
|
|
292
|
+
});
|
|
293
|
+
error.status ||= 502;
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
app.use('/workspace', (req, res, next) => {
|
|
299
|
+
const relPath = req.path.replace(/^\//, '');
|
|
300
|
+
if (relPath.split('/').some((part) => part.startsWith('.'))) return res.status(403).json({ error: 'Access denied' });
|
|
301
|
+
const safe = sanitizePath(relPath, workspaceRoot);
|
|
302
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
303
|
+
req._safePath = safe;
|
|
304
|
+
next();
|
|
305
|
+
});
|
|
306
|
+
app.use('/workspace', async (req, res, next) => {
|
|
307
|
+
try {
|
|
308
|
+
const info = await stat(req._safePath);
|
|
309
|
+
if (!info.isFile()) return next();
|
|
310
|
+
res.sendFile(req._safePath);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error.code === 'ENOENT') return next();
|
|
313
|
+
next(error);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
app.get('/api/version', async (_req, res) => {
|
|
318
|
+
res.json(await getAppVersionInfo());
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
app.get('/api/engines', async (_req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
const engines = await detectEngines();
|
|
324
|
+
res.json({ engines });
|
|
325
|
+
} catch (e) {
|
|
326
|
+
res.status(e.status || 500).json({ error: e.message, code: e.code });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.get('/api/config', async (_req, res) => {
|
|
331
|
+
try {
|
|
332
|
+
await workspaceRegistry.add(workspaceRoot, { activate: true });
|
|
333
|
+
await hydrateAgentCommands(null, { loadProvider: true });
|
|
334
|
+
res.json({ provider, workspace: workspaceRoot });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
res.status(error.status || 500).json({ error: publicErrorMessage(error), code: error.code });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
app.get('/api/workspaces', async (_req, res) => {
|
|
341
|
+
await resourceResponse(res, async () => {
|
|
342
|
+
await workspaceRegistry.add(workspaceRoot, { activate: true });
|
|
343
|
+
const workspaces = await workspaceRegistry.list(workspaceRoot);
|
|
344
|
+
return { activePath: workspaceRoot, workspaces: workspaces.filter((item) => item.available || item.active) };
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
app.get('/api/workspaces/browse', async (req, res) => {
|
|
349
|
+
await resourceResponse(res, async () => await browseWorkspaceDirectories(req.query.path || ''));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
app.post('/api/workspaces', async (req, res) => {
|
|
353
|
+
await resourceResponse(res, async () => {
|
|
354
|
+
await workspaceRegistry.add(workspaceRoot, { activate: true });
|
|
355
|
+
const registered = await workspaceRegistry.add(req.body?.path);
|
|
356
|
+
const { entry, initialization } = await switchWorkspace(registered.id);
|
|
357
|
+
return { workspace: entry, createdSample: initialization.createdSample };
|
|
358
|
+
}, 201);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
app.post('/api/workspaces/:id/activate', async (req, res) => {
|
|
362
|
+
await resourceResponse(res, async () => {
|
|
363
|
+
const { entry, initialization } = await switchWorkspace(req.params.id);
|
|
364
|
+
return { workspace: entry, createdSample: initialization.createdSample };
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
app.post('/api/workspaces/pick-folder', async (_req, res) => {
|
|
369
|
+
if (typeof options.folderPicker === 'function') {
|
|
370
|
+
return resourceResponse(res, async () => ({ path: await options.folderPicker(workspaceRoot) }));
|
|
371
|
+
}
|
|
372
|
+
const candidates = process.platform === 'darwin'
|
|
373
|
+
? [['osascript', ['-e', 'POSIX path of (choose folder with prompt "Choose a Papergod workspace")']]]
|
|
374
|
+
: [
|
|
375
|
+
['zenity', ['--file-selection', '--directory', '--title=Choose a Papergod workspace']],
|
|
376
|
+
['kdialog', ['--getexistingdirectory', workspaceRoot]],
|
|
377
|
+
['python3', ['-c', 'import sys, tkinter as tk; from tkinter import filedialog; root=tk.Tk(); root.withdraw(); root.attributes("-topmost", True); root.update(); path=filedialog.askdirectory(parent=root, initialdir=sys.argv[1], title="Choose a Papergod workspace", mustexist=True); print(path); root.destroy()', workspaceRoot], false],
|
|
378
|
+
];
|
|
379
|
+
const tryPicker = (index = 0) => new Promise((resolvePicker, rejectPicker) => {
|
|
380
|
+
if (!candidates[index]) return rejectPicker(Object.assign(new Error('No graphical folder picker is available. Paste an absolute folder path instead.'), { status: 501, code: 'PICKER_UNAVAILABLE' }));
|
|
381
|
+
const [command, args, cancelOnExitOne = true] = candidates[index];
|
|
382
|
+
const child = spawn(command, args, { shell: false, windowsHide: true });
|
|
383
|
+
let output = '';
|
|
384
|
+
let unavailable = false;
|
|
385
|
+
let timedOut = false;
|
|
386
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, 5 * 60_000);
|
|
387
|
+
child.stdout?.on('data', (chunk) => { output += chunk; });
|
|
388
|
+
child.once('error', (error) => {
|
|
389
|
+
clearTimeout(timeout);
|
|
390
|
+
unavailable = true;
|
|
391
|
+
if (error.code === 'ENOENT') tryPicker(index + 1).then(resolvePicker, rejectPicker);
|
|
392
|
+
else rejectPicker(error);
|
|
393
|
+
});
|
|
394
|
+
child.once('close', (code) => {
|
|
395
|
+
clearTimeout(timeout);
|
|
396
|
+
if (unavailable) return;
|
|
397
|
+
if (timedOut) return rejectPicker(Object.assign(new Error('Folder picker timed out.'), { status: 504, code: 'PICKER_TIMEOUT' }));
|
|
398
|
+
if (code === 0 && output.trim()) resolvePicker(output.trim());
|
|
399
|
+
else if (code === 0) rejectPicker(Object.assign(new Error('Folder selection was cancelled.'), { status: 409, code: 'PICKER_CANCELLED' }));
|
|
400
|
+
else if (code === 1 && cancelOnExitOne) rejectPicker(Object.assign(new Error('Folder selection was cancelled.'), { status: 409, code: 'PICKER_CANCELLED' }));
|
|
401
|
+
else if (code !== null) tryPicker(index + 1).then(resolvePicker, rejectPicker);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
await resourceResponse(res, async () => ({ path: await tryPicker() }));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const requireCurrentTerminal = (id) => {
|
|
408
|
+
const session = terminalManager.get(id);
|
|
409
|
+
if (session.workspace !== workspaceRoot) throw Object.assign(new Error('Terminal belongs to another workspace.'), { status: 409, code: 'TERMINAL_WORKSPACE_MISMATCH' });
|
|
410
|
+
return session;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
app.post('/api/terminal', async (_req, res) => {
|
|
414
|
+
await resourceResponse(res, async () => ({ session: terminalManager.start(workspaceRoot) }), 201);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
app.get('/api/terminal/:id/events', (req, res) => {
|
|
418
|
+
try {
|
|
419
|
+
requireCurrentTerminal(req.params.id);
|
|
420
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
421
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
422
|
+
res.setHeader('Connection', 'keep-alive');
|
|
423
|
+
res.flushHeaders?.();
|
|
424
|
+
const detach = terminalManager.attach(req.params.id, res);
|
|
425
|
+
const heartbeat = setInterval(() => res.write(': keepalive\n\n'), 15_000);
|
|
426
|
+
req.once('close', () => { clearInterval(heartbeat); detach(); });
|
|
427
|
+
} catch (error) {
|
|
428
|
+
res.status(error.status || 500).json({ error: publicErrorMessage(error), code: error.code });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
app.post('/api/terminal/:id/input', async (req, res) => {
|
|
433
|
+
await resourceResponse(res, async () => {
|
|
434
|
+
requireCurrentTerminal(req.params.id);
|
|
435
|
+
terminalManager.input(req.params.id, req.body?.data);
|
|
436
|
+
return { ok: true };
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
app.post('/api/terminal/:id/resize', async (req, res) => {
|
|
441
|
+
await resourceResponse(res, async () => {
|
|
442
|
+
requireCurrentTerminal(req.params.id);
|
|
443
|
+
terminalManager.resize(req.params.id, req.body?.cols, req.body?.rows);
|
|
444
|
+
return { ok: true };
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
app.delete('/api/terminal/:id', async (req, res) => {
|
|
449
|
+
await resourceResponse(res, async () => {
|
|
450
|
+
requireCurrentTerminal(req.params.id);
|
|
451
|
+
terminalManager.close(req.params.id);
|
|
452
|
+
return { ok: true };
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const zoteroConnectionOptions = async (overrides = {}) => {
|
|
457
|
+
const state = await loadReferenceState(workspaceRoot);
|
|
458
|
+
return {
|
|
459
|
+
...state.zotero, baseUrl: options.zoteroBaseUrl,
|
|
460
|
+
...(options.zoteroFetch ? { fetchImpl: options.zoteroFetch } : {}), ...overrides,
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
app.get('/api/references', async (req, res) => {
|
|
465
|
+
await resourceResponse(res, async () => {
|
|
466
|
+
const state = await loadReferenceState(workspaceRoot);
|
|
467
|
+
const query = String(req.query.q || '').trim().toLowerCase();
|
|
468
|
+
const items = query ? state.items.filter((item) => [item.citekey, item.title, item.year, item.doi, ...(item.authors || [])].join(' ').toLowerCase().includes(query)) : state.items;
|
|
469
|
+
return { ...state, items };
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
app.put('/api/references/config', async (req, res) => {
|
|
474
|
+
await resourceResponse(res, async () => ({ state: await configureReferences(workspaceRoot, req.body || {}) }));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
app.post('/api/references/folders', async (req, res) => {
|
|
478
|
+
await resourceResponse(res, async () => await addReferenceFolder(workspaceRoot, req.body?.path), 201);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
app.delete('/api/references/folders', async (req, res) => {
|
|
482
|
+
await resourceResponse(res, async () => ({ state: await removeReferenceFolder(workspaceRoot, req.body?.path) }));
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
app.post('/api/references/scan', async (_req, res) => {
|
|
486
|
+
await resourceResponse(res, async () => await scanAllReferenceFolders(workspaceRoot));
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
app.post('/api/references/import', async (req, res) => {
|
|
490
|
+
await resourceResponse(res, async () => ({ state: await importReferences(workspaceRoot, req.body?.references) }), 201);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
app.patch('/api/references/:id', async (req, res) => {
|
|
494
|
+
await resourceResponse(res, async () => await updateReference(workspaceRoot, req.params.id, req.body || {}));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
app.post('/api/references/:id/resolve', async (req, res) => {
|
|
498
|
+
await resourceResponse(res, async () => await resolveStoredReference(workspaceRoot, req.params.id, options.referenceFetch ? { fetchImpl: options.referenceFetch } : {}));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
app.post('/api/references/bibliography', async (_req, res) => {
|
|
502
|
+
await resourceResponse(res, async () => await writeBibliography(workspaceRoot));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
app.post('/api/references/review', async (req, res) => {
|
|
506
|
+
await resourceResponse(res, async () => {
|
|
507
|
+
await hydrateAgentCommands();
|
|
508
|
+
return await generateLiteratureReview(workspaceRoot, req.body || {}, { provider, commands: agentCommands });
|
|
509
|
+
}, 201);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
app.post('/api/references/check', async (req, res) => {
|
|
513
|
+
await resourceResponse(res, async () => await checkWorkspaceCitations(workspaceRoot, req.body?.file));
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
app.get('/api/references/zotero/status', async (_req, res) => {
|
|
517
|
+
await resourceResponse(res, async () => ({ status: await getZoteroStatus(await zoteroConnectionOptions()) }));
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
app.get('/api/references/zotero/collections', async (_req, res) => {
|
|
521
|
+
await resourceResponse(res, async () => ({ collections: await listZoteroCollections(await zoteroConnectionOptions()) }));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
app.get('/api/references/zotero/items', async (req, res) => {
|
|
525
|
+
await resourceResponse(res, async () => ({
|
|
526
|
+
items: await searchZoteroItems(await zoteroConnectionOptions({ query: String(req.query.q || ''), collectionKey: String(req.query.collectionKey || '') })),
|
|
527
|
+
}));
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
app.post('/api/references/zotero/import', async (req, res) => {
|
|
531
|
+
await resourceResponse(res, async () => {
|
|
532
|
+
if (!Array.isArray(req.body?.references)) throw Object.assign(new Error('references must be an array.'), { status: 400 });
|
|
533
|
+
const connection = await zoteroConnectionOptions();
|
|
534
|
+
const enriched = [];
|
|
535
|
+
for (const reference of req.body.references.slice(0, 100)) enriched.push(await enrichZoteroAttachment(reference, connection));
|
|
536
|
+
let imported = enriched;
|
|
537
|
+
try {
|
|
538
|
+
const status = await getZoteroStatus(connection);
|
|
539
|
+
if (status.betterBibtex) {
|
|
540
|
+
const bibtex = await exportBetterBibTeX(enriched.map((item) => item.citekey), connection);
|
|
541
|
+
const exported = parseBibTeX(bibtex, 'zotero://better-bibtex');
|
|
542
|
+
imported = enriched.map((item) => {
|
|
543
|
+
const match = exported.find((entry) => entry.citekey === item.citekey);
|
|
544
|
+
return match ? { ...item, ...match, id: item.id, source: 'zotero', sourceId: item.sourceId, hasPdf: item.hasPdf, attachmentKey: item.attachmentKey } : item;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
} catch { imported = enriched; }
|
|
548
|
+
const state = await importReferences(workspaceRoot, imported);
|
|
549
|
+
return { state, imported: enriched.length };
|
|
550
|
+
}, 201);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
app.get('/api/references/zotero/fulltext/:attachmentKey', async (req, res) => {
|
|
554
|
+
await resourceResponse(res, async () => {
|
|
555
|
+
const fulltext = await getZoteroFullText(req.params.attachmentKey, await zoteroConnectionOptions());
|
|
556
|
+
const content = String(fulltext.content || '');
|
|
557
|
+
return { ...fulltext, content: content.slice(0, 1_000_000), truncated: content.length > 1_000_000 };
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
app.get('/api/agent/activity/:id', (req, res) => {
|
|
562
|
+
const activity = agentActivityJobs.get(req.params.id);
|
|
563
|
+
if (!activity) return res.status(404).json({ error: 'Agent activity not found' });
|
|
564
|
+
const { rawOutput: _rawOutput, ...safeActivity } = activity;
|
|
565
|
+
res.json(safeActivity);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
app.post('/api/workspace/open-folder', (_req, res) => {
|
|
569
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open';
|
|
570
|
+
const child = spawn(command, [workspaceRoot], { detached: true, stdio: 'ignore', shell: false });
|
|
571
|
+
child.once('spawn', () => { child.unref(); res.json({ ok: true, workspace: workspaceRoot }); });
|
|
572
|
+
child.once('error', (error) => res.status(500).json({ error: `Could not open the paper folder: ${error.message}` }));
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
app.get('/api/agents', async (_req, res) => {
|
|
576
|
+
await resourceResponse(res, async () => {
|
|
577
|
+
const project = await hydrateAgentCommands(null, { loadProvider: true });
|
|
578
|
+
const detected = await detectAgentProviders({ commands: agentCommands });
|
|
579
|
+
const detectedById = new Map(detected.map((item) => [item.provider, item]));
|
|
580
|
+
const saved = project.project.agentProfiles || {};
|
|
581
|
+
const definitions = [
|
|
582
|
+
{ id: 'mock', label: 'Mock', adapter: 'built-in', command: '', capabilities: ['revise', 'paragraph', 'review', 'generation'], integration: 'ready' },
|
|
583
|
+
{ id: 'codex', label: 'Codex CLI', adapter: 'structured-cli', command: 'codex', capabilities: ['revise', 'paragraph', 'review', 'generation'], integration: 'ready' },
|
|
584
|
+
{ id: 'claude-code', label: 'Claude Code', adapter: 'structured-cli', command: 'claude', capabilities: ['revise', 'paragraph', 'review', 'generation'], integration: 'ready' },
|
|
585
|
+
{ id: 'opencode', label: 'OpenCode CLI', adapter: 'structured-cli', command: 'opencode', capabilities: ['revise', 'paragraph', 'review', 'generation'], integration: 'ready' },
|
|
586
|
+
{ id: 'pi', label: 'Pi Agent', adapter: 'json-event-cli', command: 'pi', capabilities: ['revise', 'paragraph', 'review', 'generation'], integration: 'ready' },
|
|
587
|
+
];
|
|
588
|
+
return {
|
|
589
|
+
selected: provider,
|
|
590
|
+
providers: definitions.map((definition) => {
|
|
591
|
+
const detectedAgent = detectedById.get(definition.id) || { available: false, authenticated: false, authStatus: 'CLI unavailable', version: null };
|
|
592
|
+
const connection = agentConnectionState.get(definition.id);
|
|
593
|
+
return {
|
|
594
|
+
...definition,
|
|
595
|
+
command: saved[definition.id]?.command || definition.command,
|
|
596
|
+
args: saved[definition.id]?.args || [],
|
|
597
|
+
model: saved[definition.id]?.model || '',
|
|
598
|
+
reasoningEffort: saved[definition.id]?.reasoningEffort || '',
|
|
599
|
+
...detectedAgent,
|
|
600
|
+
...(connection ? {
|
|
601
|
+
authenticated: connection.authenticated ?? detectedAgent.authenticated,
|
|
602
|
+
authStatus: connection.authStatus || detectedAgent.authStatus,
|
|
603
|
+
liveCheck: connection,
|
|
604
|
+
} : {}),
|
|
605
|
+
};
|
|
606
|
+
}),
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
app.put('/api/agents/config', async (req, res) => {
|
|
612
|
+
const { id, command = '', args = [], model = '', reasoningEffort = '', activate = false } = req.body || {};
|
|
613
|
+
if (!AGENT_PROVIDERS.includes(id)) return res.status(400).json({ error: 'Unknown Agent provider' });
|
|
614
|
+
if (typeof command !== 'string' || command.length > 500) return res.status(400).json({ error: 'command must be a string up to 500 characters' });
|
|
615
|
+
if (!Array.isArray(args) || args.some((item) => typeof item !== 'string') || args.length > 30) return res.status(400).json({ error: 'args must be an array of strings' });
|
|
616
|
+
if (typeof model !== 'string' || model.length > 200) return res.status(400).json({ error: 'model must be a string up to 200 characters' });
|
|
617
|
+
if (!['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(reasoningEffort)) return res.status(400).json({ error: 'reasoningEffort is invalid' });
|
|
618
|
+
await resourceResponse(res, async () => {
|
|
619
|
+
await updateProject(workspaceRoot, (project) => {
|
|
620
|
+
project.project.agentProfiles ||= {};
|
|
621
|
+
project.project.agentProfiles[id] = { command, args, model, reasoningEffort };
|
|
622
|
+
if (activate) project.project.activeAgentProvider = id;
|
|
623
|
+
});
|
|
624
|
+
if (id !== 'mock' && command.trim()) agentCommands[id] = { command: command.trim(), args, model: model.trim(), reasoningEffort };
|
|
625
|
+
if (activate) {
|
|
626
|
+
provider = id;
|
|
627
|
+
app.locals.config.provider = provider;
|
|
628
|
+
}
|
|
629
|
+
return { ok: true, selected: provider };
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
app.post('/api/agents/probe', async (req, res) => {
|
|
634
|
+
const { id, command = '', args = [], model = '', live = false } = req.body || {};
|
|
635
|
+
if (!AGENT_PROVIDERS.includes(id)) return res.status(400).json({ error: 'Unknown Agent provider' });
|
|
636
|
+
if (typeof command !== 'string' || !Array.isArray(args) || args.some((item) => typeof item !== 'string') || typeof model !== 'string') {
|
|
637
|
+
return res.status(400).json({ error: 'Invalid Agent probe configuration' });
|
|
638
|
+
}
|
|
639
|
+
const commands = { ...agentCommands };
|
|
640
|
+
if (id !== 'mock' && command.trim()) commands[id] = { command: command.trim(), args, model: model.trim() };
|
|
641
|
+
if (!live) {
|
|
642
|
+
return resourceResponse(res, async () => {
|
|
643
|
+
const detected = await detectAgentProviders({ commands, providers: [id] });
|
|
644
|
+
return { agent: detected.find((item) => item.provider === id) };
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const testId = `agent_probe_${randomUUID()}`;
|
|
649
|
+
const controller = new AbortController();
|
|
650
|
+
const job = { id: testId, provider: id, status: 'queued', createdAt: new Date().toISOString(), startedAt: null, finishedAt: null, agent: null, liveTest: null, controller };
|
|
651
|
+
agentProbeJobs.set(testId, job);
|
|
652
|
+
while (agentProbeJobs.size > 50) agentProbeJobs.delete(agentProbeJobs.keys().next().value);
|
|
653
|
+
res.status(202).json({ test: { id: testId, provider: id, status: job.status, createdAt: job.createdAt } });
|
|
654
|
+
|
|
655
|
+
setImmediate(async () => {
|
|
656
|
+
job.status = 'running';
|
|
657
|
+
job.startedAt = new Date().toISOString();
|
|
658
|
+
const startedAt = Date.now();
|
|
659
|
+
try {
|
|
660
|
+
const detected = await detectAgentProviders({ commands, providers: [id] });
|
|
661
|
+
const agent = detected.find((item) => item.provider === id);
|
|
662
|
+
job.agent = agent;
|
|
663
|
+
if (controller.signal.aborted) throw Object.assign(new Error('Agent live test cancelled'), { code: 'AGENT_CANCELLED' });
|
|
664
|
+
if (id === 'mock') {
|
|
665
|
+
job.liveTest = { ok: true, latencyMs: Date.now() - startedAt, summary: 'Built-in Mock workflow is ready.' };
|
|
666
|
+
} else if (!agent?.available) {
|
|
667
|
+
job.liveTest = { ok: false, latencyMs: Date.now() - startedAt, error: agent?.error || 'CLI unavailable' };
|
|
668
|
+
} else {
|
|
669
|
+
const result = await runWritingAgent(id, {
|
|
670
|
+
content: 'This result is very important.',
|
|
671
|
+
prompt: 'Return one precise academic edit for the supplied sentence.',
|
|
672
|
+
resourceContext: '', resourceIds: [],
|
|
673
|
+
}, { workspaceRoot, commands, timeoutMs: 60_000, signal: controller.signal, liveTest: true });
|
|
674
|
+
job.agent = { ...agent, authenticated: true, authStatus: 'Live subscription test passed' };
|
|
675
|
+
job.liveTest = { ok: true, latencyMs: Date.now() - startedAt, summary: result.summary || 'Structured response validated.' };
|
|
676
|
+
}
|
|
677
|
+
job.status = job.liveTest.ok ? 'complete' : 'failed';
|
|
678
|
+
recordAgentConnection(id, {
|
|
679
|
+
ok: job.liveTest.ok, status: job.status, latencyMs: job.liveTest.latencyMs,
|
|
680
|
+
authenticated: job.liveTest.ok ? true : (agent?.authenticated ?? false),
|
|
681
|
+
authStatus: job.liveTest.ok ? 'Live subscription test passed' : (job.liveTest.error || agent?.authStatus || 'Live test failed'),
|
|
682
|
+
error: job.liveTest.error || '', code: job.liveTest.code || '',
|
|
683
|
+
});
|
|
684
|
+
} catch (error) {
|
|
685
|
+
const cancelled = controller.signal.aborted || error.code === 'AGENT_CANCELLED';
|
|
686
|
+
job.status = cancelled ? 'cancelled' : 'failed';
|
|
687
|
+
job.liveTest = { ok: false, latencyMs: Date.now() - startedAt, error: cancelled ? 'Live test cancelled.' : publicErrorMessage(error), code: error.code };
|
|
688
|
+
const authenticationFailed = error.code === 'AGENT_AUTH_REQUIRED';
|
|
689
|
+
recordAgentConnection(id, {
|
|
690
|
+
ok: false, status: job.status, latencyMs: job.liveTest.latencyMs,
|
|
691
|
+
...(authenticationFailed ? { authenticated: false, authStatus: 'Live subscription test failed · sign in again' } : {}),
|
|
692
|
+
error: job.liveTest.error, code: error.code || '',
|
|
693
|
+
});
|
|
694
|
+
} finally {
|
|
695
|
+
job.finishedAt = new Date().toISOString();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
app.get('/api/agents/probe/:testId', (req, res) => {
|
|
701
|
+
const job = agentProbeJobs.get(req.params.testId);
|
|
702
|
+
if (!job) return res.status(404).json({ error: 'Agent live test not found' });
|
|
703
|
+
const { controller: _controller, ...test } = job;
|
|
704
|
+
res.json({ test });
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
app.delete('/api/agents/probe/:testId', (req, res) => {
|
|
708
|
+
const job = agentProbeJobs.get(req.params.testId);
|
|
709
|
+
if (!job) return res.status(404).json({ error: 'Agent live test not found' });
|
|
710
|
+
if (['queued', 'running'].includes(job.status)) job.controller.abort();
|
|
711
|
+
res.json({ ok: true, status: job.status });
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
app.post('/api/agent/context-preview', async (req, res) => {
|
|
715
|
+
const { nodeId, documentId, content = '', temporaryPrompt = '', additionalRequirements = '', intentIds = [], resourceIds = [], citekeys = [] } = req.body || {};
|
|
716
|
+
await resourceResponse(res, async () => {
|
|
717
|
+
const project = await loadProject(workspaceRoot);
|
|
718
|
+
let document = project.documents.find((item) => item.id === documentId) || null;
|
|
719
|
+
let node = document;
|
|
720
|
+
let targetContent = typeof content === 'string' ? content : '';
|
|
721
|
+
let target = document ? { type: 'document', id: document.id, start: 0, end: targetContent.length, quote: '' } : null;
|
|
722
|
+
if (typeof nodeId === 'string' && nodeId) {
|
|
723
|
+
const context = await getNodeSourceContext(workspaceRoot, nodeId);
|
|
724
|
+
document = context.document;
|
|
725
|
+
node = context.node;
|
|
726
|
+
targetContent = context.selectedContent;
|
|
727
|
+
target = { type: context.node.type, id: context.node.id, start: context.sourceRange.start, end: context.sourceRange.end, quote: context.selectedContent };
|
|
728
|
+
}
|
|
729
|
+
if (!document) throw Object.assign(new Error('Document not found'), { status: 404 });
|
|
730
|
+
const manifestResult = await materializePromptManifest(workspaceRoot, {
|
|
731
|
+
documentId: document.id, nodeId, target, intentIds,
|
|
732
|
+
instruction: additionalRequirements || temporaryPrompt,
|
|
733
|
+
additionalRequirements: additionalRequirements || temporaryPrompt,
|
|
734
|
+
sourceContent: nodeId ? undefined : targetContent,
|
|
735
|
+
resourceIds, citekeys,
|
|
736
|
+
});
|
|
737
|
+
const manifestTarget = manifestResult.manifest.tasks.length === 1 ? manifestResult.manifest.tasks[0].target : { file: document.file, sourceRange: { start: 0, end: targetContent.length } };
|
|
738
|
+
const request = {
|
|
739
|
+
prompt: manifestResult.prompt,
|
|
740
|
+
content: targetContent,
|
|
741
|
+
resourceIds,
|
|
742
|
+
workspace: { file: manifestTarget.file || document.file, start: manifestTarget.sourceRange?.start || 0, end: manifestTarget.sourceRange?.end || targetContent.length },
|
|
743
|
+
manifest: manifestResult.manifest,
|
|
744
|
+
};
|
|
745
|
+
const payloadText = buildSuggestionPayload(provider, request, { workspaceRoot });
|
|
746
|
+
const schemaTransport = ['codex', 'claude-code'].includes(provider) ? 'out-of-band' : 'inline';
|
|
747
|
+
const payloadHash = createHash('sha256').update(payloadText).update('\0').update(manifestResult.serialized).digest('hex');
|
|
748
|
+
const primaryPayload = { provider, model: project.project.agentProfiles?.[provider]?.model || '', text: payloadText, characters: payloadText.length, tokenEstimate: Math.ceil(payloadText.length / 4), schemaTransport, hash: payloadHash };
|
|
749
|
+
const scopeLabel = intentIds.length ? `${manifestResult.manifest.tasks.length} atomic modification tasks`
|
|
750
|
+
: node?.type === 'section' ? `Section · ${node.title}`
|
|
751
|
+
: node?.type === 'paragraph' ? 'Selected paragraph'
|
|
752
|
+
: node?.type === 'sentence' ? 'Selected sentence' : `Document · ${document.title || document.file}`;
|
|
753
|
+
const resourceLayers = Object.entries(manifestResult.manifest.resources).map(([name, value]) => ({ name: `Resource · ${name}`, characters: JSON.stringify(value).length, path: value }));
|
|
754
|
+
return {
|
|
755
|
+
provider, scope: scopeLabel,
|
|
756
|
+
contextPrompt: manifestResult.prompt,
|
|
757
|
+
mergedPrompt: manifestResult.prompt,
|
|
758
|
+
assembledPrompt: payloadText,
|
|
759
|
+
characterCount: payloadText.length,
|
|
760
|
+
tokenEstimate: Math.ceil(payloadText.length / 4),
|
|
761
|
+
budget: { limitCharacters: 500000, totalCharacters: payloadText.length, remainingCharacters: 500000 - payloadText.length },
|
|
762
|
+
payload: primaryPayload,
|
|
763
|
+
manifest: manifestResult.manifest,
|
|
764
|
+
manifestPath: manifestResult.manifestPath,
|
|
765
|
+
manifestHash: manifestResult.manifestHash,
|
|
766
|
+
layers: [
|
|
767
|
+
{ name: `CLI payload · ${provider}${primaryPayload.model ? `/${primaryPayload.model}` : ''} · ${schemaTransport}`, characters: primaryPayload.characters },
|
|
768
|
+
{ name: 'Prompt manifest', characters: manifestResult.manifestCharacterCount, path: manifestResult.manifestPath },
|
|
769
|
+
...resourceLayers,
|
|
770
|
+
],
|
|
771
|
+
library: { mode: 'workspace-manifest', resources: resourceIds },
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
app.get('/api/agent/runs', async (_req, res) => {
|
|
777
|
+
await resourceResponse(res, async () => ({ runs: await listAgentRuns(workspaceRoot) }));
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
app.get('/api/analysis/formulas', (_req, res) => {
|
|
781
|
+
res.json({ formulas: ANALYSIS_FORMULAS });
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
app.post('/api/analysis/structure', async (req, res) => {
|
|
785
|
+
await resourceResponse(res, async () => ({
|
|
786
|
+
analysis: await analyzeStructure(workspaceRoot, req.body || {}),
|
|
787
|
+
}));
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
app.get('/api/orchestrations', async (_req, res) => {
|
|
791
|
+
await resourceResponse(res, async () => {
|
|
792
|
+
await orchestrationManager.normalizeStaleRuns(workspaceRoot);
|
|
793
|
+
return { orchestrations: await listOrchestrations(workspaceRoot) };
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
app.post('/api/orchestrations', async (req, res) => {
|
|
798
|
+
await resourceResponse(res, async () => ({ orchestration: await createOrchestration(workspaceRoot, req.body || {}) }), 201);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
app.get('/api/orchestrations/:id', async (req, res) => {
|
|
802
|
+
await resourceResponse(res, async () => {
|
|
803
|
+
if (!orchestrationManager.isRunning(req.params.id)) await orchestrationManager.normalizeStaleRuns(workspaceRoot);
|
|
804
|
+
return { orchestration: await getOrchestration(workspaceRoot, req.params.id) };
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
app.put('/api/orchestrations/:id', async (req, res) => {
|
|
809
|
+
await resourceResponse(res, async () => ({
|
|
810
|
+
orchestration: await updateOrchestration(workspaceRoot, req.params.id, req.body || {}),
|
|
811
|
+
}));
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
app.delete('/api/orchestrations/:id', async (req, res) => {
|
|
815
|
+
await resourceResponse(res, async () => {
|
|
816
|
+
await deleteOrchestration(workspaceRoot, req.params.id);
|
|
817
|
+
return { ok: true };
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
app.post('/api/orchestrations/:id/reset', async (req, res) => {
|
|
822
|
+
await resourceResponse(res, async () => ({ orchestration: await resetOrchestration(workspaceRoot, req.params.id) }));
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
app.post('/api/orchestrations/:id/run', async (req, res) => {
|
|
826
|
+
await resourceResponse(res, async () => {
|
|
827
|
+
await hydrateAgentCommands();
|
|
828
|
+
return await orchestrationManager.runOrchestration(workspaceRoot, req.params.id, { commands: agentCommands });
|
|
829
|
+
}, 202);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
app.post('/api/orchestrations/:id/cancel', async (req, res) => {
|
|
833
|
+
await resourceResponse(res, async () => await orchestrationManager.cancelOrchestration(req.params.id));
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
app.post('/api/orchestrations/:id/gates/:nodeId/decide', async (req, res) => {
|
|
837
|
+
await resourceResponse(res, async () => ({
|
|
838
|
+
orchestration: await orchestrationManager.decideGate(
|
|
839
|
+
workspaceRoot, req.params.id, req.params.nodeId, req.body?.decision, req.body?.note,
|
|
840
|
+
),
|
|
841
|
+
}));
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
app.get('/api/project', async (_req, res) => {
|
|
845
|
+
try {
|
|
846
|
+
res.json({ project: await loadProject(workspaceRoot) });
|
|
847
|
+
} catch (e) {
|
|
848
|
+
res.status(500).json({ error: e.message });
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
app.put('/api/project', async (req, res) => {
|
|
853
|
+
try {
|
|
854
|
+
const project = await saveProject(workspaceRoot, req.body?.project);
|
|
855
|
+
res.json({ ok: true, project });
|
|
856
|
+
} catch (e) {
|
|
857
|
+
if (e.code === 'INVALID_PROJECT') {
|
|
858
|
+
return res.status(400).json({ error: e.message, details: e.details });
|
|
859
|
+
}
|
|
860
|
+
res.status(500).json({ error: e.message });
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
app.post('/api/documents/sync', async (req, res) => {
|
|
865
|
+
const { file } = req.body || {};
|
|
866
|
+
if (typeof file !== 'string' || !file) return res.status(400).json({ error: 'file is required' });
|
|
867
|
+
await resourceResponse(res, async () => ({ document: await syncDocumentStructure(workspaceRoot, file) }));
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
app.get('/api/documents/:id/structure', async (req, res) => {
|
|
871
|
+
await resourceResponse(res, async () => ({ document: await getDocumentStructure(workspaceRoot, req.params.id) }));
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
app.put('/api/documents/:id/metadata', async (req, res) => {
|
|
875
|
+
await resourceResponse(res, async () => ({
|
|
876
|
+
document: await updateDocumentMetadata(workspaceRoot, req.params.id, req.body),
|
|
877
|
+
}));
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
app.put('/api/structure/nodes/:id', async (req, res) => {
|
|
881
|
+
await resourceResponse(res, async () => ({
|
|
882
|
+
node: await updateNodeMetadata(workspaceRoot, req.params.id, req.body),
|
|
883
|
+
}));
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
app.get('/api/libraries', async (_req, res) => {
|
|
887
|
+
await resourceResponse(res, async () => ({ libraries: await getLibraries(workspaceRoot) }));
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
app.post('/api/libraries/search', async (req, res) => {
|
|
891
|
+
await resourceResponse(res, async () => {
|
|
892
|
+
const libraries = await getLibraries(workspaceRoot);
|
|
893
|
+
return { results: searchLibraries(libraries, req.body || {}) };
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
app.post('/api/libraries/context', async (req, res) => {
|
|
898
|
+
await resourceResponse(res, async () => {
|
|
899
|
+
const libraries = await getLibraries(workspaceRoot);
|
|
900
|
+
return { context: buildLibraryContext(libraries, req.body || {}) };
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
app.post('/api/libraries/render-pattern', async (req, res) => {
|
|
905
|
+
await resourceResponse(res, async () => {
|
|
906
|
+
const { patternId, values } = req.body || {};
|
|
907
|
+
const libraries = await getLibraries(workspaceRoot);
|
|
908
|
+
const pattern = libraries.sentencePatterns.find((item) => item.id === patternId);
|
|
909
|
+
if (!pattern) {
|
|
910
|
+
const error = new Error('Sentence pattern not found');
|
|
911
|
+
error.status = 404;
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
return renderSentencePattern(pattern, values);
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
app.post('/api/libraries/extract', async (req, res) => {
|
|
919
|
+
const { file } = req.body || {};
|
|
920
|
+
if (typeof file !== 'string' || !file) return res.status(400).json({ error: 'file is required' });
|
|
921
|
+
const safe = sanitizePath(file, workspaceRoot);
|
|
922
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
923
|
+
if (!safe.endsWith('.tex')) return res.status(400).json({ error: 'Only .tex files can be analyzed' });
|
|
924
|
+
await resourceResponse(res, async () => ({
|
|
925
|
+
candidates: extractLibraryCandidates(await readFile(safe, 'utf-8'), file),
|
|
926
|
+
}));
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
app.post('/api/libraries/extract-text', async (req, res) => {
|
|
930
|
+
await resourceResponse(res, async () => {
|
|
931
|
+
await hydrateAgentCommands();
|
|
932
|
+
return await extractTextCandidates(workspaceRoot, req.body || {}, { provider, commands: agentCommands });
|
|
933
|
+
}, 201);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
app.post('/api/libraries/vocabulary/:scope', async (req, res) => {
|
|
937
|
+
await resourceResponse(res, async () => ({
|
|
938
|
+
item: await createLibraryResource(workspaceRoot, 'vocabulary', req.params.scope, req.body),
|
|
939
|
+
}), 201);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
app.put('/api/libraries/vocabulary/:scope/:id', async (req, res) => {
|
|
943
|
+
await resourceResponse(res, async () => ({
|
|
944
|
+
item: await updateLibraryResource(workspaceRoot, 'vocabulary', req.params.scope, req.params.id, req.body),
|
|
945
|
+
}));
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
app.delete('/api/libraries/vocabulary/:scope/:id', async (req, res) => {
|
|
949
|
+
await resourceResponse(res, async () => {
|
|
950
|
+
await deleteLibraryResource(workspaceRoot, 'vocabulary', req.params.scope, req.params.id);
|
|
951
|
+
return { ok: true };
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
app.post('/api/libraries/:kind', async (req, res) => {
|
|
956
|
+
await resourceResponse(res, async () => ({
|
|
957
|
+
item: await createLibraryResource(workspaceRoot, req.params.kind, null, req.body),
|
|
958
|
+
}), 201);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
app.put('/api/libraries/:kind/:id', async (req, res) => {
|
|
962
|
+
await resourceResponse(res, async () => ({
|
|
963
|
+
item: await updateLibraryResource(workspaceRoot, req.params.kind, null, req.params.id, req.body),
|
|
964
|
+
}));
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
app.delete('/api/libraries/:kind/:id', async (req, res) => {
|
|
968
|
+
await resourceResponse(res, async () => {
|
|
969
|
+
await deleteLibraryResource(workspaceRoot, req.params.kind, null, req.params.id);
|
|
970
|
+
return { ok: true };
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
app.get('/api/annotations', async (req, res) => {
|
|
975
|
+
await resourceResponse(res, async () => ({ annotations: await listAnnotations(workspaceRoot, req.query.documentId) }));
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
app.get('/api/reviewer-profiles', (_req, res) => {
|
|
979
|
+
res.json(getReviewerProfileCatalog());
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
app.get('/api/reviews', async (req, res) => {
|
|
983
|
+
await resourceResponse(res, async () => ({ reviews: await listReviewRounds(workspaceRoot, req.query.documentId) }));
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
app.post('/api/reviews', async (req, res) => {
|
|
987
|
+
await resourceResponse(res, async () => ({ review: await createReviewRound(workspaceRoot, req.body || {}) }), 201);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
app.post('/api/reviews/:id/run', async (req, res) => {
|
|
991
|
+
const controller = new AbortController();
|
|
992
|
+
req.once('aborted', () => controller.abort());
|
|
993
|
+
await resourceResponse(res, async () => {
|
|
994
|
+
await hydrateAgentCommands();
|
|
995
|
+
return { review: await runReviewRound(workspaceRoot, req.params.id, { commands: agentCommands, signal: controller.signal }) };
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
app.post('/api/reviews/:id/to-revision', async (req, res) => {
|
|
1000
|
+
await resourceResponse(res, async () => await sendReviewItemsToRevision(
|
|
1001
|
+
workspaceRoot, req.params.id, req.body?.itemIds, req.body?.title,
|
|
1002
|
+
), 201);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
app.post('/api/review/import', async (req, res) => {
|
|
1006
|
+
await resourceResponse(res, async () => ({
|
|
1007
|
+
annotations: await importReviewOpinions(workspaceRoot, req.body || {}),
|
|
1008
|
+
}), 201);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
app.post('/api/review/orchestrate', async (req, res) => {
|
|
1012
|
+
const controller = new AbortController();
|
|
1013
|
+
req.once('aborted', () => controller.abort());
|
|
1014
|
+
await resourceResponse(res, async () => {
|
|
1015
|
+
await hydrateAgentCommands();
|
|
1016
|
+
return await orchestrateReviewOpinions(workspaceRoot, req.body || {}, {
|
|
1017
|
+
provider, commands: agentCommands, signal: controller.signal,
|
|
1018
|
+
});
|
|
1019
|
+
}, 201);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
app.post('/api/annotations', async (req, res) => {
|
|
1023
|
+
await resourceResponse(res, async () => ({ annotation: await createAnnotation(workspaceRoot, req.body) }), 201);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
app.put('/api/annotations/:id', async (req, res) => {
|
|
1027
|
+
await resourceResponse(res, async () => ({ annotation: await updateAnnotation(workspaceRoot, req.params.id, req.body) }));
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
app.delete('/api/annotations/:id', async (req, res) => {
|
|
1031
|
+
await resourceResponse(res, async () => {
|
|
1032
|
+
await deleteAnnotation(workspaceRoot, req.params.id);
|
|
1033
|
+
return { ok: true };
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
app.get('/api/revisions', async (req, res) => {
|
|
1038
|
+
await resourceResponse(res, async () => ({ revisions: await listRevisions(workspaceRoot, req.query.documentId) }));
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
app.get('/api/change-history', async (req, res) => {
|
|
1042
|
+
await resourceResponse(res, async () => ({
|
|
1043
|
+
entries: await getRecentChangeHistory(workspaceRoot, req.query.documentId, req.query.limit),
|
|
1044
|
+
}));
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
app.get('/api/change-history/:id', async (req, res) => {
|
|
1048
|
+
await resourceResponse(res, async () => ({ entry: await getChangeHistoryEntry(workspaceRoot, req.params.id) }));
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
app.post('/api/change-history/:id/restore', async (req, res) => {
|
|
1052
|
+
await resourceResponse(res, async () => await restoreRevisionVersion(workspaceRoot, req.params.id));
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
app.get('/api/change-history/:id/preview.pdf', async (req, res) => {
|
|
1056
|
+
let previewRoot = '';
|
|
1057
|
+
try {
|
|
1058
|
+
const { document, source } = await getHistoricalRevisionSource(workspaceRoot, req.params.id);
|
|
1059
|
+
const previewBase = join(workspaceRoot, '.papergod', 'previews');
|
|
1060
|
+
await mkdir(previewBase, { recursive: true });
|
|
1061
|
+
previewRoot = await mkdtemp(join(previewBase, 'render-'));
|
|
1062
|
+
const entries = await readdir(workspaceRoot, { withFileTypes: true });
|
|
1063
|
+
for (const entry of entries) {
|
|
1064
|
+
if (entry.name === '.papergod') continue;
|
|
1065
|
+
await cp(join(workspaceRoot, entry.name), join(previewRoot, entry.name), { recursive: true });
|
|
1066
|
+
}
|
|
1067
|
+
const previewTex = sanitizePath(document.file, previewRoot);
|
|
1068
|
+
if (!previewTex) throw Object.assign(new Error('Invalid historical document path'), { status: 403 });
|
|
1069
|
+
await mkdir(dirname(previewTex), { recursive: true });
|
|
1070
|
+
await writeFile(previewTex, source, 'utf-8');
|
|
1071
|
+
const result = await compile(previewTex, previewRoot);
|
|
1072
|
+
if (!result.ok) {
|
|
1073
|
+
await rm(previewRoot, { recursive: true, force: true });
|
|
1074
|
+
return res.status(422).json({ error: result.error, log: result.log || null });
|
|
1075
|
+
}
|
|
1076
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1077
|
+
res.sendFile(result.pdf, (error) => {
|
|
1078
|
+
rm(previewRoot, { recursive: true, force: true }).catch(() => {});
|
|
1079
|
+
if (error && !res.headersSent) res.status(error.statusCode || 500).json({ error: error.message });
|
|
1080
|
+
});
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
if (previewRoot) await rm(previewRoot, { recursive: true, force: true }).catch(() => {});
|
|
1083
|
+
res.status(error.status || 500).json({ error: publicErrorMessage(error), code: error.code });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
app.post('/api/generate/paper', async (req, res) => {
|
|
1088
|
+
const controller = new AbortController();
|
|
1089
|
+
req.once('aborted', () => controller.abort());
|
|
1090
|
+
await resourceResponse(res, async () => {
|
|
1091
|
+
await hydrateAgentCommands();
|
|
1092
|
+
return await generatePaperRevision(workspaceRoot, req.body || {}, {
|
|
1093
|
+
provider, commands: agentCommands, signal: controller.signal,
|
|
1094
|
+
});
|
|
1095
|
+
}, 201);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
app.get('/api/workflow/history', async (req, res) => {
|
|
1099
|
+
await resourceResponse(res, async () => ({ events: await getWorkflowHistory(workspaceRoot, req.query.documentId) }));
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
app.get('/api/workflow/export', async (req, res) => {
|
|
1103
|
+
await resourceResponse(res, async () => ({ bundle: await buildWorkflowExport(workspaceRoot, req.query.documentId) }));
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
app.post('/api/revisions/:id/package', async (req, res) => {
|
|
1107
|
+
await resourceResponse(res, async () => await generateRevisionPackage(workspaceRoot, req.params.id));
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
app.put('/api/revisions/:id/response-letter', async (req, res) => {
|
|
1111
|
+
await resourceResponse(res, async () => ({
|
|
1112
|
+
responseLetter: await updateRevisionResponseLetter(workspaceRoot, req.params.id, req.body || {}),
|
|
1113
|
+
}));
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
app.post('/api/revisions/:id/verify', async (req, res) => {
|
|
1117
|
+
await resourceResponse(res, async () => await verifyAppliedRevision(workspaceRoot, req.params.id));
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
app.post('/api/revisions/plan', async (req, res) => {
|
|
1121
|
+
await resourceResponse(res, async () => ({
|
|
1122
|
+
revision: await createRevisionPlan(workspaceRoot, req.body || {}),
|
|
1123
|
+
}), 201);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
app.put('/api/revisions/:id/decisions', async (req, res) => {
|
|
1127
|
+
await resourceResponse(res, async () => ({
|
|
1128
|
+
revision: await decideRevisionChanges(workspaceRoot, req.params.id, req.body?.decisions),
|
|
1129
|
+
}));
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
app.post('/api/revisions/:id/apply', async (req, res) => {
|
|
1133
|
+
await resourceResponse(res, async () => await applyRevision(workspaceRoot, req.params.id));
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
app.post('/api/revisions/:id/rollback', async (req, res) => {
|
|
1137
|
+
await resourceResponse(res, async () => await rollbackRevision(workspaceRoot, req.params.id));
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
app.post('/api/revisions', async (req, res) => {
|
|
1141
|
+
await resourceResponse(res, async () => ({ revision: await createRevision(workspaceRoot, req.body) }), 201);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
app.put('/api/revisions/:id', async (req, res) => {
|
|
1145
|
+
await resourceResponse(res, async () => ({ revision: await updateRevision(workspaceRoot, req.params.id, req.body) }));
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
app.delete('/api/revisions/:id', async (req, res) => {
|
|
1149
|
+
await resourceResponse(res, async () => {
|
|
1150
|
+
await deleteRevision(workspaceRoot, req.params.id);
|
|
1151
|
+
return { ok: true };
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
app.get('/api/files', async (_req, res) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const entries = await readdir(workspaceRoot);
|
|
1158
|
+
const files = entries.filter((f) => f.endsWith('.tex'));
|
|
1159
|
+
const pdfs = entries.filter((f) => f.endsWith('.pdf'));
|
|
1160
|
+
res.json({ files, pdfs });
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
res.status(500).json({ error: e.message });
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
app.get('/api/files/*', async (req, res) => {
|
|
1167
|
+
const filePath = req.params[0];
|
|
1168
|
+
const safe = sanitizePath(filePath, workspaceRoot);
|
|
1169
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
1170
|
+
try {
|
|
1171
|
+
const content = await readFile(safe, 'utf-8');
|
|
1172
|
+
res.json({ name: filePath, content });
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
if (e.code === 'ENOENT') return res.status(404).json({ error: 'File not found' });
|
|
1175
|
+
res.status(500).json({ error: e.message });
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
app.put('/api/files/*', async (req, res) => {
|
|
1180
|
+
const filePath = req.params[0];
|
|
1181
|
+
const safe = sanitizePath(filePath, workspaceRoot);
|
|
1182
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
1183
|
+
if (!safe.endsWith('.tex')) return res.status(400).json({ error: 'Only .tex files can be edited' });
|
|
1184
|
+
const { content } = req.body;
|
|
1185
|
+
if (typeof content !== 'string') return res.status(400).json({ error: 'Content must be a string' });
|
|
1186
|
+
try {
|
|
1187
|
+
await writeFile(safe, content, 'utf-8');
|
|
1188
|
+
res.json({ ok: true });
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
res.status(500).json({ error: e.message });
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
app.post('/api/compile', async (req, res) => {
|
|
1195
|
+
const { file } = req.body || {};
|
|
1196
|
+
if (!file || typeof file !== 'string') return res.status(400).json({ error: 'file is required' });
|
|
1197
|
+
const safe = sanitizePath(file, workspaceRoot);
|
|
1198
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
1199
|
+
if (!safe.endsWith('.tex')) return res.status(400).json({ error: 'Only .tex files can be compiled' });
|
|
1200
|
+
try {
|
|
1201
|
+
const s = await stat(safe);
|
|
1202
|
+
if (!s.isFile()) return res.status(400).json({ error: 'Not a file' });
|
|
1203
|
+
} catch {
|
|
1204
|
+
return res.status(404).json({ error: 'File not found' });
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
const result = await compile(safe, workspaceRoot);
|
|
1208
|
+
if (result.ok) {
|
|
1209
|
+
const pdfName = file.replace(/\.tex$/, '.pdf');
|
|
1210
|
+
res.json({ ok: true, pdf: `/workspace/${pdfName}`, engine: result.engine });
|
|
1211
|
+
} else {
|
|
1212
|
+
res.json({ ok: false, error: result.error, engine: result.engine, log: result.log || null });
|
|
1213
|
+
}
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
res.status(500).json({ error: e.message });
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
app.post('/api/agent/suggest', async (req, res) => {
|
|
1220
|
+
const { documentId, content, prompt, promptIsComposed = false, resourceIds, citekeys = [], intentIds = [], additionalRequirements = '', manifest = null, manifestPath = '', payloadHash = '' } = req.body || {};
|
|
1221
|
+
if (typeof content !== 'string') return res.status(400).json({ error: 'content is required' });
|
|
1222
|
+
if (manifest !== null) return res.status(400).json({ error: 'Send manifestPath, not a client-supplied manifest object', code: 'INVALID_PROMPT_MANIFEST' });
|
|
1223
|
+
if (manifestPath && !payloadHash) return res.status(400).json({ error: 'Manifest execution requires a confirmed payload hash', code: 'PROMPT_CONFIRMATION_REQUIRED' });
|
|
1224
|
+
try {
|
|
1225
|
+
const project = await loadProject(workspaceRoot);
|
|
1226
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
1227
|
+
let resolvedManifest = null;
|
|
1228
|
+
let resolvedManifestPath = manifestPath;
|
|
1229
|
+
let resolvedManifestSerialized = '';
|
|
1230
|
+
if (manifestPath) {
|
|
1231
|
+
const loadedManifest = await loadPromptManifest(workspaceRoot, manifestPath);
|
|
1232
|
+
resolvedManifest = loadedManifest.manifest;
|
|
1233
|
+
resolvedManifestSerialized = loadedManifest.serialized;
|
|
1234
|
+
}
|
|
1235
|
+
let effectivePrompt = promptIsComposed ? prompt : [
|
|
1236
|
+
project.project.corePrompt && `Paper core prompt:\n${project.project.corePrompt}`,
|
|
1237
|
+
document?.corePrompt && `Document prompt:\n${document.corePrompt}`,
|
|
1238
|
+
`Current editing request:\n${typeof prompt === 'string' && prompt.trim() ? prompt : 'Improve this scope according to the supplied writing context.'}`,
|
|
1239
|
+
].filter(Boolean).join('\n\n');
|
|
1240
|
+
if (!resolvedManifest && intentIds.length && document) {
|
|
1241
|
+
const generatedManifest = await materializePromptManifest(workspaceRoot, {
|
|
1242
|
+
documentId: document.id, intentIds, additionalRequirements: additionalRequirements || prompt,
|
|
1243
|
+
sourceContent: content,
|
|
1244
|
+
resourceIds: Array.isArray(resourceIds) ? resourceIds : [], citekeys,
|
|
1245
|
+
});
|
|
1246
|
+
resolvedManifest = generatedManifest.manifest;
|
|
1247
|
+
resolvedManifestPath = generatedManifest.manifestPath;
|
|
1248
|
+
resolvedManifestSerialized = generatedManifest.serialized;
|
|
1249
|
+
effectivePrompt = generatedManifest.prompt;
|
|
1250
|
+
}
|
|
1251
|
+
if (resolvedManifest?.tasks?.length) {
|
|
1252
|
+
const actualSourceHash = createHash('sha256').update(content).digest('hex');
|
|
1253
|
+
if (resolvedManifest.tasks.some((task) => task.target?.sourceHash && task.target.sourceHash !== actualSourceHash)) {
|
|
1254
|
+
throw Object.assign(new Error('The manuscript changed after the Prompt Manifest was prepared; refresh the preview before invoking the Agent.'), { status: 409, code: 'PROMPT_TARGET_STALE' });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const libraries = project.libraries;
|
|
1258
|
+
const libraryContext = buildLibraryContext(libraries, {
|
|
1259
|
+
query: `${effectivePrompt}\n${content}`, resourceIds,
|
|
1260
|
+
});
|
|
1261
|
+
res.json(await requestSuggestions(content, effectivePrompt, req, {
|
|
1262
|
+
libraryContext, file: document?.file || '', nodeStart: 0,
|
|
1263
|
+
manifest: resolvedManifest, manifestPath: resolvedManifestPath, manifestSerialized: resolvedManifestSerialized, payloadHash, intentIds, additionalRequirements, citekeys,
|
|
1264
|
+
}));
|
|
1265
|
+
} catch (e) {
|
|
1266
|
+
res.status(e.status || 500).json({ error: e.message, code: e.code, details: e.details });
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
app.post('/api/agent/suggest-node', async (req, res) => {
|
|
1271
|
+
const { nodeId, prompt, promptIsComposed = false, resourceIds, citekeys = [], intentIds = [], additionalRequirements = '', manifest = null, manifestPath = '', payloadHash = '' } = req.body || {};
|
|
1272
|
+
if (typeof nodeId !== 'string' || !nodeId) return res.status(400).json({ error: 'nodeId is required' });
|
|
1273
|
+
if (manifest !== null) return res.status(400).json({ error: 'Send manifestPath, not a client-supplied manifest object', code: 'INVALID_PROMPT_MANIFEST' });
|
|
1274
|
+
if (manifestPath && !payloadHash) return res.status(400).json({ error: 'Manifest execution requires a confirmed payload hash', code: 'PROMPT_CONFIRMATION_REQUIRED' });
|
|
1275
|
+
try {
|
|
1276
|
+
const context = await getNodeSourceContext(workspaceRoot, nodeId);
|
|
1277
|
+
let resolvedManifest = null;
|
|
1278
|
+
let resolvedManifestSerialized = '';
|
|
1279
|
+
if (manifestPath) {
|
|
1280
|
+
const loadedManifest = await loadPromptManifest(workspaceRoot, manifestPath);
|
|
1281
|
+
resolvedManifest = loadedManifest.manifest;
|
|
1282
|
+
resolvedManifestSerialized = loadedManifest.serialized;
|
|
1283
|
+
}
|
|
1284
|
+
if (resolvedManifest?.tasks?.some((task) => task.target?.sourceHash && task.target.sourceHash !== context.document.sourceHash)) {
|
|
1285
|
+
throw Object.assign(new Error('The target changed after the Prompt Manifest was prepared; refresh the preview before invoking the Agent.'), { status: 409, code: 'PROMPT_TARGET_STALE' });
|
|
1286
|
+
}
|
|
1287
|
+
const parent = context.node.parentId ? findStructureNode(context.document, context.node.parentId) : null;
|
|
1288
|
+
const layers = promptIsComposed ? prompt : [
|
|
1289
|
+
context.project.project.corePrompt && `Paper core prompt:\n${context.project.project.corePrompt}`,
|
|
1290
|
+
context.document.corePrompt && `Document prompt:\n${context.document.corePrompt}`,
|
|
1291
|
+
parent?.type === 'paragraph' && parent.prompt && `Paragraph prompt:\n${parent.prompt}`,
|
|
1292
|
+
context.node.prompt && `Element prompt:\n${context.node.prompt}`,
|
|
1293
|
+
`Current editing request:\n${typeof prompt === 'string' ? prompt : ''}`,
|
|
1294
|
+
].filter(Boolean).join('\n\n');
|
|
1295
|
+
const libraryContext = buildLibraryContext(context.project.libraries, {
|
|
1296
|
+
query: `${context.node.summary || ''} ${context.node.prompt || ''} ${prompt || ''} ${context.selectedContent}`,
|
|
1297
|
+
sectionType: context.section?.title || '', resourceIds,
|
|
1298
|
+
});
|
|
1299
|
+
const result = await requestSuggestions(context.selectedContent, layers, req, {
|
|
1300
|
+
file: context.document.file, nodeId, nodeStart: context.sourceRange.start,
|
|
1301
|
+
libraryContext,
|
|
1302
|
+
manifest: resolvedManifest, manifestPath, manifestSerialized: resolvedManifestSerialized, payloadHash, intentIds, additionalRequirements, citekeys,
|
|
1303
|
+
});
|
|
1304
|
+
res.json({ ...result, nodeId, sourceRange: context.sourceRange });
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
res.status(e.status || 500).json({ error: e.message, code: e.code, details: e.details });
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
app.post('/api/agent/generate-paragraph', async (req, res) => {
|
|
1311
|
+
const { prompt = '', nodeId, documentId, resourceIds, sectionType } = req.body || {};
|
|
1312
|
+
if (typeof prompt !== 'string') return res.status(400).json({ error: 'prompt must be a string' });
|
|
1313
|
+
try {
|
|
1314
|
+
const project = await loadProject(workspaceRoot);
|
|
1315
|
+
let document = project.documents.find((item) => item.id === documentId);
|
|
1316
|
+
let node = null;
|
|
1317
|
+
let section = null;
|
|
1318
|
+
if (typeof nodeId === 'string' && nodeId) {
|
|
1319
|
+
const context = await getNodeSourceContext(workspaceRoot, nodeId);
|
|
1320
|
+
document = context.document; node = context.node; section = context.section;
|
|
1321
|
+
}
|
|
1322
|
+
const effectivePrompt = [
|
|
1323
|
+
project.project.corePrompt && `Paper core prompt:\n${project.project.corePrompt}`,
|
|
1324
|
+
document?.corePrompt && `Document prompt:\n${document.corePrompt}`,
|
|
1325
|
+
section?.prompt && section.id !== node?.id && `Section prompt:\n${section.prompt}`,
|
|
1326
|
+
node?.prompt && `Element prompt:\n${node.prompt}`,
|
|
1327
|
+
`Current paragraph request:\n${prompt.trim() || 'Draft a paragraph that fulfills the supplied writing context.'}`,
|
|
1328
|
+
].filter(Boolean).join('\n\n');
|
|
1329
|
+
const libraries = project.libraries;
|
|
1330
|
+
const libraryContext = buildLibraryContext(libraries, { query: effectivePrompt, sectionType: section?.title || sectionType || '', resourceIds });
|
|
1331
|
+
if (provider === 'mock') {
|
|
1332
|
+
const generated = composeMockParagraph(libraries, libraryContext, effectivePrompt);
|
|
1333
|
+
const completedAt = new Date().toISOString();
|
|
1334
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
1335
|
+
provider, operation: 'generate-paragraph', status: 'complete', prompt: effectivePrompt,
|
|
1336
|
+
input: JSON.stringify({ providedResources: libraryContext.resources, libraryMode: libraryContext.mode }),
|
|
1337
|
+
output: JSON.stringify(generated), error: '', startedAt: completedAt, finishedAt: completedAt,
|
|
1338
|
+
});
|
|
1339
|
+
return res.json({
|
|
1340
|
+
provider, runId: run.id, draft: generated.draft,
|
|
1341
|
+
library: {
|
|
1342
|
+
mode: libraryContext.mode, providedResources: libraryContext.resources,
|
|
1343
|
+
usedResourceIds: generated.usedResourceIds,
|
|
1344
|
+
},
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
const sentinel = '[[PAPERGOD_PARAGRAPH_DRAFT]]';
|
|
1348
|
+
const instruction = `Generate one cohesive academic paragraph for this writing context:\n${effectivePrompt}\nReturn exactly one suggestion that replaces the exact originalText ${sentinel} with the paragraph.`;
|
|
1349
|
+
const result = await requestSuggestions(sentinel, instruction, req, { libraryContext });
|
|
1350
|
+
const proposal = result.suggestions.find((item) => item.originalText === sentinel);
|
|
1351
|
+
if (!proposal) {
|
|
1352
|
+
const error = new Error('Agent did not return a paragraph draft');
|
|
1353
|
+
error.status = 502;
|
|
1354
|
+
throw error;
|
|
1355
|
+
}
|
|
1356
|
+
res.json({ ...result, draft: proposal.suggestedText, suggestions: undefined });
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
res.status(error.status || 500).json({ error: error.message, code: error.code, details: error.details });
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
app.post('/api/agent/insert-paragraph', async (req, res) => {
|
|
1363
|
+
await resourceResponse(res, async () => await insertGeneratedParagraph(workspaceRoot, req.body || {}));
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
app.post('/api/agent/apply', async (req, res) => {
|
|
1367
|
+
const { file, suggestionId } = req.body || {};
|
|
1368
|
+
if (!file || !suggestionId) return res.status(400).json({ error: 'file and suggestionId are required' });
|
|
1369
|
+
const safe = sanitizePath(file, workspaceRoot);
|
|
1370
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
1371
|
+
if (!safe.endsWith('.tex')) return res.status(400).json({ error: 'Only .tex files can be edited' });
|
|
1372
|
+
try {
|
|
1373
|
+
const result = await applySuggestionAsRevision(workspaceRoot, file, getSuggestion(suggestionId));
|
|
1374
|
+
removeSuggestion(suggestionId);
|
|
1375
|
+
res.json({ ok: true, ...result });
|
|
1376
|
+
} catch (e) {
|
|
1377
|
+
res.status(e.status || 500).json({ error: e.message, code: e.code });
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
app.post('/api/agent/apply-all', async (req, res) => {
|
|
1382
|
+
const { file, suggestionIds } = req.body || {};
|
|
1383
|
+
if (!file || !Array.isArray(suggestionIds) || !suggestionIds.length) return res.status(400).json({ error: 'file and suggestionIds are required' });
|
|
1384
|
+
const safe = sanitizePath(file, workspaceRoot);
|
|
1385
|
+
if (!safe) return res.status(403).json({ error: 'Access denied' });
|
|
1386
|
+
if (!safe.endsWith('.tex')) return res.status(400).json({ error: 'Only .tex files can be edited' });
|
|
1387
|
+
try {
|
|
1388
|
+
const selected = suggestionIds.map((suggestionId) => getSuggestion(suggestionId));
|
|
1389
|
+
const result = await applySuggestionsAsRevision(workspaceRoot, file, selected);
|
|
1390
|
+
suggestionIds.forEach(removeSuggestion);
|
|
1391
|
+
res.json({ ok: true, ...result });
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
res.status(error.status || 500).json({ error: error.message, code: error.code });
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
app.post('/api/agent/reject', async (req, res) => {
|
|
1398
|
+
const { suggestionId, file } = req.body || {};
|
|
1399
|
+
if (!suggestionId || !file) return res.status(400).json({ error: 'file and suggestionId are required' });
|
|
1400
|
+
try {
|
|
1401
|
+
const revision = await recordRejectedSuggestion(workspaceRoot, file, getSuggestion(suggestionId));
|
|
1402
|
+
removeSuggestion(suggestionId);
|
|
1403
|
+
res.json({ ok: true, revision });
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
res.status(error.status || 500).json({ error: error.message, code: error.code });
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
app.use((_req, res) => {
|
|
1410
|
+
res.status(404).json({ error: 'Not found' });
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
return app;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export async function startServer({ workspaceRoot = DEFAULT_WORKSPACE, port = 3000, provider = 'mock', workspaceRegistryFile } = {}) {
|
|
1417
|
+
const app = createApp(workspaceRoot, { provider, workspaceRegistryFile });
|
|
1418
|
+
return await new Promise((resolveServer, reject) => {
|
|
1419
|
+
const server = app.listen(port, '127.0.0.1', () => resolveServer(server));
|
|
1420
|
+
server.once('close', () => app.locals.cleanup?.());
|
|
1421
|
+
server.once('error', reject);
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1426
|
+
|
|
1427
|
+
if (isMainModule) {
|
|
1428
|
+
const port = parseInt(process.env.PORT || '3000', 10);
|
|
1429
|
+
const host = '127.0.0.1';
|
|
1430
|
+
const workspace = process.env.WORKSPACE || DEFAULT_WORKSPACE;
|
|
1431
|
+
const server = await startServer({ workspaceRoot: workspace, port });
|
|
1432
|
+
console.log(`Papergod running at http://${host}:${server.address().port}`);
|
|
1433
|
+
|
|
1434
|
+
function shutdown() {
|
|
1435
|
+
console.log('\nShutting down...');
|
|
1436
|
+
server.close(() => process.exit(0));
|
|
1437
|
+
setTimeout(() => process.exit(1), 5000);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
process.on('SIGINT', shutdown);
|
|
1441
|
+
process.on('SIGTERM', shutdown);
|
|
1442
|
+
}
|