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,300 @@
1
+ import { loadProject } from './project-store.js';
2
+ import { getNodeSourceContext, syncDocumentStructure } from './document-structure.js';
3
+ import { findStructureNode, isAbbreviationAt, sentenceEndIndex } from './latex-structure.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Descriptive statistics over sentence/paragraph lengths (word counts)
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const ANALYSIS_FORMULAS = [
10
+ { id: 'mean', name: 'Mean length', formula: 'μ = (1/n) · Σᵢ₌₁ⁿ xᵢ', description: 'Average sentence (or paragraph) length in words.' },
11
+ { id: 'stddev', name: 'Sample standard deviation', formula: 's = √( Σᵢ₌₁ⁿ (xᵢ − μ)² / (n − 1) )', description: 'Spread of lengths around the mean. n − 1 (Bessel) is used so s is an unbiased estimate of the population standard deviation.' },
12
+ { id: 'variance', name: 'Variance', formula: 's² = Σᵢ₌₁ⁿ (xᵢ − μ)² / (n − 1)', description: 'Squared standard deviation; mean squared deviation from the mean.' },
13
+ { id: 'median', name: 'Median', formula: 'median = middle value of sorted lengths (average of the two middle values when n is even)', description: 'Robust centre; half the units are shorter, half longer.' },
14
+ { id: 'range', name: 'Range', formula: 'R = max(x) − min(x)', description: 'Full spread between the longest and shortest unit.' },
15
+ { id: 'iqr', name: 'Interquartile range', formula: 'IQR = Q₃ − Q₁', description: 'Spread of the middle 50% of the data; robust to outliers.' },
16
+ { id: 'cv', name: 'Coefficient of variation', formula: 'CV = s / μ', description: 'Relative variation. Low CV means lengths cluster tightly around the mean — the signature of mechanically uniform text.' },
17
+ { id: 'delta', name: 'Adjacent change', formula: 'Δ = (1/(n−1)) · Σᵢ₌₁ⁿ⁻¹ |xᵢ₊₁ − xᵢ|', description: 'Average absolute jump between consecutive units; captures rhythm and variation from one sentence to the next.' },
18
+ { id: 'relativeDelta', name: 'Relative adjacent change', formula: 'Δ / μ', description: 'Adjacent change normalised by the mean, comparable across texts of different scales.' },
19
+ { id: 'variationIndex', name: 'Variation index', formula: 'VI = 100 · ( 0.6 · min(1, CV/0.5) + 0.4 · min(1, (Δ/μ)/0.8) )', description: '0–100 composite of CV and relative adjacent change. Higher values mean more rhythmic variation (organic prose); low values mean uniform, template-like writing.' },
20
+ ];
21
+
22
+ function cleanText(value) {
23
+ return String(value || '').replace(/\s+/g, ' ').trim();
24
+ }
25
+
26
+ // Strip LaTeX markup before counting words and splitting sentences so that
27
+ // commands and math do not inflate sentence lengths or break sentence pauses.
28
+ function cleanLatexForText(value) {
29
+ return cleanText(value)
30
+ .replace(/(?<!\\)%.*$/gm, ' ')
31
+ .replace(/\\(?:cite|ref|label|footnote|emph|textbf|textit|texttt)\*?(?:\[[^\]]*\])?\{([^{}]*)\}/g, '$1')
32
+ .replace(/\\[a-zA-Z@]+\*?(?:\[[^\]]*\])?/g, ' ')
33
+ .replace(/[{}]/g, ' ')
34
+ .replace(/\$+[^$]*\$+/g, ' equation ');
35
+ }
36
+
37
+ export function wordCount(text) {
38
+ const cleaned = cleanLatexForText(text);
39
+ return cleaned ? cleaned.split(/\s+/).filter(Boolean).length : 0;
40
+ }
41
+
42
+ export function splitSentences(text) {
43
+ const source = cleanLatexForText(text);
44
+ if (!source) return [];
45
+ const sentences = [];
46
+ let sentenceStart = 0;
47
+ for (let cursor = 0; cursor < source.length; cursor += 1) {
48
+ const character = source[cursor];
49
+ if (!'.?!'.includes(character)) continue;
50
+ const previous = source[cursor - 1];
51
+ const boundary = sentenceEndIndex(source, cursor);
52
+ if (boundary === -1) continue; // glued to following word/command
53
+ let look = boundary;
54
+ while (look < source.length && /\s/.test(source[look])) look += 1;
55
+ const nextNonSpace = source[look];
56
+ if (/\d/.test(previous || '') && /\d/.test(nextNonSpace || '')) continue; // decimal
57
+ if (character === '.' && isAbbreviationAt(source, cursor)) continue; // e.g. i.e. cf.
58
+ if (look < source.length && /[a-z]/.test(nextNonSpace || '')) continue; // embedded quote continues
59
+ const sentence = cleanText(source.slice(sentenceStart, boundary));
60
+ if (sentence) sentences.push(sentence);
61
+ sentenceStart = boundary;
62
+ cursor = boundary - 1;
63
+ }
64
+ const tail = cleanText(source.slice(sentenceStart));
65
+ if (tail) sentences.push(tail);
66
+ return sentences;
67
+ }
68
+
69
+ function round(value, digits = 2) {
70
+ if (!Number.isFinite(value)) return 0;
71
+ const factor = 10 ** digits;
72
+ return Math.round(value * factor) / factor;
73
+ }
74
+
75
+ function mean(values) {
76
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
77
+ }
78
+
79
+ function sampleStddev(values) {
80
+ if (values.length < 2) return 0;
81
+ const mu = mean(values);
82
+ return Math.sqrt(values.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (values.length - 1));
83
+ }
84
+
85
+ function median(sorted) {
86
+ const n = sorted.length;
87
+ if (!n) return 0;
88
+ const mid = Math.floor(n / 2);
89
+ return n % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
90
+ }
91
+
92
+ function adjacentDelta(values) {
93
+ if (values.length < 2) return 0;
94
+ let sum = 0;
95
+ for (let index = 1; index < values.length; index += 1) sum += Math.abs(values[index] - values[index - 1]);
96
+ return sum / (values.length - 1);
97
+ }
98
+
99
+ export function analyzeLengths(values) {
100
+ const n = values.length;
101
+ if (!n) return { count: 0 };
102
+ const sorted = [...values].sort((a, b) => a - b);
103
+ const mu = mean(values);
104
+ const s = sampleStddev(values);
105
+ const delta = adjacentDelta(values);
106
+ const lowerHalf = sorted.slice(0, Math.floor(sorted.length / 2));
107
+ const upperHalf = sorted.slice(Math.ceil(sorted.length / 2));
108
+ return {
109
+ count: n,
110
+ mean: round(mu),
111
+ stddev: round(s),
112
+ variance: round(s * s),
113
+ min: sorted[0],
114
+ max: sorted[n - 1],
115
+ range: round(sorted[n - 1] - sorted[0]),
116
+ median: round(median(sorted)),
117
+ iqr: round(median(upperHalf) - median(lowerHalf)),
118
+ cv: round(mu ? s / mu : 0),
119
+ delta: round(delta),
120
+ relativeDelta: round(mu ? delta / mu : 0),
121
+ };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Variation index (0-100) and the mechanical-writing verdict
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export function variationIndex(sentenceStats) {
129
+ const cvPart = Math.min(1, (sentenceStats.cv || 0) / 0.5);
130
+ const deltaPart = Math.min(1, (sentenceStats.relativeDelta || 0) / 0.8);
131
+ return {
132
+ score: Math.round((0.6 * cvPart + 0.4 * deltaPart) * 100),
133
+ cvPart: round(cvPart, 3),
134
+ deltaPart: round(deltaPart, 3),
135
+ };
136
+ }
137
+
138
+ export function mechanicalVerdict(score) {
139
+ // score is the variation index (0-100): LOW = uniform/template-like, HIGH = organic.
140
+ if (score >= 70) {
141
+ return {
142
+ label: 'highly-varied', title: 'Highly varied rhythm',
143
+ note: 'Sentence lengths change sharply and irregularly throughout. Strong rhythmic variation is typical of organic, edited prose.',
144
+ };
145
+ }
146
+ if (score >= 45) {
147
+ return {
148
+ label: 'varied', title: 'Varied rhythm',
149
+ note: 'Sentence lengths fluctuate noticeably, with meaningful jumps between adjacent sentences. This resembles natural human drafting.',
150
+ };
151
+ }
152
+ if (score >= 25) {
153
+ return {
154
+ label: 'uniform', title: 'Uniform rhythm',
155
+ note: 'Sentence lengths cluster tightly around the mean. The text reads steadily; consider whether that evenness matches an intentional style or a mechanical template.',
156
+ };
157
+ }
158
+ return {
159
+ label: 'highly-uniform', title: 'Highly uniform rhythm',
160
+ note: 'Sentence lengths barely vary and rarely jump between adjacent sentences. This is the pattern typical of template-generated text — but uniformity alone is not proof of AI writing; short, technical passages are naturally even.',
161
+ };
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Analysis units
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function analyzeTextUnit(text, { id = '', label = '' } = {}) {
169
+ const sentences = splitSentences(text);
170
+ const sentenceLengths = sentences.map((sentence, index) => ({
171
+ index, text: sentence, wordCount: wordCount(sentence), charCount: sentence.length,
172
+ }));
173
+ const stats = analyzeLengths(sentenceLengths.map((sentence) => sentence.wordCount));
174
+ const variation = variationIndex(stats);
175
+ return {
176
+ unit: { id, label, text: cleanText(text), sentenceCount: sentences.length, wordCount: wordCount(text) },
177
+ sentences: sentenceLengths,
178
+ stats,
179
+ variation,
180
+ verdict: mechanicalVerdict(variation.score),
181
+ formulas: ANALYSIS_FORMULAS,
182
+ };
183
+ }
184
+
185
+ function flattenParagraphs(sections) {
186
+ const result = [];
187
+ const visit = (nodes, section) => {
188
+ for (const node of nodes || []) {
189
+ if (node.type === 'paragraph') result.push({ node, section });
190
+ visit(node.children, section);
191
+ }
192
+ };
193
+ for (const section of sections || []) visit([section], section);
194
+ return result;
195
+ }
196
+
197
+ function summarizeUnit(analysis) {
198
+ return {
199
+ id: analysis.unit.id,
200
+ label: analysis.unit.label,
201
+ text: analysis.unit.text,
202
+ sentenceCount: analysis.unit.sentenceCount,
203
+ wordCount: analysis.unit.wordCount,
204
+ stats: analysis.stats,
205
+ variation: analysis.variation,
206
+ verdict: analysis.verdict,
207
+ };
208
+ }
209
+
210
+ function analyzeDocumentStructure(document) {
211
+ const paragraphs = flattenParagraphs(document.sections);
212
+ const paragraphAnalyses = paragraphs.map(({ node, section }) => ({
213
+ ...summarizeUnit(analyzeTextUnit(node.text, { id: node.id, label: node.summary || `Paragraph in ${section?.title || 'document'}` })),
214
+ section: section?.title || '',
215
+ }));
216
+ const allSentenceLengths = paragraphAnalyses.flatMap((paragraph) => {
217
+ const sentences = splitSentences(paragraph.text);
218
+ return sentences.map((sentence) => wordCount(sentence));
219
+ });
220
+ const paragraphLengths = paragraphAnalyses.map((paragraph) => paragraph.wordCount);
221
+ const paragraphSentenceCounts = paragraphAnalyses.map((paragraph) => paragraph.sentenceCount);
222
+ const sentenceStats = analyzeLengths(allSentenceLengths);
223
+ const paragraphStats = analyzeLengths(paragraphLengths);
224
+ const variation = variationIndex(sentenceStats);
225
+ return {
226
+ unit: {
227
+ id: document.id,
228
+ label: document.title || document.file,
229
+ text: '',
230
+ paragraphCount: paragraphAnalyses.length,
231
+ sentenceCount: allSentenceLengths.length,
232
+ wordCount: paragraphLengths.reduce((sum, value) => sum + value, 0),
233
+ },
234
+ paragraphs: paragraphAnalyses,
235
+ sentenceStats,
236
+ paragraphStats,
237
+ paragraphSentenceStats: analyzeLengths(paragraphSentenceCounts),
238
+ variation,
239
+ verdict: mechanicalVerdict(variation.score),
240
+ formulas: ANALYSIS_FORMULAS,
241
+ };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Public API entry
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function error(message, status = 400) {
249
+ const value = new Error(message);
250
+ value.status = status;
251
+ return value;
252
+ }
253
+
254
+ export async function analyzeStructure(workspaceRoot, { documentId, nodeId, content } = {}) {
255
+ if (typeof content === 'string' && content.trim()) {
256
+ return { kind: 'selection', ...analyzeTextUnit(content, { label: 'Selection' }) };
257
+ }
258
+ let project = await loadProject(workspaceRoot);
259
+ let document = project.documents.find((item) => item.id === documentId);
260
+ if (!document) {
261
+ document = project.documents[0];
262
+ if (!document) throw error('No document found in this workspace', 404);
263
+ }
264
+ await syncDocumentStructure(workspaceRoot, document.file);
265
+ project = await loadProject(workspaceRoot);
266
+ document = project.documents.find((item) => item.id === document.id);
267
+
268
+ if (typeof nodeId === 'string' && nodeId) {
269
+ const context = await getNodeSourceContext(workspaceRoot, nodeId);
270
+ if (context.node.type === 'sentence') {
271
+ const paragraph = context.node.parentId ? findStructureNode(context.document, context.node.parentId) : null;
272
+ if (paragraph?.type === 'paragraph') {
273
+ return { kind: 'paragraph', section: context.section?.title || '', ...analyzeTextUnit(paragraph.text, { id: paragraph.id, label: paragraph.summary || 'Paragraph' }) };
274
+ }
275
+ return { kind: 'sentence', section: context.section?.title || '', ...analyzeTextUnit(context.node.text, { id: context.node.id, label: 'Sentence' }) };
276
+ }
277
+ if (context.node.type === 'paragraph') {
278
+ return { kind: 'paragraph', section: context.section?.title || '', ...analyzeTextUnit(context.node.text, { id: context.node.id, label: context.node.summary || 'Paragraph' }) };
279
+ }
280
+ if (context.node.type === 'section') {
281
+ const paragraphs = flattenParagraphs([context.node]);
282
+ const paragraphAnalyses = paragraphs.map(({ node }) => summarizeUnit(analyzeTextUnit(node.text, { id: node.id, label: node.summary || `Paragraph in ${context.node.title}` })));
283
+ const sentenceStats = analyzeLengths(paragraphAnalyses.flatMap((paragraph) => splitSentences(paragraph.text).map((sentence) => wordCount(sentence))));
284
+ const paragraphStats = analyzeLengths(paragraphAnalyses.map((paragraph) => paragraph.wordCount));
285
+ const variation = variationIndex(sentenceStats);
286
+ return {
287
+ kind: 'section',
288
+ section: context.node.title,
289
+ unit: { id: context.node.id, label: context.node.title, paragraphCount: paragraphAnalyses.length, sentenceCount: sentenceStats.count, wordCount: paragraphStats.count ? paragraphStats.mean * paragraphStats.count : 0 },
290
+ paragraphs: paragraphAnalyses,
291
+ sentenceStats,
292
+ paragraphStats,
293
+ variation,
294
+ verdict: mechanicalVerdict(variation.score),
295
+ formulas: ANALYSIS_FORMULAS,
296
+ };
297
+ }
298
+ }
299
+ return { kind: 'document', ...analyzeDocumentStructure(document) };
300
+ }
@@ -0,0 +1,290 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { loadProject, updateProject } from './project-store.js';
3
+
4
+ function timestamp() {
5
+ return new Date().toISOString();
6
+ }
7
+
8
+ function id(prefix) {
9
+ return `${prefix}_${randomUUID()}`;
10
+ }
11
+
12
+ function text(value, fallback = '') {
13
+ return typeof value === 'string' ? value : fallback;
14
+ }
15
+
16
+ function strings(value) {
17
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim()) : [];
18
+ }
19
+
20
+ function resourceError(message, status = 400) {
21
+ const error = new Error(message);
22
+ error.status = status;
23
+ return error;
24
+ }
25
+
26
+ const libraryKinds = {
27
+ corpora: {
28
+ prefix: 'corpus',
29
+ collection: (project) => project.libraries.corpora,
30
+ build(input, existing) {
31
+ const time = timestamp();
32
+ return {
33
+ id: existing?.id || id('corpus'),
34
+ name: text(input.name),
35
+ description: text(input.description),
36
+ content: text(input.content),
37
+ source: text(input.source),
38
+ tags: strings(input.tags),
39
+ createdAt: existing?.createdAt || time,
40
+ updatedAt: time,
41
+ };
42
+ },
43
+ },
44
+ 'sentence-patterns': {
45
+ prefix: 'pattern',
46
+ collection: (project) => project.libraries.sentencePatterns,
47
+ build(input, existing) {
48
+ const time = timestamp();
49
+ return {
50
+ id: existing?.id || id('pattern'),
51
+ name: text(input.name),
52
+ template: text(input.template),
53
+ description: text(input.description),
54
+ source: text(input.source),
55
+ tags: strings(input.tags),
56
+ sectionTypes: strings(input.sectionTypes),
57
+ slots: Array.isArray(input.slots) ? input.slots.map((slot) => ({
58
+ name: text(slot?.name),
59
+ description: text(slot?.description),
60
+ required: slot?.required !== false,
61
+ })) : [],
62
+ createdAt: existing?.createdAt || time,
63
+ updatedAt: time,
64
+ };
65
+ },
66
+ },
67
+ };
68
+
69
+ function vocabularyConfig(scope) {
70
+ if (!['global', 'session'].includes(scope)) throw resourceError('Vocabulary scope must be global or session');
71
+ return {
72
+ collection: (project) => project.libraries.vocabulary[scope],
73
+ build(input, existing) {
74
+ const time = timestamp();
75
+ return {
76
+ id: existing?.id || id('vocabulary'),
77
+ term: text(input.term),
78
+ preferred: text(input.preferred),
79
+ definition: text(input.definition),
80
+ source: text(input.source),
81
+ alternatives: strings(input.alternatives),
82
+ examples: strings(input.examples),
83
+ tags: strings(input.tags),
84
+ createdAt: existing?.createdAt || time,
85
+ updatedAt: time,
86
+ };
87
+ },
88
+ };
89
+ }
90
+
91
+ function libraryConfig(kind, scope) {
92
+ if (kind === 'vocabulary') return vocabularyConfig(scope);
93
+ const config = libraryKinds[kind];
94
+ if (!config) throw resourceError('Unknown library kind', 404);
95
+ return config;
96
+ }
97
+
98
+ export async function getLibraries(workspaceRoot) {
99
+ return (await loadProject(workspaceRoot)).libraries;
100
+ }
101
+
102
+ export async function createLibraryResource(workspaceRoot, kind, scope, input = {}) {
103
+ const config = libraryConfig(kind, scope);
104
+ const item = config.build(input);
105
+ await updateProject(workspaceRoot, (project) => config.collection(project).push(item));
106
+ return item;
107
+ }
108
+
109
+ export async function updateLibraryResource(workspaceRoot, kind, scope, resourceId, input = {}) {
110
+ const config = libraryConfig(kind, scope);
111
+ const { result } = await updateProject(workspaceRoot, (project) => {
112
+ const collection = config.collection(project);
113
+ const index = collection.findIndex((item) => item.id === resourceId);
114
+ if (index === -1) throw resourceError('Library resource not found', 404);
115
+ const updated = config.build({ ...collection[index], ...input }, collection[index]);
116
+ collection[index] = updated;
117
+ return updated;
118
+ });
119
+ return result;
120
+ }
121
+
122
+ export async function deleteLibraryResource(workspaceRoot, kind, scope, resourceId) {
123
+ const config = libraryConfig(kind, scope);
124
+ await updateProject(workspaceRoot, (project) => {
125
+ const collection = config.collection(project);
126
+ const index = collection.findIndex((item) => item.id === resourceId);
127
+ if (index === -1) throw resourceError('Library resource not found', 404);
128
+ collection.splice(index, 1);
129
+ });
130
+ }
131
+
132
+ function buildTarget(input = {}) {
133
+ return {
134
+ type: text(input.type, 'document'),
135
+ id: text(input.id),
136
+ start: Number.isInteger(input.start) ? input.start : 0,
137
+ end: Number.isInteger(input.end) ? input.end : 0,
138
+ quote: text(input.quote),
139
+ };
140
+ }
141
+
142
+ function buildAnnotation(input, existing) {
143
+ const time = timestamp();
144
+ return {
145
+ id: existing?.id || id('annotation'),
146
+ documentId: text(input.documentId),
147
+ target: buildTarget(input.target),
148
+ category: text(input.category, 'other'),
149
+ severity: text(input.severity, 'info'),
150
+ body: text(input.body),
151
+ suggestedFix: text(input.suggestedFix),
152
+ status: text(input.status, 'open'),
153
+ order: Number.isInteger(input.order) ? input.order : existing?.order,
154
+ dependsOn: strings(input.dependsOn),
155
+ source: {
156
+ type: text(input.source?.type, 'user'),
157
+ actor: text(input.source?.actor),
158
+ },
159
+ createdAt: existing?.createdAt || time,
160
+ updatedAt: time,
161
+ };
162
+ }
163
+
164
+ export async function listAnnotations(workspaceRoot, documentId) {
165
+ const annotations = (await loadProject(workspaceRoot)).annotations;
166
+ return documentId ? annotations.filter((item) => item.documentId === documentId) : annotations;
167
+ }
168
+
169
+ export async function createAnnotation(workspaceRoot, input = {}) {
170
+ const item = buildAnnotation(input);
171
+ await updateProject(workspaceRoot, (project) => project.annotations.push(item));
172
+ return item;
173
+ }
174
+
175
+ export async function updateAnnotation(workspaceRoot, annotationId, input = {}) {
176
+ const { result } = await updateProject(workspaceRoot, (project) => {
177
+ const index = project.annotations.findIndex((item) => item.id === annotationId);
178
+ if (index === -1) throw resourceError('Annotation not found', 404);
179
+ const existing = project.annotations[index];
180
+ const updated = buildAnnotation({
181
+ ...existing,
182
+ ...input,
183
+ target: { ...existing.target, ...input.target },
184
+ source: { ...existing.source, ...input.source },
185
+ }, existing);
186
+ project.annotations[index] = updated;
187
+ return updated;
188
+ });
189
+ return result;
190
+ }
191
+
192
+ export async function deleteAnnotation(workspaceRoot, annotationId) {
193
+ await updateProject(workspaceRoot, (project) => {
194
+ const index = project.annotations.findIndex((item) => item.id === annotationId);
195
+ if (index === -1) throw resourceError('Annotation not found', 404);
196
+ project.annotations.splice(index, 1);
197
+ });
198
+ }
199
+
200
+ function buildRevision(input, existing) {
201
+ const time = timestamp();
202
+ return {
203
+ id: existing?.id || id('revision'),
204
+ documentId: text(input.documentId),
205
+ title: text(input.title),
206
+ summary: text(input.summary),
207
+ status: text(input.status, 'draft'),
208
+ annotationIds: strings(input.annotationIds),
209
+ changes: Array.isArray(input.changes) ? input.changes.map((change) => ({
210
+ id: text(change?.id) || id('change'),
211
+ target: buildTarget(change?.target),
212
+ before: text(change?.before),
213
+ after: text(change?.after),
214
+ reason: text(change?.reason),
215
+ status: text(change?.status, 'proposed'),
216
+ })) : [],
217
+ createdAt: existing?.createdAt || time,
218
+ updatedAt: time,
219
+ };
220
+ }
221
+
222
+ export async function listRevisions(workspaceRoot, documentId) {
223
+ const revisions = (await loadProject(workspaceRoot)).revisions;
224
+ return documentId ? revisions.filter((item) => item.documentId === documentId) : revisions;
225
+ }
226
+
227
+ export async function createRevision(workspaceRoot, input = {}) {
228
+ const item = buildRevision(input);
229
+ await updateProject(workspaceRoot, (project) => project.revisions.push(item));
230
+ return item;
231
+ }
232
+
233
+ export async function updateRevision(workspaceRoot, revisionId, input = {}) {
234
+ const { result } = await updateProject(workspaceRoot, (project) => {
235
+ const index = project.revisions.findIndex((item) => item.id === revisionId);
236
+ if (index === -1) throw resourceError('Revision not found', 404);
237
+ const updated = buildRevision({ ...project.revisions[index], ...input }, project.revisions[index]);
238
+ project.revisions[index] = updated;
239
+ return updated;
240
+ });
241
+ return result;
242
+ }
243
+
244
+ export async function deleteRevision(workspaceRoot, revisionId) {
245
+ await updateProject(workspaceRoot, (project) => {
246
+ const index = project.revisions.findIndex((item) => item.id === revisionId);
247
+ if (index === -1) throw resourceError('Revision not found', 404);
248
+ project.revisions.splice(index, 1);
249
+ });
250
+ }
251
+
252
+ function buildAgentRun(input, existing) {
253
+ const time = timestamp();
254
+ return {
255
+ id: existing?.id || id('agent_run'),
256
+ provider: text(input.provider, 'mock'),
257
+ operation: text(input.operation),
258
+ status: text(input.status, 'queued'),
259
+ prompt: text(input.prompt),
260
+ input: text(input.input),
261
+ output: text(input.output),
262
+ error: text(input.error),
263
+ startedAt: text(input.startedAt),
264
+ finishedAt: text(input.finishedAt),
265
+ createdAt: existing?.createdAt || time,
266
+ updatedAt: time,
267
+ };
268
+ }
269
+
270
+ export async function createAgentRun(workspaceRoot, input = {}) {
271
+ const item = buildAgentRun(input);
272
+ await updateProject(workspaceRoot, (project) => project.agentRuns.push(item));
273
+ return item;
274
+ }
275
+
276
+ export async function updateAgentRun(workspaceRoot, runId, input = {}) {
277
+ const { result } = await updateProject(workspaceRoot, (project) => {
278
+ const index = project.agentRuns.findIndex((item) => item.id === runId);
279
+ if (index === -1) throw resourceError('Agent run not found', 404);
280
+ const existing = project.agentRuns[index];
281
+ const updated = buildAgentRun({ ...existing, ...input }, existing);
282
+ project.agentRuns[index] = updated;
283
+ return updated;
284
+ });
285
+ return result;
286
+ }
287
+
288
+ export async function listAgentRuns(workspaceRoot) {
289
+ return (await loadProject(workspaceRoot)).agentRuns;
290
+ }