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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/ROADMAP.md +171 -0
  4. package/example/main.tex +360 -0
  5. package/frontend/src/components/ui/badge.jsx +5 -0
  6. package/frontend/src/components/ui/button.jsx +24 -0
  7. package/frontend/src/components/workbench.jsx +182 -0
  8. package/frontend/src/lib/utils.js +6 -0
  9. package/frontend/src/main.jsx +19 -0
  10. package/frontend/src/theme.css +256 -0
  11. package/frontend/vite.config.js +23 -0
  12. package/package.json +73 -0
  13. package/papergod-demo.png +0 -0
  14. package/public/app.js +5480 -0
  15. package/public/brand/papergod-logo.png +0 -0
  16. package/public/i18n.js +95 -0
  17. package/public/index.html +480 -0
  18. package/public/pdf-sentence-mapping.js +142 -0
  19. package/public/react/app.js +209 -0
  20. package/public/react/assets/addon-fit-YJmn1quW.js +12 -0
  21. package/public/react/assets/addon-web-links-BWjmmSgS.js +12 -0
  22. package/public/react/assets/main.css +32 -0
  23. package/public/react/assets/xterm-BqvuqXEL.js +27 -0
  24. package/public/style.css +1462 -0
  25. package/src/cli.js +128 -0
  26. package/src/server/agent-adapters.js +1240 -0
  27. package/src/server/agent-errors.js +105 -0
  28. package/src/server/agent-runtime.js +81 -0
  29. package/src/server/agent.js +173 -0
  30. package/src/server/app-version.js +86 -0
  31. package/src/server/change-history.js +114 -0
  32. package/src/server/document-structure.js +174 -0
  33. package/src/server/index.js +1442 -0
  34. package/src/server/latex-structure.js +344 -0
  35. package/src/server/latex.js +67 -0
  36. package/src/server/library-engine.js +193 -0
  37. package/src/server/library-files.js +134 -0
  38. package/src/server/literature-review.js +122 -0
  39. package/src/server/orchestration-engine.js +662 -0
  40. package/src/server/paragraph-analysis.js +300 -0
  41. package/src/server/project-resources.js +290 -0
  42. package/src/server/project-store.js +808 -0
  43. package/src/server/prompt-manifest.js +300 -0
  44. package/src/server/references.js +425 -0
  45. package/src/server/review-panel.js +263 -0
  46. package/src/server/revise-workflow.js +278 -0
  47. package/src/server/revision-engine.js +607 -0
  48. package/src/server/security.js +16 -0
  49. package/src/server/text-extraction.js +149 -0
  50. package/src/server/workspace-browser.js +49 -0
  51. package/src/server/workspace-registry.js +143 -0
  52. package/src/server/workspace-terminal.js +99 -0
  53. package/src/server/workspace.js +223 -0
  54. package/src/server/zotero.js +98 -0
