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,105 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
/(authorization\s*:\s*(?:bearer\s+)?)[^\s,;]+/gi,
|
|
3
|
+
/(["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|oauth[_-]?token|password)["']?\s*[=:]\s*["']?)[^"'\s,;]+/gi,
|
|
4
|
+
/\b(sk-[a-zA-Z0-9_-]{12,})\b/g,
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export class AgentError extends Error {
|
|
8
|
+
constructor(message, code, options = {}) {
|
|
9
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
10
|
+
this.name = 'AgentError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
if (options.provider) this.provider = options.provider;
|
|
13
|
+
if (options.model) this.model = options.model;
|
|
14
|
+
if (Number.isFinite(options.retryAfterMs)) this.retryAfterMs = options.retryAfterMs;
|
|
15
|
+
if (options.diagnostic) this.diagnostic = redactAgentDiagnostic(options.diagnostic);
|
|
16
|
+
this.retryable = options.retryable === true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function redactAgentDiagnostic(value, limit = 4000) {
|
|
21
|
+
let text = String(value || '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
22
|
+
for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, (_match, prefix) => `${prefix || ''}[REDACTED]`);
|
|
23
|
+
return text.slice(-limit);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function retryAfterFrom(text) {
|
|
27
|
+
const match = String(text || '').match(/retry[- ]?after\s*(?:[:=]\s*)?(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|seconds?)?/i);
|
|
28
|
+
if (!match) return undefined;
|
|
29
|
+
const amount = Number(match[1]);
|
|
30
|
+
return /^m(?!s)/i.test(match[2] || '') ? amount * 60_000 : /^ms|millisecond/i.test(match[2] || '') ? amount : amount * 1000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function classifyAgentDiagnostic(value, { provider = '', model = '', cause } = {}) {
|
|
34
|
+
const diagnostic = redactAgentDiagnostic(value);
|
|
35
|
+
const lower = diagnostic.toLowerCase();
|
|
36
|
+
const make = (message, code, retryable = false) => new AgentError(message, code, {
|
|
37
|
+
provider, model, cause, diagnostic, retryable, retryAfterMs: retryAfterFrom(diagnostic),
|
|
38
|
+
});
|
|
39
|
+
if (/content[_ -]?filter|finish[_ -]?reason["'\s:=]+content_filter|reason["'\s:=]+content_filter/.test(lower)) {
|
|
40
|
+
return make('The model output was interrupted by the provider content filter. No partial result was applied.', 'AGENT_CONTENT_FILTERED');
|
|
41
|
+
}
|
|
42
|
+
if (/\brefusal\b|\brefused\b|safety refusal|response was blocked/.test(lower)) {
|
|
43
|
+
return make('The model refused this request. No partial result was applied.', 'AGENT_REFUSED');
|
|
44
|
+
}
|
|
45
|
+
if (/max[_ -]?tokens|finish[_ -]?reason["'\s:=]+length|incomplete.*(?:length|token)|output.*truncat/.test(lower)) {
|
|
46
|
+
return make('The model output ended before the structured result was complete.', 'AGENT_OUTPUT_TRUNCATED', true);
|
|
47
|
+
}
|
|
48
|
+
if (/invalid_json_schema|unsupported.*(?:schema|flag)|unknown (?:argument|option)|unrecognized (?:argument|option)/.test(lower)) {
|
|
49
|
+
return make('The installed Agent CLI is incompatible with Papergod’s required structured-output protocol.', 'AGENT_CLI_INCOMPATIBLE');
|
|
50
|
+
}
|
|
51
|
+
if (/rate.?limit|too many requests|\b429\b|quota exceeded|insufficient quota/.test(lower)) {
|
|
52
|
+
return make('The Agent provider rate limit or quota was reached.', 'AGENT_RATE_LIMITED', true);
|
|
53
|
+
}
|
|
54
|
+
if (/unauthorized|forbidden|sign.?in required|log.?in required|authentication failed|\b401\b|\b403\b/.test(lower)) {
|
|
55
|
+
return make('The Agent CLI is installed but its model provider is not authenticated.', 'AGENT_AUTH_REQUIRED');
|
|
56
|
+
}
|
|
57
|
+
if (/model.*(?:not found|unavailable)|unknown model|\b404\b.*model/.test(lower)) {
|
|
58
|
+
return make('The configured Agent model is unavailable.', 'AGENT_MODEL_NOT_FOUND');
|
|
59
|
+
}
|
|
60
|
+
if (/timed? out|timeout/.test(lower)) return make('The Agent did not respond before the timeout.', 'AGENT_TIMEOUT', true);
|
|
61
|
+
if (/econnreset|econnrefused|enotfound|network|socket|transport|connection (?:closed|failed)/.test(lower)) {
|
|
62
|
+
return make('The Agent provider connection failed.', 'AGENT_TRANSPORT_ERROR', true);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function normalizeAgentError(error, context = {}) {
|
|
68
|
+
if (error instanceof AgentError) return error;
|
|
69
|
+
const diagnostic = [error?.message, error?.stderr, error?.stdout, error?.diagnostic].filter(Boolean).join('\n');
|
|
70
|
+
const classified = classifyAgentDiagnostic(diagnostic, { ...context, cause: error });
|
|
71
|
+
if (classified) return classified;
|
|
72
|
+
if (error?.code === 'AGENT_CANCELLED' || error?.code === 'AGENT_TIMEOUT' || error?.code === 'AGENT_OUTPUT_LIMIT') return error;
|
|
73
|
+
return new AgentError(error?.message || 'Agent execution failed', error?.code || 'AGENT_PROCESS_FAILED', {
|
|
74
|
+
...context, cause: error, diagnostic,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function agentFailureAudit(error, limit = 4000) {
|
|
79
|
+
return redactAgentDiagnostic(JSON.stringify({
|
|
80
|
+
code: error?.code || 'AGENT_PROCESS_FAILED',
|
|
81
|
+
message: error?.message || 'Agent execution failed',
|
|
82
|
+
diagnostic: error?.diagnostic || '',
|
|
83
|
+
attempts: Array.isArray(error?.attempts) ? error.attempts : [],
|
|
84
|
+
}), limit);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function incompleteJsonKind(value) {
|
|
88
|
+
const text = String(value || '').trim();
|
|
89
|
+
if (!text) return 'empty';
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let inString = false;
|
|
92
|
+
let escaped = false;
|
|
93
|
+
for (const character of text) {
|
|
94
|
+
if (inString) {
|
|
95
|
+
if (escaped) escaped = false;
|
|
96
|
+
else if (character === '\\') escaped = true;
|
|
97
|
+
else if (character === '"') inString = false;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (character === '"') inString = true;
|
|
101
|
+
else if (character === '{' || character === '[') depth += 1;
|
|
102
|
+
else if (character === '}' || character === ']') depth -= 1;
|
|
103
|
+
}
|
|
104
|
+
return inString || depth > 0 ? 'truncated' : 'malformed';
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const DEFAULT_TRANSIENT_COOLDOWN_MS = 60_000;
|
|
2
|
+
const DEFAULT_RATE_COOLDOWN_MS = 5 * 60_000;
|
|
3
|
+
const health = new Map();
|
|
4
|
+
|
|
5
|
+
export function agentMemberKey(provider, model = '') {
|
|
6
|
+
return `${provider}/${model || 'default'}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function agentHealthStatus(provider, model = '', now = Date.now()) {
|
|
10
|
+
const key = agentMemberKey(provider, model);
|
|
11
|
+
const record = health.get(key);
|
|
12
|
+
if (!record) return { available: true, key };
|
|
13
|
+
if (record.unavailableUntil <= now) {
|
|
14
|
+
health.delete(key);
|
|
15
|
+
return { available: true, key };
|
|
16
|
+
}
|
|
17
|
+
return { available: false, key, ...record, retryAfterMs: record.unavailableUntil - now };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function markAgentUnavailable(provider, model, error, now = Date.now()) {
|
|
21
|
+
const code = error?.code || 'AGENT_PROCESS_FAILED';
|
|
22
|
+
let cooldownMs;
|
|
23
|
+
if (code === 'AGENT_RATE_LIMITED') cooldownMs = error.retryAfterMs || DEFAULT_RATE_COOLDOWN_MS;
|
|
24
|
+
else if (['AGENT_TIMEOUT', 'AGENT_TRANSPORT_ERROR', 'AGENT_EMPTY_RESPONSE', 'AGENT_MODEL_NOT_FOUND'].includes(code)) cooldownMs = error.retryAfterMs || DEFAULT_TRANSIENT_COOLDOWN_MS;
|
|
25
|
+
else return null;
|
|
26
|
+
const key = agentMemberKey(provider, model);
|
|
27
|
+
const unavailableUntil = now + cooldownMs;
|
|
28
|
+
const existing = health.get(key);
|
|
29
|
+
if (!existing || existing.unavailableUntil < unavailableUntil) health.set(key, { unavailableUntil, reason: code });
|
|
30
|
+
return agentHealthStatus(provider, model, now);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearAgentHealth(provider, model) {
|
|
34
|
+
if (model !== undefined) return health.delete(agentMemberKey(provider, model));
|
|
35
|
+
for (const key of [...health.keys()]) if (key.startsWith(`${provider}/`)) health.delete(key);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resetAgentHealthForTests() {
|
|
40
|
+
health.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseCliVersion(value) {
|
|
44
|
+
const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)/);
|
|
45
|
+
return match ? { raw: match[0], major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function versionAtLeast(actual, required) {
|
|
49
|
+
const left = parseCliVersion(actual);
|
|
50
|
+
const right = parseCliVersion(required);
|
|
51
|
+
if (!left || !right) return false;
|
|
52
|
+
const leftParts = [left.major, left.minor, left.patch];
|
|
53
|
+
const rightParts = [right.major, right.minor, right.patch];
|
|
54
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
55
|
+
if (leftParts[index] > rightParts[index]) return true;
|
|
56
|
+
if (leftParts[index] < rightParts[index]) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function inspectCliCapabilities(provider, versionText, helpText = '') {
|
|
62
|
+
const help = String(helpText || '');
|
|
63
|
+
const requiredFlags = provider === 'codex' ? ['--output-schema', '--output-last-message', '--ephemeral']
|
|
64
|
+
: provider === 'claude-code' ? ['--json-schema', '--output-format', '--no-session-persistence']
|
|
65
|
+
: provider === 'opencode' ? ['--format', '--pure', '--variant']
|
|
66
|
+
: ['--mode', '--no-session', '--thinking'];
|
|
67
|
+
const missing = requiredFlags.filter((flag) => !help.includes(flag));
|
|
68
|
+
const capabilities = {
|
|
69
|
+
structuredOutput: provider === 'codex' ? help.includes('--output-schema') : provider === 'claude-code' ? help.includes('--json-schema') : help.includes('--format') || help.includes('--mode'),
|
|
70
|
+
eventStream: provider === 'codex' ? help.includes('--json') : provider !== 'claude-code' || /stream-json/.test(help),
|
|
71
|
+
reasoningEffort: provider === 'codex' ? help.includes('--config') : provider === 'claude-code' ? help.includes('--effort') : provider === 'opencode' ? help.includes('--variant') : help.includes('--thinking'),
|
|
72
|
+
ephemeralSession: provider === 'codex' ? help.includes('--ephemeral') : provider === 'claude-code' ? help.includes('--no-session-persistence') : provider === 'opencode' ? help.includes('--pure') : help.includes('--no-session'),
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
installed: true,
|
|
76
|
+
compatible: missing.length === 0,
|
|
77
|
+
version: parseCliVersion(versionText)?.raw || String(versionText || '').trim() || null,
|
|
78
|
+
capabilities,
|
|
79
|
+
warnings: missing.length ? [`Installed CLI is missing required flags: ${missing.join(', ')}`] : [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
let nextId = 1;
|
|
2
|
+
const suggestionStore = new Map();
|
|
3
|
+
|
|
4
|
+
function resetStore() {
|
|
5
|
+
suggestionStore.clear();
|
|
6
|
+
nextId = 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function composeMockSuggestions(content, prompt) {
|
|
10
|
+
const results = [];
|
|
11
|
+
let nextId = 1;
|
|
12
|
+
|
|
13
|
+
const patterns = [
|
|
14
|
+
{
|
|
15
|
+
regex: /very\s+(\w+)/gi,
|
|
16
|
+
category: 'style',
|
|
17
|
+
makeDesc: (m) => `Replace "${m}" with a more precise word`,
|
|
18
|
+
makeSuggestion: (m, adj) => {
|
|
19
|
+
const upgrades = { important: 'crucial', good: 'excellent', useful: 'invaluable', rapidly: 'rapidly', big: 'substantial', hard: 'difficult' };
|
|
20
|
+
const better = upgrades[adj.toLowerCase()];
|
|
21
|
+
return better && better !== adj.toLowerCase() ? better : `remarkably ${adj}`;
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
regex: /It was (found|shown|demonstrated|observed) that\s+(the\s+)?/gi,
|
|
26
|
+
category: 'grammar',
|
|
27
|
+
makeDesc: () => 'Consider using active voice instead of passive',
|
|
28
|
+
makeSuggestion: (m, _verb, _the) => '',
|
|
29
|
+
transform: (content, match) => {
|
|
30
|
+
const passive = match[0];
|
|
31
|
+
const rest = content.slice(match.index + match[0].length);
|
|
32
|
+
const sentenceEnd = rest.search(/[.!]/);
|
|
33
|
+
const fragment = sentenceEnd > 0 ? rest.slice(0, sentenceEnd) : rest.split('\n')[0];
|
|
34
|
+
return { original: passive + fragment, suggested: fragment.charAt(0).toUpperCase() + fragment.slice(1) };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
regex: /In conclusion,?\s*.+?\./gi,
|
|
39
|
+
category: 'structure',
|
|
40
|
+
makeDesc: () => 'The conclusion is too brief; consider expanding it',
|
|
41
|
+
makeSuggestion: (m) => m,
|
|
42
|
+
transform: (_content, match) => {
|
|
43
|
+
const original = match[0];
|
|
44
|
+
const suggested = original.includes('very useful')
|
|
45
|
+
? 'In conclusion, artificial intelligence has demonstrated significant utility across diverse domains, from healthcare to scientific research. As the field continues to advance, we anticipate even broader applications and deeper integration into everyday problem-solving.'
|
|
46
|
+
: original.replace(/In conclusion,?\s*/i, 'In summary, the findings demonstrate that ');
|
|
47
|
+
return { original, suggested };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const pat of patterns) {
|
|
53
|
+
const regex = new RegExp(pat.regex.source, pat.regex.flags);
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = regex.exec(content)) !== null) {
|
|
56
|
+
const id = `sug_${nextId++}`;
|
|
57
|
+
let original, suggested;
|
|
58
|
+
|
|
59
|
+
if (pat.transform) {
|
|
60
|
+
const result = pat.transform(content, match);
|
|
61
|
+
original = result.original;
|
|
62
|
+
suggested = result.suggested;
|
|
63
|
+
} else {
|
|
64
|
+
original = match[0];
|
|
65
|
+
suggested = pat.makeSuggestion(match[0], match[1]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!original || !suggested || original === suggested) continue;
|
|
69
|
+
|
|
70
|
+
results.push({ id, category: pat.category, description: pat.makeDesc(match[0]), originalText: original, suggestedText: suggested });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (results.length === 0 && content.trim().length > 0) {
|
|
75
|
+
const id = `sug_${nextId++}`;
|
|
76
|
+
const original = content.split('\n').find((l) => l.trim().length > 20) || content.split('\n')[0];
|
|
77
|
+
const suggested = original
|
|
78
|
+
.replace(/\bcan be\b/gi, 'is')
|
|
79
|
+
.replace(/\bhave been\b/gi, 'were')
|
|
80
|
+
.replace(/\bis being\b/gi, 'is');
|
|
81
|
+
if (original !== suggested) results.push({
|
|
82
|
+
id, category: 'style', description: 'Consider refining the academic tone of this document',
|
|
83
|
+
originalText: original, suggestedText: suggested,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function generateSuggestions(content, prompt) {
|
|
91
|
+
resetStore();
|
|
92
|
+
const results = composeMockSuggestions(content, prompt);
|
|
93
|
+
for (const item of results) suggestionStore.set(item.id, item);
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerSuggestions(items) {
|
|
98
|
+
resetStore();
|
|
99
|
+
return items.map((item) => {
|
|
100
|
+
const suggestion = {
|
|
101
|
+
id: `sug_${nextId++}`,
|
|
102
|
+
category: item.category,
|
|
103
|
+
description: item.description,
|
|
104
|
+
originalText: item.originalText,
|
|
105
|
+
suggestedText: item.suggestedText,
|
|
106
|
+
reason: item.reason || '',
|
|
107
|
+
taskId: item.taskId || '',
|
|
108
|
+
nodeId: item.nodeId || '',
|
|
109
|
+
usedTemplateIds: Array.isArray(item.usedTemplateIds) ? item.usedTemplateIds : [],
|
|
110
|
+
usedCitekeys: Array.isArray(item.usedCitekeys) ? item.usedCitekeys : [],
|
|
111
|
+
targetAnchor: item.targetAnchor || null,
|
|
112
|
+
};
|
|
113
|
+
suggestionStore.set(suggestion.id, suggestion);
|
|
114
|
+
return suggestion;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function attachSuggestionContext(items, { file = '', nodeId = '', nodeStart = 0, selectedContent = '' } = {}) {
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
const suggestion = suggestionStore.get(item.id);
|
|
121
|
+
if (!suggestion) continue;
|
|
122
|
+
if (suggestion.targetAnchor?.sourceRange) {
|
|
123
|
+
suggestion.file = suggestion.targetAnchor.file || file;
|
|
124
|
+
suggestion.nodeId = suggestion.targetAnchor.nodeId || suggestion.nodeId || nodeId;
|
|
125
|
+
suggestion.sourceRange = { start: suggestion.targetAnchor.sourceRange.start, end: suggestion.targetAnchor.sourceRange.end };
|
|
126
|
+
} else {
|
|
127
|
+
const matches = [];
|
|
128
|
+
let offset = selectedContent.indexOf(suggestion.originalText);
|
|
129
|
+
while (offset !== -1) {
|
|
130
|
+
matches.push(offset);
|
|
131
|
+
offset = selectedContent.indexOf(suggestion.originalText, offset + Math.max(1, suggestion.originalText.length));
|
|
132
|
+
}
|
|
133
|
+
if (matches.length !== 1) continue;
|
|
134
|
+
suggestion.file = file;
|
|
135
|
+
suggestion.nodeId = nodeId;
|
|
136
|
+
suggestion.sourceRange = {
|
|
137
|
+
start: nodeStart + matches[0],
|
|
138
|
+
end: nodeStart + matches[0] + suggestion.originalText.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
Object.assign(item, {
|
|
142
|
+
nodeId: suggestion.nodeId,
|
|
143
|
+
sourceRange: suggestion.sourceRange,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return items;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getSuggestion(id) {
|
|
150
|
+
return suggestionStore.get(id) || null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function removeSuggestion(id) {
|
|
154
|
+
return suggestionStore.delete(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function applySuggestionToContent(content, suggestionId, file = '') {
|
|
158
|
+
const sug = suggestionStore.get(suggestionId);
|
|
159
|
+
if (!sug) return { content, error: 'Suggestion not found' };
|
|
160
|
+
if (sug.file && file && sug.file !== file) return { content, error: 'Suggestion belongs to a different file' };
|
|
161
|
+
let idx = -1;
|
|
162
|
+
if (sug.sourceRange) {
|
|
163
|
+
const actual = content.slice(sug.sourceRange.start, sug.sourceRange.end);
|
|
164
|
+
if (actual !== sug.originalText) return { content, error: 'Source text changed; generate a new suggestion' };
|
|
165
|
+
idx = sug.sourceRange.start;
|
|
166
|
+
} else {
|
|
167
|
+
idx = content.indexOf(sug.originalText);
|
|
168
|
+
}
|
|
169
|
+
if (idx === -1) return { content, error: 'Original text not found in document' };
|
|
170
|
+
const newContent = content.slice(0, idx) + sug.suggestedText + content.slice(idx + sug.originalText.length);
|
|
171
|
+
suggestionStore.delete(suggestionId);
|
|
172
|
+
return { content: newContent, error: null };
|
|
173
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
6
|
+
const PACKAGE_FILE = resolve(PROJECT_ROOT, 'package.json');
|
|
7
|
+
const RELEASES_URL = 'https://api.github.com/repos/LiuShengyu-Tech/papergod/releases/latest';
|
|
8
|
+
const CACHE_TTL = 30 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
let cachedRelease = null;
|
|
11
|
+
let cachedAt = 0;
|
|
12
|
+
let cacheResolved = false;
|
|
13
|
+
|
|
14
|
+
export function compareVersions(left, right) {
|
|
15
|
+
const parse = (value) => String(value || '').replace(/^v/i, '').split('-')[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
16
|
+
const a = parse(left);
|
|
17
|
+
const b = parse(right);
|
|
18
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
19
|
+
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) > (b[index] || 0) ? 1 : -1;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseReleaseNotes(body = '') {
|
|
25
|
+
const sections = { highlights: [], fixes: [] };
|
|
26
|
+
let target = sections.highlights;
|
|
27
|
+
for (const rawLine of String(body).split('\n')) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
if (/^#{1,6}\s*(bug fixes?|fixes?|修复|问题修复)/i.test(line)) { target = sections.fixes; continue; }
|
|
30
|
+
if (/^#{1,6}\s*(what'?s new|features?|improvements?|新增|更新|改进)/i.test(line)) { target = sections.highlights; continue; }
|
|
31
|
+
const match = line.match(/^[-*]\s+(.+)/);
|
|
32
|
+
if (match) target.push(match[1].replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1').replace(/`([^`]+)`/g, '$1'));
|
|
33
|
+
}
|
|
34
|
+
return sections;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchLatestRelease(fetchImpl = globalThis.fetch) {
|
|
38
|
+
if (cacheResolved && Date.now() - cachedAt < CACHE_TTL) return cachedRelease;
|
|
39
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
40
|
+
const response = await fetchImpl(RELEASES_URL, {
|
|
41
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'papergod-update-check' },
|
|
42
|
+
signal: AbortSignal.timeout(3000),
|
|
43
|
+
});
|
|
44
|
+
if (response.status === 404) {
|
|
45
|
+
cachedRelease = null;
|
|
46
|
+
cachedAt = Date.now();
|
|
47
|
+
cacheResolved = true;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) throw new Error(`Release check failed (${response.status})`);
|
|
51
|
+
const release = await response.json();
|
|
52
|
+
cachedRelease = release;
|
|
53
|
+
cachedAt = Date.now();
|
|
54
|
+
cacheResolved = true;
|
|
55
|
+
return release;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getAppVersionInfo({ fetchImpl = globalThis.fetch } = {}) {
|
|
59
|
+
const packageData = JSON.parse(await readFile(PACKAGE_FILE, 'utf-8'));
|
|
60
|
+
const currentVersion = packageData.version;
|
|
61
|
+
try {
|
|
62
|
+
const release = await fetchLatestRelease(fetchImpl);
|
|
63
|
+
if (!release) return { currentVersion, latestVersion: currentVersion, updateAvailable: false, checked: true };
|
|
64
|
+
const latestVersion = String(release.tag_name || release.name || currentVersion).replace(/^v/i, '');
|
|
65
|
+
const notes = parseReleaseNotes(release.body);
|
|
66
|
+
return {
|
|
67
|
+
currentVersion,
|
|
68
|
+
latestVersion,
|
|
69
|
+
updateAvailable: compareVersions(latestVersion, currentVersion) > 0,
|
|
70
|
+
checked: true,
|
|
71
|
+
publishedAt: release.published_at || null,
|
|
72
|
+
releaseUrl: release.html_url || packageData.homepage || null,
|
|
73
|
+
title: release.name || `Papergod ${latestVersion}`,
|
|
74
|
+
highlights: notes.highlights,
|
|
75
|
+
fixes: notes.fixes,
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return { currentVersion, latestVersion: currentVersion, updateAvailable: false, checked: false };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resetVersionCacheForTests() {
|
|
83
|
+
cachedRelease = null;
|
|
84
|
+
cachedAt = 0;
|
|
85
|
+
cacheResolved = false;
|
|
86
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { loadProject } from './project-store.js';
|
|
4
|
+
import { sanitizePath } from './security.js';
|
|
5
|
+
|
|
6
|
+
function hash(content) {
|
|
7
|
+
return createHash('sha256').update(content).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function timestampOf(revision) {
|
|
11
|
+
return revision.rolledBackAt || revision.appliedAt || revision.updatedAt || revision.createdAt || '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function appliedRanges(changes) {
|
|
15
|
+
const executable = changes.filter((change) => Number.isInteger(change.target?.start) && typeof change.before === 'string' && typeof change.after === 'string');
|
|
16
|
+
return executable.map((change) => {
|
|
17
|
+
const shift = executable
|
|
18
|
+
.filter((other) => other.target.start < change.target.start)
|
|
19
|
+
.reduce((total, other) => total + other.after.length - other.before.length, 0);
|
|
20
|
+
const currentStart = change.target.start + shift;
|
|
21
|
+
return { ...change, currentStart, currentEnd: currentStart + change.after.length };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function historicalRevisionSource(workspaceRoot, revision, document) {
|
|
26
|
+
if (!revision?.recoveryPoint) {
|
|
27
|
+
const error = new Error('Historical source is unavailable'); error.status = 404; throw error;
|
|
28
|
+
}
|
|
29
|
+
const recovery = sanitizePath(revision.recoveryPoint.path, workspaceRoot);
|
|
30
|
+
if (!recovery || !revision.recoveryPoint.path.startsWith('.papergod/recovery/')) {
|
|
31
|
+
const error = new Error('Invalid recovery point'); error.status = 403; throw error;
|
|
32
|
+
}
|
|
33
|
+
const original = await readFile(recovery, 'utf-8');
|
|
34
|
+
if (hash(original) !== revision.recoveryPoint.sourceHash) {
|
|
35
|
+
const error = new Error('Recovery point checksum failed'); error.status = 409; throw error;
|
|
36
|
+
}
|
|
37
|
+
const changes = (revision.changes || [])
|
|
38
|
+
.filter((change) => ['applied', 'reverted'].includes(change.status) && Number.isInteger(change.target?.start) && Number.isInteger(change.target?.end))
|
|
39
|
+
.sort((left, right) => right.target.start - left.target.start);
|
|
40
|
+
let source = original;
|
|
41
|
+
for (const change of changes) {
|
|
42
|
+
if (source.slice(change.target.start, change.target.end) !== change.before) {
|
|
43
|
+
const error = new Error(`Historical change ${change.id} does not match its recovery point`); error.status = 409; throw error;
|
|
44
|
+
}
|
|
45
|
+
source = source.slice(0, change.target.start) + change.after + source.slice(change.target.end);
|
|
46
|
+
}
|
|
47
|
+
if (hash(source) !== revision.recoveryPoint.appliedHash) {
|
|
48
|
+
const error = new Error('Historical version checksum failed'); error.status = 409; throw error;
|
|
49
|
+
}
|
|
50
|
+
return { revision, document, source };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getRecentChangeHistory(workspaceRoot, documentId, limit = 5) {
|
|
54
|
+
const project = await loadProject(workspaceRoot);
|
|
55
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
56
|
+
if (!document) {
|
|
57
|
+
const error = new Error('Document not found'); error.status = 404; throw error;
|
|
58
|
+
}
|
|
59
|
+
const safe = sanitizePath(document.file, workspaceRoot);
|
|
60
|
+
if (!safe) {
|
|
61
|
+
const error = new Error('Access denied'); error.status = 403; throw error;
|
|
62
|
+
}
|
|
63
|
+
const currentHash = hash(await readFile(safe, 'utf-8'));
|
|
64
|
+
const revisions = project.revisions
|
|
65
|
+
.filter((item) => item.documentId === documentId && item.recoveryPoint && ['applied', 'rolled-back'].includes(item.status))
|
|
66
|
+
.sort((left, right) => timestampOf(right).localeCompare(timestampOf(left)))
|
|
67
|
+
.slice(0, Math.max(1, Math.min(5, Number(limit) || 5)));
|
|
68
|
+
return Promise.all(revisions.map(async (revision, index) => {
|
|
69
|
+
const matchesCurrent = revision.status === 'applied' && revision.recoveryPoint.appliedHash === currentHash;
|
|
70
|
+
const changes = appliedRanges((revision.changes || []).filter((change) => ['applied', 'reverted'].includes(change.status)));
|
|
71
|
+
const { source } = await historicalRevisionSource(workspaceRoot, revision, document);
|
|
72
|
+
return {
|
|
73
|
+
id: revision.id, documentId: revision.documentId, file: revision.file || document.file,
|
|
74
|
+
title: revision.title, summary: revision.summary, origin: revision.origin || 'revision', status: revision.status,
|
|
75
|
+
createdAt: revision.createdAt, appliedAt: revision.appliedAt || '', rolledBackAt: revision.rolledBackAt || '',
|
|
76
|
+
changeCount: changes.length, isLatest: index === 0, matchesCurrent,
|
|
77
|
+
canRollback: matchesCurrent, canRestore: !matchesCurrent, previewAvailable: true,
|
|
78
|
+
changes: changes.map((change) => ({
|
|
79
|
+
id: change.id, before: change.before, after: change.after, reason: change.reason || '', target: change.target,
|
|
80
|
+
currentStart: matchesCurrent ? change.currentStart : null, currentEnd: matchesCurrent ? change.currentEnd : null,
|
|
81
|
+
type: change.before === '' ? 'added' : change.after === '' ? 'deleted' : 'modified',
|
|
82
|
+
contextBefore: source.slice(Math.max(0, change.currentStart - 240), change.currentStart),
|
|
83
|
+
contextAfter: source.slice(change.currentEnd, Math.min(source.length, change.currentEnd + 240)),
|
|
84
|
+
})),
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function getHistoricalRevisionSource(workspaceRoot, revisionId) {
|
|
90
|
+
const project = await loadProject(workspaceRoot);
|
|
91
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
92
|
+
if (!revision) {
|
|
93
|
+
const error = new Error('Change history entry not found'); error.status = 404; throw error;
|
|
94
|
+
}
|
|
95
|
+
const document = project.documents.find((item) => item.id === revision.documentId);
|
|
96
|
+
if (!document) {
|
|
97
|
+
const error = new Error('Document not found'); error.status = 404; throw error;
|
|
98
|
+
}
|
|
99
|
+
return historicalRevisionSource(workspaceRoot, revision, document);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getChangeHistoryEntry(workspaceRoot, revisionId) {
|
|
103
|
+
const project = await loadProject(workspaceRoot);
|
|
104
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
105
|
+
if (!revision) {
|
|
106
|
+
const error = new Error('Change history entry not found'); error.status = 404; throw error;
|
|
107
|
+
}
|
|
108
|
+
const history = await getRecentChangeHistory(workspaceRoot, revision.documentId, 5);
|
|
109
|
+
const entry = history.find((item) => item.id === revisionId);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
const error = new Error('Change history entry is outside the recent history window'); error.status = 404; throw error;
|
|
112
|
+
}
|
|
113
|
+
return entry;
|
|
114
|
+
}
|