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,1240 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
4
|
+
import { basename, dirname, extname, join } from 'path';
|
|
5
|
+
import { homedir, tmpdir } from 'os';
|
|
6
|
+
import { AgentError, classifyAgentDiagnostic, incompleteJsonKind, normalizeAgentError, redactAgentDiagnostic } from './agent-errors.js';
|
|
7
|
+
import { agentHealthStatus, clearAgentHealth, inspectCliCapabilities, markAgentUnavailable } from './agent-runtime.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
|
+
const MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const MAX_INPUT_CHARS = 500_000;
|
|
12
|
+
export const AGENT_PROVIDERS = ['mock', 'codex', 'claude-code', 'opencode', 'pi'];
|
|
13
|
+
|
|
14
|
+
export const SUGGESTION_OUTPUT_SCHEMA = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
required: ['summary', 'suggestions', 'unresolvedTasks', 'usedResourceIds'],
|
|
18
|
+
properties: {
|
|
19
|
+
summary: { type: 'string' },
|
|
20
|
+
usedResourceIds: { type: 'array', items: { type: 'string' } },
|
|
21
|
+
unresolvedTasks: {
|
|
22
|
+
type: 'array', maxItems: 50,
|
|
23
|
+
items: { type: 'object', additionalProperties: false, required: ['taskId', 'reason'], properties: { taskId: { type: 'string' }, reason: { type: 'string' } } },
|
|
24
|
+
},
|
|
25
|
+
suggestions: {
|
|
26
|
+
type: 'array',
|
|
27
|
+
maxItems: 50,
|
|
28
|
+
items: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
required: ['taskId', 'nodeId', 'category', 'description', 'originalText', 'suggestedText', 'reason', 'usedTemplateIds', 'usedCitekeys'],
|
|
32
|
+
properties: {
|
|
33
|
+
taskId: { type: 'string' },
|
|
34
|
+
nodeId: { type: 'string' },
|
|
35
|
+
category: { type: 'string', enum: ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'] },
|
|
36
|
+
description: { type: 'string' },
|
|
37
|
+
originalText: { type: 'string' },
|
|
38
|
+
suggestedText: { type: 'string' },
|
|
39
|
+
reason: { type: 'string' },
|
|
40
|
+
usedTemplateIds: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
usedCitekeys: { type: 'array', items: { type: 'string' } },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const REVIEW_OUTPUT_SCHEMA = {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
required: ['summary', 'verdict', 'confidence', 'items'],
|
|
52
|
+
properties: {
|
|
53
|
+
summary: { type: 'string' },
|
|
54
|
+
verdict: { type: 'string', enum: ['accept', 'minor-revision', 'major-revision', 'reject'] },
|
|
55
|
+
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
|
56
|
+
items: {
|
|
57
|
+
type: 'array', maxItems: 30,
|
|
58
|
+
items: {
|
|
59
|
+
type: 'object', additionalProperties: false,
|
|
60
|
+
required: ['rubricId', 'kind', 'category', 'severity', 'body', 'suggestedFix', 'quote'],
|
|
61
|
+
properties: {
|
|
62
|
+
rubricId: { type: 'string' },
|
|
63
|
+
kind: { type: 'string', enum: ['concern', 'strength'] },
|
|
64
|
+
category: { type: 'string', enum: ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'] },
|
|
65
|
+
severity: { type: 'string', enum: ['info', 'minor', 'major', 'critical'] },
|
|
66
|
+
body: { type: 'string' }, suggestedFix: { type: 'string' }, quote: { type: 'string' },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const PAPER_GENERATION_OUTPUT_SCHEMA = {
|
|
74
|
+
type: 'object', additionalProperties: false,
|
|
75
|
+
required: ['summary', 'latex', 'usedResourceIds'],
|
|
76
|
+
properties: {
|
|
77
|
+
summary: { type: 'string' }, latex: { type: 'string' },
|
|
78
|
+
usedResourceIds: { type: 'array', items: { type: 'string' } },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const REVIEW_ORCHESTRATION_OUTPUT_SCHEMA = {
|
|
83
|
+
type: 'object', additionalProperties: false, required: ['summary', 'opinions'],
|
|
84
|
+
properties: {
|
|
85
|
+
summary: { type: 'string' },
|
|
86
|
+
opinions: { type: 'array', maxItems: 100, items: {
|
|
87
|
+
type: 'object', additionalProperties: false,
|
|
88
|
+
required: ['body', 'category', 'severity', 'quote', 'suggestedFix', 'dependsOn'],
|
|
89
|
+
properties: {
|
|
90
|
+
body: { type: 'string' },
|
|
91
|
+
category: { type: 'string', enum: ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'] },
|
|
92
|
+
severity: { type: 'string', enum: ['info', 'minor', 'major', 'critical'] },
|
|
93
|
+
quote: { type: 'string' }, suggestedFix: { type: 'string' },
|
|
94
|
+
dependsOn: { type: 'array', items: { type: 'integer', minimum: 1 } },
|
|
95
|
+
},
|
|
96
|
+
} },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function safeEnvironment() {
|
|
101
|
+
const blocked = /^(NODE_OPTIONS|BASH_ENV|ENV|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_.*)$/;
|
|
102
|
+
return Object.fromEntries(Object.entries(process.env).filter(([key]) => !blocked.test(key)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const knownWindowsPaths = new Map();
|
|
106
|
+
|
|
107
|
+
function discoverKnownWindowsPath(provider) {
|
|
108
|
+
if (process.platform !== 'win32') return null;
|
|
109
|
+
const cached = knownWindowsPaths.get(provider);
|
|
110
|
+
if (cached && existsSync(cached)) return cached;
|
|
111
|
+
let found = null;
|
|
112
|
+
if (provider === 'codex') {
|
|
113
|
+
// Codex CLI installs under %LOCALAPPDATA%\OpenAI\Codex\bin\<hash>\codex.exe
|
|
114
|
+
// without registering itself on PATH. The hash directory changes on every
|
|
115
|
+
// Codex update, so re-scan whenever the cached path is gone.
|
|
116
|
+
const base = join(process.env.LOCALAPPDATA || '', 'OpenAI', 'Codex', 'bin');
|
|
117
|
+
let entries;
|
|
118
|
+
try { entries = readdirSync(base); } catch { entries = []; }
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const candidate = join(base, entry, 'codex.exe');
|
|
121
|
+
if (existsSync(candidate)) { found = candidate; break; }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
knownWindowsPaths.set(provider, found);
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function commandSpec(provider, overrides = {}) {
|
|
129
|
+
const override = overrides[provider];
|
|
130
|
+
const defaultCommand = provider === 'claude-code' ? 'claude' : provider;
|
|
131
|
+
if (!override) {
|
|
132
|
+
const known = discoverKnownWindowsPath(provider);
|
|
133
|
+
return { command: known || defaultCommand, prefixArgs: [], model: '' };
|
|
134
|
+
}
|
|
135
|
+
if (typeof override === 'string') {
|
|
136
|
+
const command = override === defaultCommand ? (discoverKnownWindowsPath(provider) || defaultCommand) : override;
|
|
137
|
+
return { command, prefixArgs: [], model: '' };
|
|
138
|
+
}
|
|
139
|
+
// A saved profile that only repeats the default command name (e.g. "codex")
|
|
140
|
+
// must not disable automatic discovery: the Codex CLI moves between hash
|
|
141
|
+
// directories on update and is often not on PATH at all.
|
|
142
|
+
const configuredCommand = override.command || defaultCommand;
|
|
143
|
+
const command = configuredCommand === defaultCommand
|
|
144
|
+
? (discoverKnownWindowsPath(provider) || defaultCommand)
|
|
145
|
+
: configuredCommand;
|
|
146
|
+
return {
|
|
147
|
+
command,
|
|
148
|
+
prefixArgs: Array.isArray(override.args) ? override.args : [],
|
|
149
|
+
model: typeof override.model === 'string' ? override.model.trim() : '',
|
|
150
|
+
reasoningEffort: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(override.reasoningEffort) ? override.reasoningEffort : '',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function modelArgs(spec) {
|
|
155
|
+
return spec.model ? ['--model', spec.model] : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function reasoningArgs(provider, effort) {
|
|
159
|
+
if (!effort) return [];
|
|
160
|
+
if (provider === 'codex') return ['--config', `model_reasoning_effort="${effort}"`];
|
|
161
|
+
if (provider === 'claude-code') return ['--effort', effort === 'xhigh' ? 'high' : effort];
|
|
162
|
+
if (provider === 'opencode') return ['--variant', effort];
|
|
163
|
+
if (provider === 'pi') return ['--thinking', effort === 'xhigh' ? 'high' : effort];
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function operationEffort(operation, configured = '') {
|
|
168
|
+
if (configured) return configured;
|
|
169
|
+
return operation === 'suggest' ? 'low' : ['review', 'generation', 'orchestration'].includes(operation) ? 'high' : 'medium';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveWindowsCommand(command) {
|
|
173
|
+
if (process.platform !== 'win32') return { command, shell: false };
|
|
174
|
+
if (extname(command)) return { command, shell: false }; // explicit codex.exe / pi.cmd
|
|
175
|
+
const hasPath = command.includes('\\') || command.includes('/');
|
|
176
|
+
const name = basename(command);
|
|
177
|
+
const dirs = hasPath ? [dirname(command)] : (process.env.PATH || '').split(';').filter(Boolean);
|
|
178
|
+
for (const dir of dirs) {
|
|
179
|
+
for (const extension of ['.exe', '.cmd', '.bat']) {
|
|
180
|
+
const candidate = join(dir, name + extension);
|
|
181
|
+
if (existsSync(candidate)) return { command: candidate, shell: extension !== '.exe' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { command, shell: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function runProcess(command, args, { cwd, input = '', timeoutMs = DEFAULT_TIMEOUT_MS, signal, allowFailure = false, onOutput, envOverrides = {} } = {}) {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
if (signal?.aborted) {
|
|
190
|
+
const error = new Error('Agent run cancelled');
|
|
191
|
+
error.code = 'AGENT_CANCELLED';
|
|
192
|
+
return reject(error);
|
|
193
|
+
}
|
|
194
|
+
// Windows: npm-installed CLIs ship as .cmd/.bat shims without an .exe.
|
|
195
|
+
// Node's spawn with shell:false cannot execute those directly, so resolve the
|
|
196
|
+
// shim on PATH (or next to the given path) and run it through cmd.exe with
|
|
197
|
+
// explicitly quoted arguments (avoids the DEP0190 shell:true concatenation).
|
|
198
|
+
const resolved = resolveWindowsCommand(command);
|
|
199
|
+
const env = { ...safeEnvironment(), ...envOverrides };
|
|
200
|
+
let child;
|
|
201
|
+
if (resolved.shell) {
|
|
202
|
+
// cmd /c strips the leading quote and the last quote, so wrap the whole
|
|
203
|
+
// line in an extra pair of quotes: ""C:\...\pi.cmd" "--version""
|
|
204
|
+
const inner = [`"${resolved.command}"`, ...args.map((arg) => `"${String(arg).replace(/"/g, '""')}"`)].join(' ');
|
|
205
|
+
const commandLine = `"${inner}"`;
|
|
206
|
+
child = spawn('cmd.exe', ['/d', '/s', '/c', commandLine], {
|
|
207
|
+
cwd, env, shell: false, windowsVerbatimArguments: true,
|
|
208
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
child = spawn(resolved.command, args, {
|
|
212
|
+
cwd, env, shell: false, detached: process.platform !== 'win32',
|
|
213
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
let stdout = '';
|
|
217
|
+
let stderr = '';
|
|
218
|
+
let outputBytes = 0;
|
|
219
|
+
let settled = false;
|
|
220
|
+
let timer;
|
|
221
|
+
let forceTimer;
|
|
222
|
+
|
|
223
|
+
const finish = (error, result) => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
signal?.removeEventListener('abort', cancel);
|
|
228
|
+
if (error) reject(error);
|
|
229
|
+
else resolve(result);
|
|
230
|
+
};
|
|
231
|
+
let pendingTerminationError = null;
|
|
232
|
+
const windowsTreeKill = (force = false) => {
|
|
233
|
+
if (!child.pid) return;
|
|
234
|
+
try { spawn('taskkill.exe', ['/pid', String(child.pid), '/t', ...(force ? ['/f'] : [])], { windowsHide: true, stdio: 'ignore' }).unref(); } catch {}
|
|
235
|
+
};
|
|
236
|
+
const unixGroupAlive = () => {
|
|
237
|
+
if (process.platform === 'win32' || !child.pid) return false;
|
|
238
|
+
try { process.kill(-child.pid, 0); return true; } catch { return false; }
|
|
239
|
+
};
|
|
240
|
+
const terminateTree = (error) => {
|
|
241
|
+
if (settled || pendingTerminationError) return;
|
|
242
|
+
pendingTerminationError = error;
|
|
243
|
+
try {
|
|
244
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGTERM');
|
|
245
|
+
else windowsTreeKill(false);
|
|
246
|
+
} catch {}
|
|
247
|
+
forceTimer = setTimeout(() => {
|
|
248
|
+
try {
|
|
249
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
250
|
+
else windowsTreeKill(true);
|
|
251
|
+
} catch {}
|
|
252
|
+
setTimeout(() => finish(pendingTerminationError), 25).unref();
|
|
253
|
+
}, 750);
|
|
254
|
+
forceTimer.unref();
|
|
255
|
+
};
|
|
256
|
+
const cancel = () => {
|
|
257
|
+
const error = new Error('Agent run cancelled');
|
|
258
|
+
error.code = 'AGENT_CANCELLED';
|
|
259
|
+
terminateTree(error);
|
|
260
|
+
};
|
|
261
|
+
const append = (current, chunk) => {
|
|
262
|
+
outputBytes += chunk.length;
|
|
263
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
264
|
+
const error = new Error('Agent output exceeded 5 MiB');
|
|
265
|
+
error.code = 'AGENT_OUTPUT_LIMIT';
|
|
266
|
+
terminateTree(error);
|
|
267
|
+
}
|
|
268
|
+
return current + chunk.toString('utf-8');
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); onOutput?.('stdout', chunk.toString('utf-8')); });
|
|
272
|
+
child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); onOutput?.('stderr', chunk.toString('utf-8')); });
|
|
273
|
+
child.once('error', (error) => finish(error));
|
|
274
|
+
child.once('close', (code, signal) => {
|
|
275
|
+
if (pendingTerminationError) {
|
|
276
|
+
if (process.platform !== 'win32' && !unixGroupAlive()) {
|
|
277
|
+
clearTimeout(forceTimer);
|
|
278
|
+
return finish(pendingTerminationError);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (code !== 0) {
|
|
283
|
+
if (allowFailure) return finish(null, { stdout, stderr, code, signal });
|
|
284
|
+
const error = new Error((stderr || stdout || `Agent exited with code ${code}`).trim());
|
|
285
|
+
error.code = 'AGENT_PROCESS_FAILED';
|
|
286
|
+
error.exitCode = code;
|
|
287
|
+
error.signal = signal;
|
|
288
|
+
error.stdout = stdout;
|
|
289
|
+
error.stderr = stderr;
|
|
290
|
+
return finish(error);
|
|
291
|
+
}
|
|
292
|
+
finish(null, { stdout, stderr, code });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
296
|
+
timer = setTimeout(() => {
|
|
297
|
+
const error = new Error(`Agent timed out after ${timeoutMs}ms`);
|
|
298
|
+
error.code = 'AGENT_TIMEOUT';
|
|
299
|
+
terminateTree(error);
|
|
300
|
+
}, timeoutMs);
|
|
301
|
+
timer.unref();
|
|
302
|
+
|
|
303
|
+
child.stdin.on('error', () => {});
|
|
304
|
+
child.stdin.end(input);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function validateSuggestionResponse(value, content, allowedResourceIds = []) {
|
|
309
|
+
const errors = [];
|
|
310
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, errors: ['response must be an object'] };
|
|
311
|
+
if (typeof value.summary !== 'string') errors.push('summary must be a string');
|
|
312
|
+
if (!Array.isArray(value.usedResourceIds) || value.usedResourceIds.some((item) => typeof item !== 'string')) {
|
|
313
|
+
errors.push('usedResourceIds must be an array of strings');
|
|
314
|
+
} else {
|
|
315
|
+
const allowed = new Set(allowedResourceIds);
|
|
316
|
+
if (new Set(value.usedResourceIds).size !== value.usedResourceIds.length) errors.push('usedResourceIds must not contain duplicates');
|
|
317
|
+
value.usedResourceIds.forEach((resourceId, index) => {
|
|
318
|
+
if (!allowed.has(resourceId)) errors.push(`usedResourceIds[${index}] was not provided to the Agent`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (!Array.isArray(value.suggestions)) errors.push('suggestions must be an array');
|
|
322
|
+
else if (value.suggestions.length > 50) errors.push('suggestions must contain at most 50 items');
|
|
323
|
+
else value.suggestions.forEach((suggestion, index) => {
|
|
324
|
+
const path = `suggestions[${index}]`;
|
|
325
|
+
if (!suggestion || typeof suggestion !== 'object' || Array.isArray(suggestion)) return errors.push(`${path} must be an object`);
|
|
326
|
+
const allowed = ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'];
|
|
327
|
+
if (!allowed.includes(suggestion.category)) errors.push(`${path}.category is invalid`);
|
|
328
|
+
for (const field of ['description', 'originalText', 'suggestedText', 'reason']) {
|
|
329
|
+
if (typeof suggestion[field] !== 'string' || !suggestion[field]) errors.push(`${path}.${field} must be a non-empty string`);
|
|
330
|
+
}
|
|
331
|
+
if (typeof suggestion.originalText === 'string' && !content.includes(suggestion.originalText)) {
|
|
332
|
+
errors.push(`${path}.originalText was not found in the submitted document`);
|
|
333
|
+
}
|
|
334
|
+
if (suggestion.originalText === suggestion.suggestedText) errors.push(`${path} does not change the text`);
|
|
335
|
+
});
|
|
336
|
+
return { ok: errors.length === 0, errors };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function parseAgentJson(output) {
|
|
340
|
+
return parseStructuredAgentJson(output, (value) => value && typeof value === 'object'
|
|
341
|
+
&& typeof value.summary === 'string' && Array.isArray(value.suggestions));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function protocolObjects(output) {
|
|
345
|
+
const objects = [];
|
|
346
|
+
for (const text of [String(output || '').trim(), ...String(output || '').split(/\r?\n/)]) {
|
|
347
|
+
try { const value = JSON.parse(text); if (value && typeof value === 'object') objects.push(value); } catch {}
|
|
348
|
+
}
|
|
349
|
+
return objects;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function classifyAgentCliFailure({ provider = '', stdout = '', stderr = '', output = '', exitCode, signal } = {}) {
|
|
353
|
+
const combined = [stdout, stderr, output].filter(Boolean).join('\n');
|
|
354
|
+
for (const event of protocolObjects(combined)) {
|
|
355
|
+
const response = event.response && typeof event.response === 'object' ? event.response : event;
|
|
356
|
+
const reason = response.incomplete_details?.reason || response.incompleteDetails?.reason || response.finish_reason || event.finish_reason || event.stop_reason;
|
|
357
|
+
const failure = response.error || event.error || event.failure || (event.is_error ? event.result || event.subtype : null);
|
|
358
|
+
if (response.status === 'incomplete' || event.status === 'incomplete' || event.type === 'response.incomplete') {
|
|
359
|
+
const classified = classifyAgentDiagnostic(String(reason || failure?.message || failure || 'incomplete output'), { provider });
|
|
360
|
+
const error = classified?.code === 'AGENT_CONTENT_FILTERED' ? classified : new AgentError('The model output ended before the structured result was complete.', 'AGENT_OUTPUT_TRUNCATED', { provider, diagnostic: combined, retryable: true });
|
|
361
|
+
if (exitCode !== undefined) error.exitCode = exitCode;
|
|
362
|
+
if (signal) error.signal = signal;
|
|
363
|
+
return error;
|
|
364
|
+
}
|
|
365
|
+
if (event.type === 'error' || event.type === 'response.failed' || event.type === 'turn.failed' || event.is_error === true || failure) {
|
|
366
|
+
const detail = typeof failure === 'string' ? failure : JSON.stringify(failure || event);
|
|
367
|
+
const error = classifyAgentDiagnostic(detail, { provider }) || new AgentError('The Agent provider reported a protocol failure.', 'AGENT_PROTOCOL_ERROR', { provider, diagnostic: combined });
|
|
368
|
+
if (exitCode !== undefined) error.exitCode = exitCode;
|
|
369
|
+
if (signal) error.signal = signal;
|
|
370
|
+
return error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return classifyAgentDiagnostic(combined, { provider });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function runProviderProcess(provider, command, args, options) {
|
|
377
|
+
try { return await runProcess(command, args, options); }
|
|
378
|
+
catch (error) {
|
|
379
|
+
throw classifyAgentCliFailure({ provider, stdout: error.stdout, stderr: error.stderr, exitCode: error.exitCode, signal: error.signal }) || normalizeAgentError(error, { provider });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parseStructuredAgentJson(output, predicate) {
|
|
384
|
+
const trimmed = output.trim();
|
|
385
|
+
const locate = (value) => {
|
|
386
|
+
if (predicate(value)) return value;
|
|
387
|
+
if (!value || typeof value !== 'object') return null;
|
|
388
|
+
for (const key of ['structured_output', 'structuredOutput', 'result', 'message', 'content']) {
|
|
389
|
+
const nested = value[key];
|
|
390
|
+
if (predicate(nested)) return nested;
|
|
391
|
+
if (typeof nested === 'string') {
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(nested);
|
|
394
|
+
const found = locate(parsed);
|
|
395
|
+
if (found) return found;
|
|
396
|
+
} catch {}
|
|
397
|
+
} else if (nested && typeof nested === 'object') {
|
|
398
|
+
const found = locate(nested);
|
|
399
|
+
if (found) return found;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
const direct = JSON.parse(trimmed);
|
|
406
|
+
const found = locate(direct);
|
|
407
|
+
if (found) return found;
|
|
408
|
+
} catch {}
|
|
409
|
+
|
|
410
|
+
const candidates = [];
|
|
411
|
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
412
|
+
if (fence) candidates.push(fence[1].trim());
|
|
413
|
+
|
|
414
|
+
const eventTexts = [];
|
|
415
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
416
|
+
try {
|
|
417
|
+
const event = JSON.parse(line);
|
|
418
|
+
const found = locate(event);
|
|
419
|
+
if (found) return found;
|
|
420
|
+
if (typeof event?.part?.text === 'string' && ['text', 'message'].includes(event.type)) eventTexts.push(event.part.text);
|
|
421
|
+
if (['message_end', 'turn_end'].includes(event?.type)) {
|
|
422
|
+
const message = event.message;
|
|
423
|
+
if (message?.role === 'assistant' && Array.isArray(message.content)) {
|
|
424
|
+
for (const part of message.content) if (part?.type === 'text' && typeof part.text === 'string') eventTexts.push(part.text);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
if (eventTexts.length) {
|
|
430
|
+
const eventText = eventTexts.join('');
|
|
431
|
+
candidates.push(eventText);
|
|
432
|
+
const eventFence = eventText.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
433
|
+
if (eventFence) candidates.push(eventFence[1].trim());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const firstBrace = trimmed.indexOf('{');
|
|
437
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
438
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
439
|
+
|
|
440
|
+
for (const candidate of candidates) {
|
|
441
|
+
try {
|
|
442
|
+
const parsed = JSON.parse(candidate);
|
|
443
|
+
const found = locate(parsed);
|
|
444
|
+
if (found) return found;
|
|
445
|
+
} catch {}
|
|
446
|
+
}
|
|
447
|
+
const classified = classifyAgentCliFailure({ output: trimmed });
|
|
448
|
+
if (classified) throw classified;
|
|
449
|
+
const kind = incompleteJsonKind(trimmed);
|
|
450
|
+
if (kind === 'empty') throw new AgentError('Agent returned an empty structured response', 'AGENT_EMPTY_RESPONSE');
|
|
451
|
+
if (kind === 'truncated') throw new AgentError('Agent returned a truncated structured response', 'AGENT_OUTPUT_TRUNCATED', { diagnostic: trimmed, retryable: true });
|
|
452
|
+
throw new AgentError('Agent did not return valid JSON', 'AGENT_INVALID_JSON', { diagnostic: trimmed });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function parseReviewAgentJson(output) {
|
|
456
|
+
return parseStructuredAgentJson(output, (value) => value && typeof value === 'object'
|
|
457
|
+
&& typeof value.summary === 'string' && Array.isArray(value.items));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function parsePaperGenerationJson(output) {
|
|
461
|
+
return parseStructuredAgentJson(output, (value) => value && typeof value === 'object'
|
|
462
|
+
&& typeof value.summary === 'string' && typeof value.latex === 'string');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function parseReviewOrchestrationJson(output) {
|
|
466
|
+
return parseStructuredAgentJson(output, (value) => value && typeof value === 'object' && Array.isArray(value.opinions));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function validateReviewResponse(value, content, rubricIds = []) {
|
|
470
|
+
const errors = [];
|
|
471
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, errors: ['response must be an object'] };
|
|
472
|
+
if (typeof value.summary !== 'string' || !value.summary.trim()) errors.push('summary must be a non-empty string');
|
|
473
|
+
if (!['accept', 'minor-revision', 'major-revision', 'reject'].includes(value.verdict)) errors.push('verdict is invalid');
|
|
474
|
+
if (typeof value.confidence !== 'number' || value.confidence < 0 || value.confidence > 1) errors.push('confidence must be between 0 and 1');
|
|
475
|
+
if (!Array.isArray(value.items)) errors.push('items must be an array');
|
|
476
|
+
else if (value.items.length > 30) errors.push('items must contain at most 30 entries');
|
|
477
|
+
else value.items.forEach((item, index) => {
|
|
478
|
+
const path = `items[${index}]`;
|
|
479
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return errors.push(`${path} must be an object`);
|
|
480
|
+
if (!rubricIds.includes(item.rubricId)) errors.push(`${path}.rubricId does not reference the supplied rubric`);
|
|
481
|
+
if (!['concern', 'strength'].includes(item.kind)) errors.push(`${path}.kind is invalid`);
|
|
482
|
+
if (!['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'].includes(item.category)) errors.push(`${path}.category is invalid`);
|
|
483
|
+
if (!['info', 'minor', 'major', 'critical'].includes(item.severity)) errors.push(`${path}.severity is invalid`);
|
|
484
|
+
for (const field of ['body', 'suggestedFix', 'quote']) {
|
|
485
|
+
if (typeof item[field] !== 'string') errors.push(`${path}.${field} must be a string`);
|
|
486
|
+
}
|
|
487
|
+
if (typeof item.body === 'string' && !item.body.trim()) errors.push(`${path}.body must be non-empty`);
|
|
488
|
+
if (typeof item.quote === 'string' && item.quote && !content.includes(item.quote)) errors.push(`${path}.quote was not found in the document`);
|
|
489
|
+
});
|
|
490
|
+
return { ok: errors.length === 0, errors };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function validatePaperGenerationResponse(value, allowedResourceIds = []) {
|
|
494
|
+
const errors = [];
|
|
495
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, errors: ['response must be an object'] };
|
|
496
|
+
if (typeof value.summary !== 'string' || !value.summary.trim()) errors.push('summary must be a non-empty string');
|
|
497
|
+
if (typeof value.latex !== 'string' || !value.latex.trim()) errors.push('latex must be a non-empty string');
|
|
498
|
+
else {
|
|
499
|
+
if (value.latex.length > MAX_INPUT_CHARS * 2) errors.push('latex exceeds 1,000,000 characters');
|
|
500
|
+
if (!/\\documentclass(?:\[[^\]]*\])?\{[^}]+\}/.test(value.latex)) errors.push('latex must contain documentclass');
|
|
501
|
+
if (!/\\begin\{document\}/.test(value.latex) || !/\\end\{document\}/.test(value.latex)) errors.push('latex must contain a complete document environment');
|
|
502
|
+
if (/\\(?:write18|openout|openin|read|immediate)\b/i.test(value.latex)) errors.push('latex contains a prohibited I/O command');
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(value.usedResourceIds) || value.usedResourceIds.some((item) => typeof item !== 'string')) errors.push('usedResourceIds must be an array of strings');
|
|
505
|
+
else {
|
|
506
|
+
const allowed = new Set(allowedResourceIds);
|
|
507
|
+
if (new Set(value.usedResourceIds).size !== value.usedResourceIds.length) errors.push('usedResourceIds must not contain duplicates');
|
|
508
|
+
value.usedResourceIds.forEach((resourceId, index) => { if (!allowed.has(resourceId)) errors.push(`usedResourceIds[${index}] was not provided to the Agent`); });
|
|
509
|
+
}
|
|
510
|
+
return { ok: errors.length === 0, errors };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function validateReviewOrchestrationResponse(value, content) {
|
|
514
|
+
const errors = [];
|
|
515
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, errors: ['response must be an object'] };
|
|
516
|
+
if (typeof value.summary !== 'string') errors.push('summary must be a string');
|
|
517
|
+
if (!Array.isArray(value.opinions) || !value.opinions.length) errors.push('opinions must be a non-empty array');
|
|
518
|
+
else if (value.opinions.length > 100) errors.push('opinions must contain at most 100 entries');
|
|
519
|
+
else value.opinions.forEach((opinion, index) => {
|
|
520
|
+
const path = `opinions[${index}]`;
|
|
521
|
+
if (!opinion || typeof opinion !== 'object' || Array.isArray(opinion)) return errors.push(`${path} must be an object`);
|
|
522
|
+
if (typeof opinion.body !== 'string' || !opinion.body.trim()) errors.push(`${path}.body must be non-empty`);
|
|
523
|
+
if (!['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'].includes(opinion.category)) errors.push(`${path}.category is invalid`);
|
|
524
|
+
if (!['info', 'minor', 'major', 'critical'].includes(opinion.severity)) errors.push(`${path}.severity is invalid`);
|
|
525
|
+
for (const field of ['quote', 'suggestedFix']) if (typeof opinion[field] !== 'string') errors.push(`${path}.${field} must be a string`);
|
|
526
|
+
if (typeof opinion.quote === 'string' && opinion.quote && !content.includes(opinion.quote)) errors.push(`${path}.quote was not found in the manuscript`);
|
|
527
|
+
if (!Array.isArray(opinion.dependsOn) || opinion.dependsOn.some((dependency) => !Number.isInteger(dependency) || dependency < 1 || dependency > value.opinions.length || dependency === index + 1)) errors.push(`${path}.dependsOn contains an invalid opinion number`);
|
|
528
|
+
else if (new Set(opinion.dependsOn).size !== opinion.dependsOn.length) errors.push(`${path}.dependsOn must not contain duplicates`);
|
|
529
|
+
});
|
|
530
|
+
return { ok: errors.length === 0, errors };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const LIBRARY_FILES = [
|
|
534
|
+
{ path: '.papergod/library/corpus.md', description: 'writing library: corpora (search by tag/topic)' },
|
|
535
|
+
{ path: '.papergod/library/patterns.md', description: 'writing library: sentence patterns (with {slot} placeholders)' },
|
|
536
|
+
{ path: '.papergod/library/vocabulary-global.md', description: 'preferred wording (global)' },
|
|
537
|
+
{ path: '.papergod/library/vocabulary-session.md', description: 'preferred wording (this session)' },
|
|
538
|
+
{ path: '.papergod/index.json', description: 'machine-readable catalog of library entries' },
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
// Build a compact workspace index for the agent prompt: absolute root, a flat
|
|
542
|
+
// file listing with purpose annotations, and the target range. The full
|
|
543
|
+
// document and library bodies are intentionally NOT included — the agent reads
|
|
544
|
+
// them from disk on demand.
|
|
545
|
+
export function buildWorkspaceIndex(workspaceRoot, { file = '', start = 0, end = 0 } = {}) {
|
|
546
|
+
let entries = [];
|
|
547
|
+
try { entries = readdirSync(workspaceRoot, { withFileTypes: true }); } catch { entries = []; }
|
|
548
|
+
const texFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.tex')).map((entry) => entry.name);
|
|
549
|
+
const bibFiles = entries.filter((entry) => entry.isFile() && /\.(bib|bibtex)$/i.test(entry.name)).map((entry) => entry.name);
|
|
550
|
+
|
|
551
|
+
const lines = [];
|
|
552
|
+
lines.push(`WORKING DIRECTORY (absolute path): ${workspaceRoot}`);
|
|
553
|
+
lines.push('');
|
|
554
|
+
lines.push('Files in this workspace (read-only; do not modify any file):');
|
|
555
|
+
for (const name of texFiles.sort()) {
|
|
556
|
+
if (name === file) lines.push(` ${name} <-- TARGET document`);
|
|
557
|
+
else lines.push(` ${name}`);
|
|
558
|
+
}
|
|
559
|
+
for (const name of bibFiles.sort()) lines.push(` ${name} (bibliography)`);
|
|
560
|
+
for (const { path, description } of LIBRARY_FILES) lines.push(` ${path} -- ${description}`);
|
|
561
|
+
lines.push('');
|
|
562
|
+
const target = file
|
|
563
|
+
? (Number.isInteger(start) && Number.isInteger(end) && end > start
|
|
564
|
+
? `Read the TARGET document ${file} (JavaScript UTF-16 source-character range [${start}, ${end})), then analyze exactly that range and produce suggestions whose originalText is a contiguous substring of the file.`
|
|
565
|
+
: `Read the TARGET document ${file}, then analyze it and produce suggestions whose originalText is a contiguous substring of the file.`)
|
|
566
|
+
: 'Read the target document before answering.';
|
|
567
|
+
lines.push(target);
|
|
568
|
+
lines.push('When writing-library context is relevant, read the corresponding .papergod/library files first and report the entry ids you actually use in usedResourceIds.');
|
|
569
|
+
return lines.join('\n');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function buildPrompt({ prompt, content, resourceContext = '' }) {
|
|
573
|
+
return `You are an academic writing editor. Analyze only the LaTeX document supplied below.
|
|
574
|
+
Return JSON matching the required schema. Every originalText must be an exact, contiguous substring of the submitted document. For legacy requests use taskId "task_1", nodeId "", empty usedTemplateIds/usedCitekeys, and unresolvedTasks: [] unless the request supplies them. Do not edit files and do not include Markdown fences.
|
|
575
|
+
|
|
576
|
+
User editing instruction:
|
|
577
|
+
${prompt}
|
|
578
|
+
|
|
579
|
+
${resourceContext || 'No writing library resources were provided. Return usedResourceIds as an empty array.'}
|
|
580
|
+
|
|
581
|
+
LaTeX document:
|
|
582
|
+
<document>
|
|
583
|
+
${content}
|
|
584
|
+
</document>`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Index-style prompt: no document body or library text is inlined; the agent
|
|
588
|
+
// reads the workspace on demand. `workspace` = { workspaceRoot, file, start, end }.
|
|
589
|
+
const EXACT_TARGET_SENTINEL = '__PAPERGOD_EXACT_TARGET__';
|
|
590
|
+
|
|
591
|
+
function buildWorkspacePrompt({ prompt, workspace, manifest }) {
|
|
592
|
+
const manifestTransport = manifest?.tasks?.some((task) => task?.target?.matchMode === 'exact')
|
|
593
|
+
? `\nCompact exact-target transport:\nFor every manifest task whose target.matchMode is "exact", do not repeat its potentially long exactQuote in the response. Set originalText to exactly "${EXACT_TARGET_SENTINEL}". Papergod will restore the already-verified immutable exactQuote by taskId before validation. For substring-within-range tasks, return the actual unique source substring as originalText.`
|
|
594
|
+
: '';
|
|
595
|
+
return `You are an academic writing editor. Return JSON matching the required schema. Every originalText must be an exact, contiguous substring of the TARGET file in the workspace, except for the verified exact-target sentinel protocol below. Each suggestion must include its manifest taskId and nodeId plus usedTemplateIds and usedCitekeys; for legacy requests use taskId "task_1", nodeId "", empty provenance arrays, and unresolvedTasks: []. Do not edit files and do not include Markdown fences.${manifestTransport}
|
|
596
|
+
|
|
597
|
+
User editing instruction:
|
|
598
|
+
${prompt}
|
|
599
|
+
|
|
600
|
+
${buildWorkspaceIndex(workspace.workspaceRoot, workspace)}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Chooses between the legacy inline prompt and the workspace-index prompt.
|
|
604
|
+
export function buildSuggestionPrompt(request, options) {
|
|
605
|
+
if (request.workspace && options.workspaceRoot) {
|
|
606
|
+
return buildWorkspacePrompt({
|
|
607
|
+
prompt: request.prompt,
|
|
608
|
+
workspace: { workspaceRoot: options.workspaceRoot, file: request.workspace.file || '', start: request.workspace.start ?? 0, end: request.workspace.end ?? 0 },
|
|
609
|
+
manifest: request.manifest,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
return buildPrompt(request);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function buildSuggestionPayload(provider, request, options) {
|
|
616
|
+
const prompt = buildSuggestionPrompt(request, options);
|
|
617
|
+
const payload = ['opencode', 'pi'].includes(provider) ? withOutputSchema(prompt, SUGGESTION_OUTPUT_SCHEMA) : prompt;
|
|
618
|
+
if (payload.length > MAX_INPUT_CHARS) {
|
|
619
|
+
const error = new AgentError('The final Agent payload exceeds 500,000 characters.', 'AGENT_INPUT_TOO_LARGE', { provider, characters: payload.length, limit: MAX_INPUT_CHARS });
|
|
620
|
+
error.status = 413;
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
return payload;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Returns the workspace index text when the request targets a workspace file,
|
|
627
|
+
// or null to fall back to inline mode.
|
|
628
|
+
function workspaceIndexMaybe(request, options) {
|
|
629
|
+
if (request.workspace && options?.workspaceRoot) {
|
|
630
|
+
return buildWorkspaceIndex(options.workspaceRoot, {
|
|
631
|
+
file: request.workspace.file || '',
|
|
632
|
+
start: request.workspace.start ?? 0,
|
|
633
|
+
end: request.workspace.end ?? 0,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function buildReviewPrompt(request, options) {
|
|
640
|
+
const reviewer = request.reviewer;
|
|
641
|
+
const rubric = request.rubric || [];
|
|
642
|
+
const profile = `Reviewer profile:
|
|
643
|
+
Name: ${reviewer.name}
|
|
644
|
+
Role: ${reviewer.role}
|
|
645
|
+
Focus: ${reviewer.focus}
|
|
646
|
+
Additional instruction: ${reviewer.prompt || 'None'}
|
|
647
|
+
|
|
648
|
+
Review rubric:
|
|
649
|
+
${rubric.map((item) => `- ${item.id}: ${item.title} — ${item.instruction} (weight ${item.weight})`).join('\n')}`;
|
|
650
|
+
const index = workspaceIndexMaybe(request, options);
|
|
651
|
+
if (index) {
|
|
652
|
+
return `You are an independent academic peer reviewer. Read the TARGET document in the workspace and review it from your assigned perspective. Return only JSON matching the required schema. A quote must be an exact contiguous substring of the manuscript or an empty string. Keep each item atomic and assign it to one supplied rubricId. Do not edit files.
|
|
653
|
+
|
|
654
|
+
${profile}
|
|
655
|
+
|
|
656
|
+
${index}`;
|
|
657
|
+
}
|
|
658
|
+
return `You are an independent academic peer reviewer. Review only the supplied LaTeX manuscript from your assigned perspective. Do not edit files. Return only JSON matching the required schema. A quote must be an exact contiguous substring of the manuscript or an empty string. Keep each item atomic and assign it to one supplied rubricId.
|
|
659
|
+
|
|
660
|
+
${profile}
|
|
661
|
+
|
|
662
|
+
LaTeX manuscript:
|
|
663
|
+
<document>
|
|
664
|
+
${request.content}
|
|
665
|
+
</document>`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function buildPaperGenerationPrompt({ instruction, projectContext, outlineContext, resourceContext }) {
|
|
669
|
+
return `You are drafting a complete academic paper as LaTeX. Return only JSON matching the required schema. The latex field must be a self-contained compilable document. Treat project and outline prompts as writing requirements, not as LaTeX source. Do not use shell escape, file I/O commands, Markdown fences, or invented resource IDs.
|
|
670
|
+
|
|
671
|
+
User generation instruction:
|
|
672
|
+
${instruction}
|
|
673
|
+
|
|
674
|
+
Project writing context:
|
|
675
|
+
${projectContext || 'No project-level context.'}
|
|
676
|
+
|
|
677
|
+
Required outline and per-element prompts:
|
|
678
|
+
${outlineContext || 'Use a conventional abstract, introduction, methods, results, discussion, and conclusion structure.'}
|
|
679
|
+
|
|
680
|
+
${resourceContext || 'No writing library resources were provided. Return usedResourceIds as an empty array.'}`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function buildReviewOrchestrationPrompt(request, options) {
|
|
684
|
+
const feedback = `Reviewer feedback:
|
|
685
|
+
<feedback>
|
|
686
|
+
${request.feedback}
|
|
687
|
+
</feedback>
|
|
688
|
+
|
|
689
|
+
Manuscript outline:
|
|
690
|
+
${request.outlineContext || 'No outline metadata.'}`;
|
|
691
|
+
const index = workspaceIndexMaybe(request, options);
|
|
692
|
+
if (index) {
|
|
693
|
+
return `You are an academic revision orchestrator. Convert the supplied reviewer feedback into atomic, non-duplicated opinions. Read the TARGET document in the workspace. Return only JSON matching the required schema. For each opinion, quote an exact contiguous manuscript substring when it targets specific text; otherwise use an empty quote for a document-level task. suggestedFix may be empty when author judgment or new evidence is required. dependsOn contains one-based opinion numbers that must be completed first. Do not edit files.
|
|
694
|
+
|
|
695
|
+
${feedback}
|
|
696
|
+
|
|
697
|
+
${index}`;
|
|
698
|
+
}
|
|
699
|
+
return `You are an academic revision orchestrator. Convert the supplied reviewer feedback into atomic, non-duplicated opinions. Return only JSON matching the required schema. For each opinion, quote an exact contiguous manuscript substring when it targets specific text; otherwise use an empty quote for a document-level task. suggestedFix may be empty when author judgment or new evidence is required. dependsOn contains one-based opinion numbers that must be completed first. Do not edit files.
|
|
700
|
+
|
|
701
|
+
${feedback}
|
|
702
|
+
|
|
703
|
+
LaTeX manuscript:
|
|
704
|
+
<document>
|
|
705
|
+
${request.content}
|
|
706
|
+
</document>`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function withOutputSchema(prompt, schema) {
|
|
710
|
+
return `${prompt}\n\nRequired JSON Schema:\n${JSON.stringify(schema)}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function validateProviderLifecycle(provider, output) {
|
|
714
|
+
const events = protocolObjects(output).filter((event) => typeof event.type === 'string');
|
|
715
|
+
if (!events.length) return;
|
|
716
|
+
let terminal = true;
|
|
717
|
+
if (provider === 'pi') terminal = events.some((event) => ['message_end', 'agent_end'].includes(event.type));
|
|
718
|
+
else if (provider === 'claude-code') terminal = events.some((event) => event.type === 'result');
|
|
719
|
+
else if (provider === 'opencode') terminal = events.some((event) => event.type === 'step_finish' || (['message', 'text'].includes(event.type) && typeof event.part?.text === 'string'));
|
|
720
|
+
if (!terminal) throw new AgentError('The Agent event stream closed before a terminal result event.', 'AGENT_OUTPUT_TRUNCATED', { provider, diagnostic: output, retryable: true });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function parseProviderOutput(provider, parser, output, stderr = '') {
|
|
724
|
+
const failure = classifyAgentCliFailure({ provider, output, stderr });
|
|
725
|
+
if (failure) throw failure;
|
|
726
|
+
validateProviderLifecycle(provider, output);
|
|
727
|
+
try {
|
|
728
|
+
return parser(output);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const normalized = normalizeAgentError(error, { provider });
|
|
731
|
+
normalized.diagnostic = redactAgentDiagnostic([stderr, output].filter(Boolean).join('\n'));
|
|
732
|
+
throw normalized;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function restoreManifestExactTargets(response, manifest) {
|
|
737
|
+
if (!response || !Array.isArray(response.suggestions) || !Array.isArray(manifest?.tasks)) return response;
|
|
738
|
+
const exactTargets = new Map(manifest.tasks
|
|
739
|
+
.filter((task) => task?.target?.matchMode === 'exact' && typeof task.target.exactQuote === 'string' && task.target.exactQuote)
|
|
740
|
+
.map((task) => [task.taskId, task.target.exactQuote]));
|
|
741
|
+
for (const suggestion of response.suggestions) {
|
|
742
|
+
const exactQuote = exactTargets.get(suggestion?.taskId);
|
|
743
|
+
if (exactQuote) suggestion.originalText = exactQuote;
|
|
744
|
+
}
|
|
745
|
+
return response;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function readCodexOutput(outputFile, result, parser, manifest = null) {
|
|
749
|
+
let output = '';
|
|
750
|
+
try { output = await readFile(outputFile, 'utf-8'); } catch (error) {
|
|
751
|
+
const failure = classifyAgentCliFailure({ provider: 'codex', stdout: result.stdout, stderr: result.stderr });
|
|
752
|
+
if (failure) throw failure;
|
|
753
|
+
throw new AgentError('Codex did not create its structured output file.', 'AGENT_PROTOCOL_ERROR', { provider: 'codex', cause: error, diagnostic: [result.stdout, result.stderr].join('\n') });
|
|
754
|
+
}
|
|
755
|
+
const response = parseProviderOutput('codex', parser, output, [result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
756
|
+
return restoreManifestExactTargets(response, manifest);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function runClaudeStructured(prompt, schema, parser, options) {
|
|
760
|
+
const spec = commandSpec('claude-code', options.commands);
|
|
761
|
+
// Workspace mode grants read-only tools (Read/Grep/Glob) so the agent can
|
|
762
|
+
// read the paper and library files; inline mode keeps tools empty.
|
|
763
|
+
const toolFlag = options.readFromWorkspace ? ['--tools', 'Read,Grep,Glob'] : ['--tools', ''];
|
|
764
|
+
const args = [
|
|
765
|
+
...spec.prefixArgs,
|
|
766
|
+
'--print',
|
|
767
|
+
'--output-format', 'json',
|
|
768
|
+
'--json-schema', JSON.stringify(schema),
|
|
769
|
+
'--permission-mode', 'plan',
|
|
770
|
+
...toolFlag,
|
|
771
|
+
'--no-session-persistence',
|
|
772
|
+
...modelArgs(spec),
|
|
773
|
+
...reasoningArgs('claude-code', options.reasoningEffort || spec.reasoningEffort),
|
|
774
|
+
];
|
|
775
|
+
const result = await runProviderProcess('claude-code', spec.command, args, {
|
|
776
|
+
cwd: options.workspaceRoot,
|
|
777
|
+
input: prompt,
|
|
778
|
+
timeoutMs: options.timeoutMs,
|
|
779
|
+
signal: options.signal,
|
|
780
|
+
onOutput: options.onOutput,
|
|
781
|
+
});
|
|
782
|
+
return parseProviderOutput('claude-code', parser, result.stdout, result.stderr);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function runClaude(request, options) {
|
|
786
|
+
return runClaudeStructured(buildSuggestionPayload('claude-code', request, options), SUGGESTION_OUTPUT_SCHEMA, parseAgentJson, options);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function runClaudeReview(request, options) {
|
|
790
|
+
return runClaudeStructured(buildReviewPrompt(request, options), REVIEW_OUTPUT_SCHEMA, parseReviewAgentJson, options);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function runClaudePaperGeneration(request, options) {
|
|
794
|
+
return runClaudeStructured(buildPaperGenerationPrompt(request), PAPER_GENERATION_OUTPUT_SCHEMA, parsePaperGenerationJson, options);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function runClaudeReviewOrchestration(request, options) {
|
|
798
|
+
return runClaudeStructured(buildReviewOrchestrationPrompt(request, options), REVIEW_ORCHESTRATION_OUTPUT_SCHEMA, parseReviewOrchestrationJson, options);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function runCodex(request, options) {
|
|
802
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-codex-'));
|
|
803
|
+
try {
|
|
804
|
+
const schemaFile = join(temporary, 'response-schema.json');
|
|
805
|
+
const outputFile = join(temporary, 'last-message.json');
|
|
806
|
+
await writeFile(schemaFile, JSON.stringify(SUGGESTION_OUTPUT_SCHEMA), 'utf-8');
|
|
807
|
+
const spec = commandSpec('codex', options.commands);
|
|
808
|
+
const effectiveEffort = options.liveTest ? 'low' : options.reasoningEffort || spec.reasoningEffort;
|
|
809
|
+
const args = [...spec.prefixArgs, 'exec', ...modelArgs(spec), ...reasoningArgs('codex', effectiveEffort), '--sandbox', 'read-only', '--skip-git-repo-check', '--ephemeral', '--color', 'never', '--output-schema', schemaFile, '--output-last-message', outputFile, '-'];
|
|
810
|
+
const result = await runProviderProcess('codex', spec.command, args, { cwd: options.workspaceRoot, input: buildSuggestionPayload('codex', request, options), timeoutMs: options.timeoutMs, signal: options.signal, onOutput: options.onOutput });
|
|
811
|
+
return await readCodexOutput(outputFile, result, parseAgentJson, request.manifest);
|
|
812
|
+
} finally {
|
|
813
|
+
await rm(temporary, { recursive: true, force: true });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function runOpenCodeStructured(prompt, parser, options, instruction) {
|
|
818
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-opencode-'));
|
|
819
|
+
try {
|
|
820
|
+
const requestFile = join(temporary, 'request.txt');
|
|
821
|
+
const configFile = join(temporary, 'opencode.json');
|
|
822
|
+
// Workspace mode: run against the workspace directory (so the agent can
|
|
823
|
+
// read the paper/library) with read-only permissions; inline mode keeps a
|
|
824
|
+
// deny-only throwaway directory.
|
|
825
|
+
const runDir = options.readFromWorkspace ? options.workspaceRoot : temporary;
|
|
826
|
+
const permission = options.readFromWorkspace
|
|
827
|
+
? { '*': 'deny', read: 'allow', glob: 'allow', grep: 'allow', list: 'allow' }
|
|
828
|
+
: 'deny';
|
|
829
|
+
await Promise.all([
|
|
830
|
+
writeFile(requestFile, prompt, 'utf-8'),
|
|
831
|
+
writeFile(configFile, JSON.stringify({ permission }), 'utf-8'),
|
|
832
|
+
]);
|
|
833
|
+
const spec = commandSpec('opencode', options.commands);
|
|
834
|
+
const args = [...spec.prefixArgs, 'run', instruction, ...modelArgs(spec), ...reasoningArgs('opencode', options.reasoningEffort || spec.reasoningEffort), '--pure', '--format', 'json', '--dir', runDir, '--file', requestFile];
|
|
835
|
+
const result = await runProviderProcess('opencode', spec.command, args, { cwd: runDir, timeoutMs: options.timeoutMs, signal: options.signal, onOutput: options.onOutput, envOverrides: { OPENCODE_CONFIG: configFile } });
|
|
836
|
+
return parseProviderOutput('opencode', parser, result.stdout, result.stderr);
|
|
837
|
+
} finally {
|
|
838
|
+
await rm(temporary, { recursive: true, force: true });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function runOpenCode(request, options) {
|
|
843
|
+
return runOpenCodeStructured(buildSuggestionPayload('opencode', request, options), parseAgentJson, options, 'Follow the attached academic editing request and return only the required JSON.');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function runCodexReview(request, options) {
|
|
847
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-review-codex-'));
|
|
848
|
+
try {
|
|
849
|
+
const schemaFile = join(temporary, 'review-schema.json');
|
|
850
|
+
const outputFile = join(temporary, 'last-message.json');
|
|
851
|
+
await writeFile(schemaFile, JSON.stringify(REVIEW_OUTPUT_SCHEMA), 'utf-8');
|
|
852
|
+
const spec = commandSpec('codex', options.commands);
|
|
853
|
+
const args = [...spec.prefixArgs, 'exec', ...modelArgs(spec), ...reasoningArgs('codex', options.reasoningEffort || spec.reasoningEffort), '--sandbox', 'read-only', '--skip-git-repo-check', '--ephemeral', '--color', 'never', '--output-schema', schemaFile, '--output-last-message', outputFile, '-'];
|
|
854
|
+
const result = await runProviderProcess('codex', spec.command, args, { cwd: options.workspaceRoot, input: buildReviewPrompt(request, options), timeoutMs: options.timeoutMs, signal: options.signal });
|
|
855
|
+
return await readCodexOutput(outputFile, result, parseReviewAgentJson);
|
|
856
|
+
} finally {
|
|
857
|
+
await rm(temporary, { recursive: true, force: true });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function runOpenCodeReview(request, options) {
|
|
862
|
+
return runOpenCodeStructured(withOutputSchema(buildReviewPrompt(request, options), REVIEW_OUTPUT_SCHEMA), parseReviewAgentJson, options, 'Perform the independent peer review and return only the required JSON.');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function runCodexPaperGeneration(request, options) {
|
|
866
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-generate-codex-'));
|
|
867
|
+
try {
|
|
868
|
+
const schemaFile = join(temporary, 'paper-schema.json');
|
|
869
|
+
const outputFile = join(temporary, 'last-message.json');
|
|
870
|
+
await writeFile(schemaFile, JSON.stringify(PAPER_GENERATION_OUTPUT_SCHEMA), 'utf-8');
|
|
871
|
+
const spec = commandSpec('codex', options.commands);
|
|
872
|
+
const args = [...spec.prefixArgs, 'exec', ...modelArgs(spec), ...reasoningArgs('codex', options.reasoningEffort || spec.reasoningEffort), '--sandbox', 'read-only', '--skip-git-repo-check', '--ephemeral', '--color', 'never', '--output-schema', schemaFile, '--output-last-message', outputFile, '-'];
|
|
873
|
+
const result = await runProviderProcess('codex', spec.command, args, { cwd: options.workspaceRoot, input: buildPaperGenerationPrompt(request), timeoutMs: options.timeoutMs, signal: options.signal });
|
|
874
|
+
return await readCodexOutput(outputFile, result, parsePaperGenerationJson);
|
|
875
|
+
} finally {
|
|
876
|
+
await rm(temporary, { recursive: true, force: true });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function runOpenCodePaperGeneration(request, options) {
|
|
881
|
+
return runOpenCodeStructured(withOutputSchema(buildPaperGenerationPrompt(request), PAPER_GENERATION_OUTPUT_SCHEMA), parsePaperGenerationJson, options, 'Generate the complete LaTeX draft and return only the required JSON.');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function runCodexReviewOrchestration(request, options) {
|
|
885
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-orchestrate-codex-'));
|
|
886
|
+
try {
|
|
887
|
+
const schemaFile = join(temporary, 'orchestration-schema.json');
|
|
888
|
+
const outputFile = join(temporary, 'last-message.json');
|
|
889
|
+
await writeFile(schemaFile, JSON.stringify(REVIEW_ORCHESTRATION_OUTPUT_SCHEMA), 'utf-8');
|
|
890
|
+
const spec = commandSpec('codex', options.commands);
|
|
891
|
+
const args = [...spec.prefixArgs, 'exec', ...modelArgs(spec), ...reasoningArgs('codex', options.reasoningEffort || spec.reasoningEffort), '--sandbox', 'read-only', '--skip-git-repo-check', '--ephemeral', '--color', 'never', '--output-schema', schemaFile, '--output-last-message', outputFile, '-'];
|
|
892
|
+
const result = await runProviderProcess('codex', spec.command, args, { cwd: options.workspaceRoot, input: buildReviewOrchestrationPrompt(request, options), timeoutMs: options.timeoutMs, signal: options.signal });
|
|
893
|
+
return await readCodexOutput(outputFile, result, parseReviewOrchestrationJson);
|
|
894
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function runOpenCodeReviewOrchestration(request, options) {
|
|
898
|
+
return runOpenCodeStructured(withOutputSchema(buildReviewOrchestrationPrompt(request, options), REVIEW_ORCHESTRATION_OUTPUT_SCHEMA), parseReviewOrchestrationJson, options, 'Orchestrate the review feedback and return only the required JSON.');
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function runPiStructured(prompt, parser, options) {
|
|
902
|
+
const temporary = await mkdtemp(join(tmpdir(), 'papergod-pi-'));
|
|
903
|
+
try {
|
|
904
|
+
const requestFile = join(temporary, 'request.txt');
|
|
905
|
+
await writeFile(requestFile, prompt, 'utf-8');
|
|
906
|
+
const spec = commandSpec('pi', options.commands);
|
|
907
|
+
// Workspace mode: allow Pi's read tool so it can read the paper/library
|
|
908
|
+
// files itself; inline mode keeps all tools disabled (analysis-only).
|
|
909
|
+
const toolFlag = options.readFromWorkspace ? ['--tools', 'read'] : ['--no-tools'];
|
|
910
|
+
const args = [
|
|
911
|
+
...spec.prefixArgs,
|
|
912
|
+
'--print', '--mode', 'json', '--no-session', ...toolFlag, '--no-context-files',
|
|
913
|
+
'--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', '--no-approve',
|
|
914
|
+
...modelArgs(spec), ...reasoningArgs('pi', options.reasoningEffort || spec.reasoningEffort), `@${requestFile}`,
|
|
915
|
+
'Follow the attached academic writing request. Return only the required JSON.',
|
|
916
|
+
];
|
|
917
|
+
const result = await runProviderProcess('pi', spec.command, args, {
|
|
918
|
+
cwd: options.workspaceRoot, timeoutMs: options.timeoutMs, signal: options.signal, onOutput: options.onOutput,
|
|
919
|
+
});
|
|
920
|
+
return parseProviderOutput('pi', parser, result.stdout, result.stderr);
|
|
921
|
+
} finally {
|
|
922
|
+
await rm(temporary, { recursive: true, force: true });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function parsePiProvider(output) {
|
|
927
|
+
const clean = String(output || '').replace(/\x1b\[[0-9;]*m/g, '').replace(/[│├└┌─┐┘]/g, '');
|
|
928
|
+
for (const rawLine of clean.split(/\r?\n/)) {
|
|
929
|
+
const line = rawLine.trim();
|
|
930
|
+
if (!line || /^provider\s/i.test(line)) continue;
|
|
931
|
+
const match = line.match(/^(\S+)\s+/);
|
|
932
|
+
if (match) return match[1];
|
|
933
|
+
}
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function parsePiModelTable(output) {
|
|
938
|
+
const clean = String(output || '').replace(/\x1b\[[0-9;]*m/g, '').replace(/[│├└┌─┐┘]/g, '');
|
|
939
|
+
const models = [];
|
|
940
|
+
for (const rawLine of clean.split(/\r?\n/)) {
|
|
941
|
+
const line = rawLine.trim();
|
|
942
|
+
if (!line || /^provider\s/i.test(line) || /^-+/.test(line)) continue;
|
|
943
|
+
const columns = line.split(/\s{2,}/).map((value) => value.trim()).filter(Boolean);
|
|
944
|
+
const provider = columns[0];
|
|
945
|
+
const model = columns[1];
|
|
946
|
+
if (!provider || !model || provider === 'provider') continue;
|
|
947
|
+
models.push({
|
|
948
|
+
id: `${provider}/${model}`,
|
|
949
|
+
label: `${provider} · ${model}`,
|
|
950
|
+
provider,
|
|
951
|
+
model,
|
|
952
|
+
context: columns[2] || '',
|
|
953
|
+
thinking: columns[4] || '',
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
return models;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function codexConfiguredModel() {
|
|
960
|
+
const configFile = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
961
|
+
try {
|
|
962
|
+
const content = readFileSync(configFile, 'utf-8');
|
|
963
|
+
const match = content.match(/^\s*model\s*=\s*"([^"]+)"/m);
|
|
964
|
+
return match?.[1] || '';
|
|
965
|
+
} catch {
|
|
966
|
+
return '';
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Best-effort model list per provider. Never throws; failures return an empty
|
|
971
|
+
// list so the UI falls back to a free-text model field.
|
|
972
|
+
export async function listProviderModels(provider, spec, { commands = {}, detailed = false, piModelsResult = null } = {}) {
|
|
973
|
+
const discoveredAt = new Date().toISOString();
|
|
974
|
+
const success = (models, source, warnings = []) => detailed ? { models, source, discoveredAt, stale: false, warnings } : models;
|
|
975
|
+
const failure = (source, error) => detailed ? { models: [], source, discoveredAt, stale: true, warnings: [redactAgentDiagnostic(error?.message || error || 'Model discovery failed', 800)] } : [];
|
|
976
|
+
try {
|
|
977
|
+
if (provider === 'pi') {
|
|
978
|
+
const result = piModelsResult || await runProcess(spec.command, [...spec.prefixArgs, '--list-models'], { timeoutMs: 10_000, allowFailure: true });
|
|
979
|
+
if (result.code !== 0) return failure('pi-cli', result.stderr || `exit ${result.code}`);
|
|
980
|
+
return success(parsePiModelTable(result.stdout), 'pi-cli');
|
|
981
|
+
}
|
|
982
|
+
if (provider === 'codex') {
|
|
983
|
+
const configured = codexConfiguredModel();
|
|
984
|
+
const models = configured ? [{ id: configured, label: `${configured} (configured)`, provider: 'codex', model: configured, context: '', thinking: '' }] : [];
|
|
985
|
+
return success(models, 'codex-config', configured ? [] : ['No model override found; the Codex CLI default will be used.']);
|
|
986
|
+
}
|
|
987
|
+
if (provider === 'opencode') {
|
|
988
|
+
const result = await runProcess(spec.command, [...spec.prefixArgs, 'models'], { timeoutMs: 10_000, allowFailure: true });
|
|
989
|
+
if (result.code !== 0) return failure('opencode-cli', result.stderr || `exit ${result.code}`);
|
|
990
|
+
const clean = String(result.stdout).replace(/\x1b\[[0-9;]*m/g, '');
|
|
991
|
+
const models = [];
|
|
992
|
+
for (const rawLine of clean.split(/\r?\n/)) {
|
|
993
|
+
const line = rawLine.trim();
|
|
994
|
+
if (!line || /^(id|name)\s/i.test(line) || /^-+/.test(line)) continue;
|
|
995
|
+
const columns = line.split(/\s{2,}/).map((value) => value.trim()).filter(Boolean);
|
|
996
|
+
const id = columns[0];
|
|
997
|
+
if (id && !/^(Model|ID|Name)$/i.test(id)) models.push({ id, label: columns[1] ? `${id} · ${columns[1]}` : id, provider: 'opencode', model: id, context: '', thinking: '' });
|
|
998
|
+
}
|
|
999
|
+
return success(models, 'opencode-cli');
|
|
1000
|
+
}
|
|
1001
|
+
return success([], 'unsupported', ['This CLI does not expose a model-list command used by Papergod.']);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
return failure(`${provider}-cli`, error);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function runPi(request, options) {
|
|
1008
|
+
return runPiStructured(buildSuggestionPayload('pi', request, options), parseAgentJson, options);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function runPiReview(request, options) {
|
|
1012
|
+
return runPiStructured(withOutputSchema(buildReviewPrompt(request, options), REVIEW_OUTPUT_SCHEMA), parseReviewAgentJson, options);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function runPiPaperGeneration(request, options) {
|
|
1016
|
+
return runPiStructured(withOutputSchema(buildPaperGenerationPrompt(request), PAPER_GENERATION_OUTPUT_SCHEMA), parsePaperGenerationJson, options);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function runPiReviewOrchestration(request, options) {
|
|
1020
|
+
return runPiStructured(withOutputSchema(buildReviewOrchestrationPrompt(request, options), REVIEW_ORCHESTRATION_OUTPUT_SCHEMA), parseReviewOrchestrationJson, options);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export async function detectAgentProviders({ commands = {}, providers = AGENT_PROVIDERS } = {}) {
|
|
1024
|
+
const detectProvider = async (provider) => {
|
|
1025
|
+
const spec = commandSpec(provider, commands);
|
|
1026
|
+
const helpArgs = provider === 'codex' ? ['exec', '--help'] : provider === 'opencode' ? ['run', '--help'] : ['--help'];
|
|
1027
|
+
try {
|
|
1028
|
+
// Version, auth check, help text, and (for non-pi providers) the model
|
|
1029
|
+
// catalog are independent CLI invocations; running them in parallel
|
|
1030
|
+
// instead of serially cuts per-provider detection latency substantially
|
|
1031
|
+
// (each spawn is 0.5-2.5s, and the model listing is often the slowest
|
|
1032
|
+
// call). Pi reuses its auth-step `--list-models` output instead of
|
|
1033
|
+
// fetching the catalog separately, so it is excluded from this batch.
|
|
1034
|
+
const catalogFallback = { models: [], source: `${provider}-cli`, discoveredAt: new Date().toISOString(), stale: true, warnings: ['Model discovery failed'] };
|
|
1035
|
+
const [version, authResult, helpResult, prefetchedCatalog] = await Promise.all([
|
|
1036
|
+
runProcess(spec.command, [...spec.prefixArgs, '--version'], { timeoutMs: 5000 }),
|
|
1037
|
+
(provider === 'codex' ? runProcess(spec.command, [...spec.prefixArgs, 'login', 'status'], { timeoutMs: 5000, allowFailure: true })
|
|
1038
|
+
: provider === 'claude-code' ? runProcess(spec.command, [...spec.prefixArgs, 'auth', 'status'], { timeoutMs: 5000, allowFailure: true })
|
|
1039
|
+
: provider === 'opencode' ? runProcess(spec.command, [...spec.prefixArgs, 'auth', 'list'], { timeoutMs: 5000, allowFailure: true })
|
|
1040
|
+
: runProcess(spec.command, [...spec.prefixArgs, '--list-models'], { timeoutMs: 10_000, allowFailure: true })).catch(() => null),
|
|
1041
|
+
runProcess(spec.command, [...spec.prefixArgs, ...helpArgs], { timeoutMs: 5000, allowFailure: true }).catch(() => null),
|
|
1042
|
+
provider === 'pi' ? Promise.resolve(null) : listProviderModels(provider, spec, { commands, detailed: true }).catch(() => catalogFallback),
|
|
1043
|
+
]);
|
|
1044
|
+
let authenticated = false;
|
|
1045
|
+
let authStatus = 'Authentication not confirmed';
|
|
1046
|
+
let piModelsResult = null;
|
|
1047
|
+
try {
|
|
1048
|
+
if (provider === 'codex') {
|
|
1049
|
+
authenticated = authResult?.code === 0;
|
|
1050
|
+
authStatus = authenticated ? 'Signed in' : 'Sign-in required';
|
|
1051
|
+
} else if (provider === 'claude-code') {
|
|
1052
|
+
const parsed = JSON.parse((authResult.stdout || authResult.stderr).trim());
|
|
1053
|
+
authenticated = parsed.loggedIn === true;
|
|
1054
|
+
authStatus = authenticated ? `Signed in${parsed.authMethod ? ` · ${parsed.authMethod}` : ''}` : 'Sign-in required';
|
|
1055
|
+
} else if (provider === 'opencode') {
|
|
1056
|
+
const clean = authResult.stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1057
|
+
const credentialCount = (clean.match(/●/g) || []).length;
|
|
1058
|
+
authenticated = credentialCount > 0;
|
|
1059
|
+
authStatus = authenticated ? `${credentialCount} credential source${credentialCount === 1 ? '' : 's'} detected` : 'Provider login required';
|
|
1060
|
+
} else {
|
|
1061
|
+
piModelsResult = authResult;
|
|
1062
|
+
authenticated = false;
|
|
1063
|
+
authStatus = piModelsResult?.code === 0 ? 'Installed · run live test to verify credentials' : 'Installed · configure credentials in Pi';
|
|
1064
|
+
if (piModelsResult?.code === 0) {
|
|
1065
|
+
// Pi: confirm provider readiness with `pi auth check --provider <name> --json`.
|
|
1066
|
+
const providerName = parsePiProvider(piModelsResult.stdout) || 'opencode-go';
|
|
1067
|
+
const check = await runProcess(spec.command, [...spec.prefixArgs, 'auth', 'check', '--provider', providerName, '--json'], { timeoutMs: 10_000, allowFailure: true });
|
|
1068
|
+
if (check.code === 0) {
|
|
1069
|
+
try {
|
|
1070
|
+
const parsed = JSON.parse(check.stdout.trim());
|
|
1071
|
+
authenticated = parsed.status === 'ready';
|
|
1072
|
+
authStatus = authenticated ? `Ready · ${parsed.authType || 'authenticated'} (${providerName})` : `Sign-in required (${providerName})`;
|
|
1073
|
+
} catch {
|
|
1074
|
+
authStatus = `Installed · ${providerName}`;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
} catch {}
|
|
1080
|
+
const versionText = (version.stdout || version.stderr).trim();
|
|
1081
|
+
const helpText = helpResult ? `${helpResult.stdout}\n${helpResult.stderr}` : '';
|
|
1082
|
+
const inspection = inspectCliCapabilities(provider, versionText, helpText);
|
|
1083
|
+
const catalog = provider === 'pi' ? await listProviderModels(provider, spec, { commands, detailed: true, piModelsResult }) : (prefetchedCatalog || catalogFallback);
|
|
1084
|
+
const health = agentHealthStatus(provider, spec.model);
|
|
1085
|
+
return { provider, available: true, authenticated, authStatus, version: versionText, compatible: inspection.compatible, capabilities: inspection.capabilities, warnings: [...inspection.warnings, ...catalog.warnings], models: catalog.models, modelCatalog: catalog, health };
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
return { provider, available: false, authenticated: false, authStatus: 'CLI unavailable', version: null, models: [], error: error.code === 'ENOENT' ? 'Not installed' : redactAgentDiagnostic(error.message, 800) };
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
const externalProviders = providers.filter((item) => item !== 'mock' && AGENT_PROVIDERS.includes(item));
|
|
1091
|
+
const result = await Promise.all(externalProviders.map(detectProvider));
|
|
1092
|
+
return [
|
|
1093
|
+
...(providers.includes('mock') ? [{ provider: 'mock', available: true, authenticated: true, authStatus: 'Built in', version: 'built-in' }] : []),
|
|
1094
|
+
...result,
|
|
1095
|
+
];
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const RETRYABLE_AGENT_CODES = new Set(['AGENT_RATE_LIMITED', 'AGENT_TRANSPORT_ERROR', 'AGENT_TIMEOUT', 'AGENT_OUTPUT_TRUNCATED', 'AGENT_EMPTY_RESPONSE']);
|
|
1099
|
+
const COOLDOWN_AGENT_CODES = new Set(['AGENT_RATE_LIMITED', 'AGENT_TRANSPORT_ERROR', 'AGENT_TIMEOUT', 'AGENT_EMPTY_RESPONSE', 'AGENT_MODEL_NOT_FOUND']);
|
|
1100
|
+
|
|
1101
|
+
function abortableDelay(ms, signal) {
|
|
1102
|
+
return new Promise((resolve, reject) => {
|
|
1103
|
+
if (signal?.aborted) return reject(new AgentError('Agent run cancelled', 'AGENT_CANCELLED'));
|
|
1104
|
+
const timer = setTimeout(resolve, ms);
|
|
1105
|
+
signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new AgentError('Agent run cancelled', 'AGENT_CANCELLED')); }, { once: true });
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function runWithRetry(provider, operation, execute, options) {
|
|
1110
|
+
const attempts = [];
|
|
1111
|
+
const started = Date.now();
|
|
1112
|
+
const deadline = started + (options.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
1113
|
+
const maxAttempts = Math.max(1, Math.min(3, Number(options.maxAttempts) || 2));
|
|
1114
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
1115
|
+
const spec = commandSpec(provider, options.commands || {});
|
|
1116
|
+
const model = options.model || spec.model;
|
|
1117
|
+
const healthIdentity = model || `${spec.command} ${spec.prefixArgs.join(' ')}`;
|
|
1118
|
+
const memberHealth = agentHealthStatus(provider, healthIdentity);
|
|
1119
|
+
if (!memberHealth.available && options.ignoreCooldown !== true) {
|
|
1120
|
+
throw new AgentError(`The selected Agent is cooling down after ${memberHealth.reason}.`, 'AGENT_COOLDOWN', { provider, model, retryAfterMs: memberHealth.retryAfterMs });
|
|
1121
|
+
}
|
|
1122
|
+
const at = Date.now();
|
|
1123
|
+
try {
|
|
1124
|
+
const remainingBeforeAttempt = deadline - at;
|
|
1125
|
+
if (remainingBeforeAttempt <= 0) throw new AgentError('The Agent did not respond before the shared deadline.', 'AGENT_TIMEOUT', { provider });
|
|
1126
|
+
options.timeoutMs = remainingBeforeAttempt;
|
|
1127
|
+
const result = await execute();
|
|
1128
|
+
clearAgentHealth(provider, healthIdentity);
|
|
1129
|
+
attempts.push({ attempt, provider, status: 'complete', durationMs: Date.now() - at });
|
|
1130
|
+
Object.defineProperty(result, 'agentMeta', { value: { provider, operation, reasoningEffort: options.reasoningEffort, attempts }, enumerable: false });
|
|
1131
|
+
return result;
|
|
1132
|
+
} catch (rawError) {
|
|
1133
|
+
const error = normalizeAgentError(rawError, { provider });
|
|
1134
|
+
attempts.push({ attempt, provider, status: 'failed', code: error.code, durationMs: Date.now() - at });
|
|
1135
|
+
options.onAttempt?.(attempts.at(-1));
|
|
1136
|
+
const retryable = error.retryable === true || RETRYABLE_AGENT_CODES.has(error.code);
|
|
1137
|
+
const cooldownEligible = COOLDOWN_AGENT_CODES.has(error.code);
|
|
1138
|
+
if (!retryable || attempt >= maxAttempts || options.signal?.aborted) {
|
|
1139
|
+
if (cooldownEligible) markAgentUnavailable(provider, healthIdentity, error);
|
|
1140
|
+
error.attempts = attempts;
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
const remaining = deadline - Date.now();
|
|
1144
|
+
const exponentialDelay = 500 * (2 ** (attempt - 1));
|
|
1145
|
+
const jitteredDelay = Math.min(10_000, Math.round(exponentialDelay * (0.8 + Math.random() * 0.4)));
|
|
1146
|
+
const requestedDelay = error.retryAfterMs || jitteredDelay;
|
|
1147
|
+
if (requestedDelay <= 0 || requestedDelay > remaining - 100) {
|
|
1148
|
+
if (cooldownEligible) markAgentUnavailable(provider, healthIdentity, error);
|
|
1149
|
+
error.attempts = attempts;
|
|
1150
|
+
throw error;
|
|
1151
|
+
}
|
|
1152
|
+
const delay = requestedDelay;
|
|
1153
|
+
options.onOutput?.('stderr', `[Papergod] ${error.code}; retrying attempt ${attempt + 1}/${maxAttempts} in ${delay}ms\n`);
|
|
1154
|
+
await abortableDelay(delay, options.signal);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
throw new AgentError('Agent retry loop ended unexpectedly', 'AGENT_PROTOCOL_ERROR');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export async function runWritingAgent(provider, request, options = {}) {
|
|
1161
|
+
if (!AGENT_PROVIDERS.includes(provider) || provider === 'mock') throw new Error(`External adapter unavailable for provider: ${provider}`);
|
|
1162
|
+
if (typeof request?.prompt !== 'string' || typeof request?.content !== 'string') throw new Error('prompt and content must be strings');
|
|
1163
|
+
if (request.content.length + request.prompt.length > MAX_INPUT_CHARS) throw new Error('Agent input exceeds 500,000 characters');
|
|
1164
|
+
const configuredEffort = commandSpec(provider, options.commands || {}).reasoningEffort;
|
|
1165
|
+
const reasoningEffort = options.reasoningEffort || operationEffort('suggest', configuredEffort);
|
|
1166
|
+
const runtime = { ...options, commands: options.commands || {}, timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, reasoningEffort, readFromWorkspace: Boolean(request.workspace && options.workspaceRoot) };
|
|
1167
|
+
const response = await runWithRetry(provider, 'suggest', () => provider === 'codex' ? runCodex(request, runtime)
|
|
1168
|
+
: provider === 'claude-code' ? runClaude(request, runtime)
|
|
1169
|
+
: provider === 'opencode' ? runOpenCode(request, runtime)
|
|
1170
|
+
: runPi(request, runtime), runtime);
|
|
1171
|
+
const validation = validateSuggestionResponse(response, request.content, request.resourceIds || []);
|
|
1172
|
+
if (!validation.ok) {
|
|
1173
|
+
const error = new Error('Agent response failed validation');
|
|
1174
|
+
error.code = 'AGENT_INVALID_RESPONSE';
|
|
1175
|
+
error.details = validation.errors;
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
return response;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
export async function runAcademicReviewAgent(provider, request, options = {}) {
|
|
1182
|
+
if (!AGENT_PROVIDERS.includes(provider) || provider === 'mock') throw new Error(`External adapter unavailable for provider: ${provider}`);
|
|
1183
|
+
if (typeof request?.content !== 'string' || !request?.reviewer || !Array.isArray(request?.rubric)) {
|
|
1184
|
+
throw new Error('content, reviewer, and rubric are required');
|
|
1185
|
+
}
|
|
1186
|
+
const promptSize = JSON.stringify({ reviewer: request.reviewer, rubric: request.rubric }).length;
|
|
1187
|
+
if (request.content.length + promptSize > MAX_INPUT_CHARS) throw new Error('Agent input exceeds 500,000 characters');
|
|
1188
|
+
const configuredEffort = commandSpec(provider, options.commands || {}).reasoningEffort;
|
|
1189
|
+
const reasoningEffort = options.reasoningEffort || operationEffort('review', configuredEffort);
|
|
1190
|
+
const runtime = { ...options, commands: options.commands || {}, timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, reasoningEffort, readFromWorkspace: Boolean(request.workspace && options.workspaceRoot) };
|
|
1191
|
+
const response = await runWithRetry(provider, 'review', () => provider === 'codex' ? runCodexReview(request, runtime)
|
|
1192
|
+
: provider === 'claude-code' ? runClaudeReview(request, runtime)
|
|
1193
|
+
: provider === 'opencode' ? runOpenCodeReview(request, runtime)
|
|
1194
|
+
: runPiReview(request, runtime), runtime);
|
|
1195
|
+
const validation = validateReviewResponse(response, request.content, request.rubric.map((item) => item.id));
|
|
1196
|
+
if (!validation.ok) {
|
|
1197
|
+
const error = new Error('Agent review response failed validation');
|
|
1198
|
+
error.code = 'AGENT_INVALID_RESPONSE';
|
|
1199
|
+
error.details = validation.errors;
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
return response;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export async function runPaperGenerationAgent(provider, request, options = {}) {
|
|
1206
|
+
if (!AGENT_PROVIDERS.includes(provider) || provider === 'mock') throw new Error(`External adapter unavailable for provider: ${provider}`);
|
|
1207
|
+
if (typeof request?.instruction !== 'string') throw new Error('instruction must be a string');
|
|
1208
|
+
const inputSize = request.instruction.length + String(request.projectContext || '').length + String(request.outlineContext || '').length + String(request.resourceContext || '').length;
|
|
1209
|
+
if (inputSize > MAX_INPUT_CHARS) throw new Error('Agent input exceeds 500,000 characters');
|
|
1210
|
+
const configuredEffort = commandSpec(provider, options.commands || {}).reasoningEffort;
|
|
1211
|
+
const reasoningEffort = options.reasoningEffort || operationEffort('generation', configuredEffort);
|
|
1212
|
+
const runtime = { ...options, commands: options.commands || {}, timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, reasoningEffort };
|
|
1213
|
+
const response = await runWithRetry(provider, 'generation', () => provider === 'codex' ? runCodexPaperGeneration(request, runtime)
|
|
1214
|
+
: provider === 'claude-code' ? runClaudePaperGeneration(request, runtime)
|
|
1215
|
+
: provider === 'opencode' ? runOpenCodePaperGeneration(request, runtime)
|
|
1216
|
+
: runPiPaperGeneration(request, runtime), runtime);
|
|
1217
|
+
const validation = validatePaperGenerationResponse(response, request.resourceIds || []);
|
|
1218
|
+
if (!validation.ok) {
|
|
1219
|
+
const error = new Error('Generated paper failed validation'); error.code = 'AGENT_INVALID_RESPONSE'; error.details = validation.errors; throw error;
|
|
1220
|
+
}
|
|
1221
|
+
return response;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
export async function runReviewOrchestrationAgent(provider, request, options = {}) {
|
|
1225
|
+
if (!AGENT_PROVIDERS.includes(provider) || provider === 'mock') throw new Error(`External adapter unavailable for provider: ${provider}`);
|
|
1226
|
+
if (typeof request?.feedback !== 'string' || typeof request?.content !== 'string') throw new Error('feedback and content must be strings');
|
|
1227
|
+
if (request.feedback.length + request.content.length + String(request.outlineContext || '').length > MAX_INPUT_CHARS) throw new Error('Agent input exceeds 500,000 characters');
|
|
1228
|
+
const configuredEffort = commandSpec(provider, options.commands || {}).reasoningEffort;
|
|
1229
|
+
const reasoningEffort = options.reasoningEffort || operationEffort('orchestration', configuredEffort);
|
|
1230
|
+
const runtime = { ...options, commands: options.commands || {}, timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, reasoningEffort, readFromWorkspace: Boolean(request.workspace && options.workspaceRoot) };
|
|
1231
|
+
const response = await runWithRetry(provider, 'orchestration', () => provider === 'codex' ? runCodexReviewOrchestration(request, runtime)
|
|
1232
|
+
: provider === 'claude-code' ? runClaudeReviewOrchestration(request, runtime)
|
|
1233
|
+
: provider === 'opencode' ? runOpenCodeReviewOrchestration(request, runtime)
|
|
1234
|
+
: runPiReviewOrchestration(request, runtime), runtime);
|
|
1235
|
+
const validation = validateReviewOrchestrationResponse(response, request.content);
|
|
1236
|
+
if (!validation.ok) {
|
|
1237
|
+
const error = new Error('Review orchestration failed validation'); error.code = 'AGENT_INVALID_RESPONSE'; error.details = validation.errors; throw error;
|
|
1238
|
+
}
|
|
1239
|
+
return response;
|
|
1240
|
+
}
|