@@ -0,0 +1,425 @@
1
+ import { createHash } from 'crypto';
2
+ import { spawn } from 'child_process';
3
+ import { mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'fs/promises';
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'path';
5
+
6
+ const STATE_VERSION = 1;
7
+ const STATE_DIRECTORY = '.papergod';
8
+ const STATE_FILE = 'references.json';
9
+ const MAX_SCAN_FILES = 5000;
10
+ const DOI_PATTERN = /10\.\d{4,9}\/[\w.()/:;-]+/i;
11
+ const ARXIV_PATTERN = /(?:arxiv\s*:\s*|arxiv\.org\/(?:abs|pdf)\/)(\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+\/\d{7}(?:v\d+)?)/i;
12
+
13
+ function hashId(value) {
14
+ return `reference_${createHash('sha256').update(value).digest('hex').slice(0, 18)}`;
15
+ }
16
+
17
+ function emptyState() {
18
+ return {
19
+ version: STATE_VERSION,
20
+ bibliographyFile: 'references.bib',
21
+ folders: [],
22
+ zotero: { libraryType: 'users', libraryId: '0', collectionKey: '' },
23
+ items: [],
24
+ updatedAt: new Date().toISOString(),
25
+ };
26
+ }
27
+
28
+ function statePath(workspaceRoot) {
29
+ return join(workspaceRoot, STATE_DIRECTORY, STATE_FILE);
30
+ }
31
+
32
+ function cleanDoi(value = '') {
33
+ return String(value).replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '').replace(/^doi:\s*/i, '').replace(/[\s}\]),.;]+$/g, '').trim().toLowerCase();
34
+ }
35
+
36
+ function stripBibValue(value = '') {
37
+ let result = value.trim();
38
+ if ((result.startsWith('{') && result.endsWith('}')) || (result.startsWith('"') && result.endsWith('"'))) result = result.slice(1, -1);
39
+ return result.replace(/[{}]/g, '').replace(/\\([&#_%])/g, '$1').trim();
40
+ }
41
+
42
+ function splitTopLevel(value, separator = ',') {
43
+ const parts = [];
44
+ let start = 0;
45
+ let depth = 0;
46
+ let quoted = false;
47
+ let escaped = false;
48
+ for (let index = 0; index < value.length; index += 1) {
49
+ const character = value[index];
50
+ if (escaped) { escaped = false; continue; }
51
+ if (character === '\\') { escaped = true; continue; }
52
+ if (character === '"' && depth === 0) quoted = !quoted;
53
+ if (!quoted) {
54
+ if (character === '{') depth += 1;
55
+ else if (character === '}') depth = Math.max(0, depth - 1);
56
+ else if (character === separator && depth === 0) {
57
+ parts.push(value.slice(start, index));
58
+ start = index + 1;
59
+ }
60
+ }
61
+ }
62
+ parts.push(value.slice(start));
63
+ return parts;
64
+ }
65
+
66
+ export function parseBibTeX(content, sourcePath = '') {
67
+ const entries = [];
68
+ let cursor = 0;
69
+ while (cursor < content.length) {
70
+ const at = content.indexOf('@', cursor);
71
+ if (at < 0) break;
72
+ const header = content.slice(at).match(/^@([a-zA-Z]+)\s*([({])/);
73
+ if (!header) { cursor = at + 1; continue; }
74
+ const type = header[1].toLowerCase();
75
+ const open = header[2];
76
+ const close = open === '{' ? '}' : ')';
77
+ const bodyStart = at + header[0].length;
78
+ let depth = 1;
79
+ let quoted = false;
80
+ let escaped = false;
81
+ let end = bodyStart;
82
+ for (; end < content.length; end += 1) {
83
+ const character = content[end];
84
+ if (escaped) { escaped = false; continue; }
85
+ if (character === '\\') { escaped = true; continue; }
86
+ if (character === '"') quoted = !quoted;
87
+ if (!quoted) {
88
+ if (character === open) depth += 1;
89
+ else if (character === close && --depth === 0) break;
90
+ }
91
+ }
92
+ cursor = end + 1;
93
+ if (depth !== 0 || ['comment', 'preamble', 'string'].includes(type)) continue;
94
+ const body = content.slice(bodyStart, end);
95
+ const parts = splitTopLevel(body);
96
+ const citekey = parts.shift()?.trim();
97
+ if (!citekey) continue;
98
+ const fields = {};
99
+ for (const part of parts) {
100
+ const equals = part.indexOf('=');
101
+ if (equals < 1) continue;
102
+ fields[part.slice(0, equals).trim().toLowerCase()] = stripBibValue(part.slice(equals + 1));
103
+ }
104
+ const rawBibtex = content.slice(at, end + 1).trim();
105
+ entries.push(normalizeReference({
106
+ source: 'bibtex', sourceId: `${sourcePath}:${citekey}`, citekey, type, fields, rawBibtex,
107
+ title: fields.title || '', authors: parseBibAuthors(fields.author), year: fields.year || fields.date?.slice(0, 4) || '',
108
+ doi: fields.doi || '', arxivId: fields.eprint && /arxiv/i.test(fields.archiveprefix || '') ? fields.eprint : '',
109
+ abstract: fields.abstract || '', sourcePath,
110
+ }));
111
+ }
112
+ return entries;
113
+ }
114
+
115
+ function parseBibAuthors(value = '') {
116
+ return value.split(/\s+and\s+/i).map((name) => name.trim()).filter(Boolean);
117
+ }
118
+
119
+ function latexEscape(value = '') {
120
+ return String(value).replace(/([&#_%])/g, '\\$1').trim();
121
+ }
122
+
123
+ export function serializeReference(reference) {
124
+ if (reference.rawBibtex?.trim()) return reference.rawBibtex.trim();
125
+ const fields = reference.fields || {};
126
+ const rows = [
127
+ ['author', reference.authors?.join(' and ') || fields.author],
128
+ ['title', reference.title || fields.title],
129
+ ['journal', reference.containerTitle || fields.journal],
130
+ ['booktitle', fields.booktitle],
131
+ ['year', reference.year || fields.year],
132
+ ['volume', fields.volume], ['number', fields.number], ['pages', fields.pages],
133
+ ['publisher', fields.publisher], ['doi', reference.doi || fields.doi],
134
+ ['url', reference.url || fields.url],
135
+ ['eprint', reference.arxivId || fields.eprint],
136
+ ['archivePrefix', reference.arxivId ? 'arXiv' : fields.archiveprefix],
137
+ ].filter(([, value]) => value);
138
+ return `@${reference.type || 'article'}{${reference.citekey},\n${rows.map(([name, value]) => ` ${name} = {${latexEscape(value)}}`).join(',\n')}\n}`;
139
+ }
140
+
141
+ export function generateCitekey(reference, occupied = new Set()) {
142
+ const firstAuthor = reference.authors?.[0] || 'anonymous';
143
+ const family = firstAuthor.includes(',') ? firstAuthor.split(',')[0] : firstAuthor.trim().split(/\s+/).at(-1);
144
+ const titleWord = String(reference.title || '').toLowerCase().match(/[a-z0-9]{4,}/)?.[0] || 'work';
145
+ const base = `${family || 'anonymous'}${String(reference.year || 'nd').match(/\d{4}/)?.[0] || 'nd'}${titleWord}`
146
+ .normalize('NFKD').replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || 'reference';
147
+ let candidate = base;
148
+ let suffix = 0;
149
+ while (occupied.has(candidate)) candidate = `${base}${String.fromCharCode(97 + suffix++)}`;
150
+ return candidate;
151
+ }
152
+
153
+ export function normalizeReference(input, occupied = new Set()) {
154
+ const doi = cleanDoi(input.doi || input.fields?.doi || '');
155
+ const identity = doi ? `doi:${doi}` : input.sourceId ? `${input.source}:${input.sourceId}` : `${input.title}|${input.year}|${input.authors?.join('|')}`;
156
+ const reference = {
157
+ id: input.id || hashId(identity), source: input.source || 'manual', sourceId: input.sourceId || '',
158
+ citekey: String(input.citekey || '').replace(/[^A-Za-z0-9:_+./-]/g, ''), type: input.type || 'article', title: input.title || '',
159
+ authors: Array.isArray(input.authors) ? input.authors.filter(Boolean) : [], year: String(input.year || ''),
160
+ doi, arxivId: input.arxivId || '', abstract: input.abstract || '', url: input.url || '',
161
+ containerTitle: input.containerTitle || '', sourcePath: input.sourcePath || '', pdfPath: input.pdfPath || '',
162
+ attachmentKey: input.attachmentKey || '', hasPdf: Boolean(input.hasPdf || input.pdfPath),
163
+ fields: input.fields || {}, rawBibtex: input.rawBibtex || '',
164
+ confidence: Number.isFinite(input.confidence) ? input.confidence : doi || input.citekey ? 1 : 0.35,
165
+ status: input.status || (doi || input.citekey ? 'verified' : 'needs-review'), included: input.included !== false,
166
+ updatedAt: input.updatedAt || new Date().toISOString(),
167
+ };
168
+ reference.citekey ||= generateCitekey(reference, occupied);
169
+ return reference;
170
+ }
171
+
172
+ function mergeReferences(existing, incoming) {
173
+ const result = existing.map((item) => ({ ...item }));
174
+ const occupied = new Set(result.map((item) => item.citekey));
175
+ for (const raw of incoming) {
176
+ const item = normalizeReference(raw, occupied);
177
+ const match = result.findIndex((current) => (item.doi && current.doi === item.doi)
178
+ || (item.source === current.source && item.sourceId && item.sourceId === current.sourceId));
179
+ if (match >= 0) result[match] = { ...result[match], ...item, id: result[match].id, included: result[match].included !== false };
180
+ else {
181
+ if (occupied.has(item.citekey)) {
182
+ item.citekey = generateCitekey(item, occupied);
183
+ item.rawBibtex = '';
184
+ item.status = 'needs-review';
185
+ }
186
+ occupied.add(item.citekey);
187
+ result.push(item);
188
+ }
189
+ }
190
+ return result;
191
+ }
192
+
193
+ export async function loadReferenceState(workspaceRoot) {
194
+ try {
195
+ const parsed = JSON.parse(await readFile(statePath(workspaceRoot), 'utf8'));
196
+ return { ...emptyState(), ...parsed, folders: Array.isArray(parsed.folders) ? parsed.folders : [], items: Array.isArray(parsed.items) ? parsed.items : [] };
197
+ } catch (error) {
198
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return emptyState();
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ export async function saveReferenceState(workspaceRoot, state) {
204
+ const file = statePath(workspaceRoot);
205
+ await mkdir(dirname(file), { recursive: true });
206
+ const temporary = `${file}.${process.pid}.tmp`;
207
+ const stored = { ...state, version: STATE_VERSION, updatedAt: new Date().toISOString() };
208
+ await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
209
+ await rename(temporary, file);
210
+ return stored;
211
+ }
212
+
213
+ async function canonicalFolder(folder) {
214
+ if (!isAbsolute(folder || '')) throw Object.assign(new Error('Use an absolute literature folder path.'), { status: 400, code: 'INVALID_REFERENCE_FOLDER' });
215
+ const canonical = await realpath(resolve(folder));
216
+ if (!(await stat(canonical)).isDirectory()) throw Object.assign(new Error('The literature path is not a folder.'), { status: 400, code: 'INVALID_REFERENCE_FOLDER' });
217
+ return canonical;
218
+ }
219
+
220
+ async function collectFiles(root) {
221
+ const files = [];
222
+ const visit = async (folder) => {
223
+ if (files.length >= MAX_SCAN_FILES) return;
224
+ for (const entry of await readdir(folder, { withFileTypes: true })) {
225
+ if (entry.name.startsWith('.')) continue;
226
+ const path = join(folder, entry.name);
227
+ if (entry.isDirectory()) await visit(path);
228
+ else if (entry.isFile() && ['.bib', '.bibtex', '.pdf'].includes(extname(entry.name).toLowerCase())) files.push(path);
229
+ if (files.length >= MAX_SCAN_FILES) break;
230
+ }
231
+ };
232
+ await visit(root);
233
+ return files;
234
+ }
235
+
236
+ function pdfText(path, timeoutMs = 12_000) {
237
+ return new Promise((resolveText) => {
238
+ const child = spawn('pdftotext', ['-f', '1', '-l', '2', '-layout', path, '-'], { shell: false, windowsHide: true });
239
+ let output = '';
240
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
241
+ child.stdout.on('data', (chunk) => { if (output.length < 200_000) output += chunk; });
242
+ child.once('error', () => { clearTimeout(timer); resolveText(''); });
243
+ child.once('close', () => { clearTimeout(timer); resolveText(output); });
244
+ });
245
+ }
246
+
247
+ async function referenceFromPdf(path, root) {
248
+ const text = await pdfText(path);
249
+ const doi = cleanDoi(text.match(DOI_PATTERN)?.[0] || '');
250
+ const arxivId = text.match(ARXIV_PATTERN)?.[1] || '';
251
+ const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 15 && line.length < 240) || '';
252
+ const fallbackTitle = basename(path, extname(path)).replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
253
+ return normalizeReference({
254
+ source: 'folder', sourceId: relative(root, path), title: firstLine || fallbackTitle, doi, arxivId,
255
+ pdfPath: path, hasPdf: true, confidence: doi || arxivId ? 0.9 : 0.3,
256
+ status: doi || arxivId ? 'identified' : 'needs-review',
257
+ });
258
+ }
259
+
260
+ export async function scanReferenceFolder(folder) {
261
+ const root = await canonicalFolder(folder);
262
+ const files = await collectFiles(root);
263
+ let items = [];
264
+ for (const file of files.filter((path) => ['.bib', '.bibtex'].includes(extname(path).toLowerCase()))) {
265
+ items = mergeReferences(items, parseBibTeX(await readFile(file, 'utf8'), file));
266
+ }
267
+ for (const file of files.filter((path) => extname(path).toLowerCase() === '.pdf')) {
268
+ const pdf = await referenceFromPdf(file, root);
269
+ const match = items.find((item) => (pdf.doi && item.doi === pdf.doi)
270
+ || basename(item.sourcePath || '', extname(item.sourcePath || '')).toLowerCase() === basename(file, '.pdf').toLowerCase());
271
+ if (match) { match.pdfPath = file; match.hasPdf = true; }
272
+ else items = mergeReferences(items, [pdf]);
273
+ }
274
+ return { root, files: files.length, items };
275
+ }
276
+
277
+ export async function addReferenceFolder(workspaceRoot, folder) {
278
+ const scan = await scanReferenceFolder(folder);
279
+ const state = await loadReferenceState(workspaceRoot);
280
+ if (!state.folders.includes(scan.root)) state.folders.push(scan.root);
281
+ state.items = mergeReferences(state.items, scan.items);
282
+ return { state: await saveReferenceState(workspaceRoot, state), scan };
283
+ }
284
+
285
+ export async function scanAllReferenceFolders(workspaceRoot) {
286
+ const state = await loadReferenceState(workspaceRoot);
287
+ const scans = [];
288
+ for (const folder of state.folders) {
289
+ try {
290
+ const scan = await scanReferenceFolder(folder);
291
+ scans.push({ folder, ok: true, files: scan.files, items: scan.items.length });
292
+ state.items = mergeReferences(state.items, scan.items);
293
+ } catch (error) {
294
+ scans.push({ folder, ok: false, error: error.message });
295
+ }
296
+ }
297
+ return { state: await saveReferenceState(workspaceRoot, state), scans };
298
+ }
299
+
300
+ export async function importReferences(workspaceRoot, references) {
301
+ if (!Array.isArray(references) || references.length > 500) throw Object.assign(new Error('references must be an array of at most 500 items.'), { status: 400 });
302
+ const state = await loadReferenceState(workspaceRoot);
303
+ state.items = mergeReferences(state.items, references);
304
+ return await saveReferenceState(workspaceRoot, state);
305
+ }
306
+
307
+ export async function configureReferences(workspaceRoot, patch = {}) {
308
+ const state = await loadReferenceState(workspaceRoot);
309
+ if (patch.bibliographyFile !== undefined) {
310
+ if (typeof patch.bibliographyFile !== 'string' || !/^[^\0]+\.bib(?:tex)?$/i.test(patch.bibliographyFile)) {
311
+ throw Object.assign(new Error('bibliographyFile must name a .bib file.'), { status: 400 });
312
+ }
313
+ state.bibliographyFile = patch.bibliographyFile;
314
+ }
315
+ if (patch.zotero !== undefined) {
316
+ const value = patch.zotero || {};
317
+ if (!['users', 'groups'].includes(value.libraryType || 'users')) throw Object.assign(new Error('Invalid Zotero library type.'), { status: 400 });
318
+ state.zotero = {
319
+ libraryType: value.libraryType || 'users', libraryId: String(value.libraryId || '0'),
320
+ collectionKey: typeof value.collectionKey === 'string' ? value.collectionKey : '',
321
+ };
322
+ }
323
+ return await saveReferenceState(workspaceRoot, state);
324
+ }
325
+
326
+ export async function removeReferenceFolder(workspaceRoot, folder) {
327
+ const state = await loadReferenceState(workspaceRoot);
328
+ state.folders = state.folders.filter((item) => item !== folder);
329
+ return await saveReferenceState(workspaceRoot, state);
330
+ }
331
+
332
+ export async function updateReference(workspaceRoot, id, patch) {
333
+ const state = await loadReferenceState(workspaceRoot);
334
+ const index = state.items.findIndex((item) => item.id === id);
335
+ if (index < 0) throw Object.assign(new Error('Reference not found.'), { status: 404 });
336
+ if (patch.citekey !== undefined) {
337
+ if (typeof patch.citekey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:_+./-]*$/.test(patch.citekey)) throw Object.assign(new Error('Invalid citation key.'), { status: 400 });
338
+ if (state.items.some((item, itemIndex) => itemIndex !== index && item.citekey === patch.citekey)) throw Object.assign(new Error('Citation key is already in use.'), { status: 409 });
339
+ }
340
+ const allowed = ['citekey', 'title', 'authors', 'year', 'doi', 'arxivId', 'abstract', 'included', 'status'];
341
+ const next = { ...state.items[index] };
342
+ for (const key of allowed) if (patch[key] !== undefined) next[key] = patch[key];
343
+ next.rawBibtex = '';
344
+ state.items[index] = normalizeReference(next);
345
+ return { state: await saveReferenceState(workspaceRoot, state), item: state.items[index] };
346
+ }
347
+
348
+ export async function writeBibliography(workspaceRoot) {
349
+ const state = await loadReferenceState(workspaceRoot);
350
+ const target = resolve(workspaceRoot, state.bibliographyFile || 'references.bib');
351
+ if (!target.startsWith(resolve(workspaceRoot) + sep) || !/\.bib(?:tex)?$/i.test(target)) {
352
+ throw Object.assign(new Error('Bibliography file must be a .bib file inside the workspace.'), { status: 400, code: 'INVALID_BIBLIOGRAPHY_PATH' });
353
+ }
354
+ const included = state.items.filter((item) => item.included !== false);
355
+ await writeFile(target, `${included.map(serializeReference).join('\n\n')}\n`, 'utf8');
356
+ return { file: relative(workspaceRoot, target), count: included.length };
357
+ }
358
+
359
+ export function extractCitationKeys(source = '') {
360
+ const keys = [];
361
+ const pattern = /\\(?:cite\w*|nocite)\*?(?:\s*\[[^\]]*\]){0,2}\s*\{([^{}]*)\}/g;
362
+ for (const match of source.matchAll(pattern)) keys.push(...match[1].split(',').map((key) => key.trim()).filter((key) => key && key !== '*'));
363
+ return [...new Set(keys)];
364
+ }
365
+
366
+ export function checkCitations(source, references) {
367
+ const cited = extractCitationKeys(source);
368
+ const available = new Set(references.filter((item) => item.included !== false).map((item) => item.citekey));
369
+ return {
370
+ cited, missing: cited.filter((key) => !available.has(key)),
371
+ uncited: references.filter((item) => item.included !== false && !cited.includes(item.citekey)).map((item) => item.citekey),
372
+ bibliographyConfigured: /\\(?:bibliography|addbibresource)\s*(?:\[[^\]]*\])?\s*\{[^}]+\}/.test(source),
373
+ };
374
+ }
375
+
376
+ export async function checkWorkspaceCitations(workspaceRoot, file) {
377
+ const target = resolve(workspaceRoot, file || 'main.tex');
378
+ if (!target.startsWith(resolve(workspaceRoot) + sep) || !target.endsWith('.tex')) throw Object.assign(new Error('Invalid TeX file.'), { status: 400 });
379
+ const state = await loadReferenceState(workspaceRoot);
380
+ return checkCitations(await readFile(target, 'utf8'), state.items);
381
+ }
382
+
383
+ export async function resolveReferenceMetadata(reference, { fetchImpl = fetch } = {}) {
384
+ if (!reference?.doi) return normalizeReference(reference || {});
385
+ const controller = new AbortController();
386
+ const timer = setTimeout(() => controller.abort(), 10_000);
387
+ try {
388
+ const response = await fetchImpl(`https://api.crossref.org/works/${encodeURIComponent(cleanDoi(reference.doi))}`, {
389
+ headers: { 'User-Agent': 'Papergod/0.1 (local reference manager)' }, signal: controller.signal,
390
+ });
391
+ if (!response.ok) throw Object.assign(new Error(`Metadata lookup failed (${response.status}).`), { status: 502 });
392
+ const work = (await response.json()).message;
393
+ return normalizeReference({
394
+ ...reference, source: reference.source || 'crossref', title: work.title?.[0] || reference.title,
395
+ authors: work.author?.map((author) => [author.family, author.given].filter(Boolean).join(', ')) || reference.authors,
396
+ year: work.published?.['date-parts']?.[0]?.[0] || work.issued?.['date-parts']?.[0]?.[0] || reference.year,
397
+ doi: work.DOI || reference.doi, url: work.URL || reference.url, containerTitle: work['container-title']?.[0] || reference.containerTitle,
398
+ type: work.type === 'proceedings-article' ? 'inproceedings' : work.type === 'book' ? 'book' : 'article',
399
+ confidence: 1, status: 'verified', rawBibtex: '',
400
+ });
401
+ } finally { clearTimeout(timer); }
402
+ }
403
+
404
+ export async function resolveStoredReference(workspaceRoot, id, options) {
405
+ const state = await loadReferenceState(workspaceRoot);
406
+ const index = state.items.findIndex((item) => item.id === id);
407
+ if (index < 0) throw Object.assign(new Error('Reference not found.'), { status: 404 });
408
+ state.items[index] = { ...(await resolveReferenceMetadata(state.items[index], options)), id: state.items[index].id };
409
+ return { state: await saveReferenceState(workspaceRoot, state), item: state.items[index] };
410
+ }
411
+
412
+ export function buildCitationContext(references, citekeys = []) {
413
+ const selected = references.filter((item) => citekeys.includes(item.citekey));
414
+ if (!selected.length) return '';
415
+ const entries = selected.map((item) => `[${item.citekey}]\nTitle: ${item.title}\nAuthors: ${item.authors.join('; ')}\nYear: ${item.year || 'unknown'}\nDOI: ${item.doi || 'none'}\nAbstract: ${item.abstract || 'not available'}`);
416
+ return `Available verified references:\n\n${entries.join('\n\n')}\n\nOnly use citation keys listed above. Never invent references, authors, findings, identifiers, or citation keys.`;
417
+ }
418
+
419
+ export function findUnknownAgentCitations(suggestions, source, references) {
420
+ const allowed = new Set([
421
+ ...references.filter((item) => item.included !== false).map((item) => item.citekey),
422
+ ...extractCitationKeys(source),
423
+ ]);
424
+ return [...new Set((suggestions || []).flatMap((item) => extractCitationKeys(item.suggestedText || '')))].filter((key) => !allowed.has(key));
425
+ }