openwriter 0.7.0 → 0.8.1
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/dist/client/assets/index-D0TNu7yx.js +210 -0
- package/dist/client/assets/index-Dze14Bgb.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/x-api/dist/index.js +4 -2
- package/dist/plugins/x-api/package.json +2 -1
- package/dist/server/compact.js +39 -1
- package/dist/server/documents.js +23 -0
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +330 -207
- package/dist/server/state.js +74 -18
- package/dist/server/task-routes.js +38 -0
- package/dist/server/tasks.js +52 -0
- package/dist/server/ws.js +12 -2
- package/package.json +4 -2
- package/skill/SKILL.md +27 -6
- package/dist/client/assets/index-BhlEJsdX.css +0 -1
- package/dist/client/assets/index-XajWsVLO.js +0 -210
package/dist/server/mcp.js
CHANGED
|
@@ -4,26 +4,25 @@
|
|
|
4
4
|
* Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
|
|
5
5
|
*/
|
|
6
6
|
import { join } from 'path';
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'fs';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId } from './helpers.js';
|
|
13
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
|
|
12
|
+
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
|
|
13
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
|
|
14
14
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
|
|
15
|
-
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
16
|
-
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer } from './workspaces.js';
|
|
17
|
-
import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
|
|
15
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
|
|
16
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
18
17
|
import { findDocNode } from './workspace-tree.js';
|
|
19
18
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
20
19
|
import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
|
|
21
20
|
import matter from 'gray-matter';
|
|
22
21
|
import { getUpdateInfo } from './update-check.js';
|
|
23
22
|
import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
|
|
24
|
-
import { markdownToTiptap } from './markdown.js';
|
|
23
|
+
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
25
24
|
import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
|
|
26
|
-
import {
|
|
25
|
+
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
27
26
|
/** Map a content type string to its frontmatter metadata object. */
|
|
28
27
|
function resolveTypeMeta(type) {
|
|
29
28
|
switch (type) {
|
|
@@ -55,17 +54,76 @@ function isTweetDoc(filename) {
|
|
|
55
54
|
return false;
|
|
56
55
|
}
|
|
57
56
|
}
|
|
57
|
+
/** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
|
|
58
|
+
function resolveDocTarget(docId) {
|
|
59
|
+
const filename = resolveDocId(docId);
|
|
60
|
+
const activeFilename = getActiveFilename();
|
|
61
|
+
// Fast path: active document — use in-memory state
|
|
62
|
+
if (filename === activeFilename) {
|
|
63
|
+
return {
|
|
64
|
+
filename,
|
|
65
|
+
filePath: getFilePath(),
|
|
66
|
+
docId,
|
|
67
|
+
isActive: true,
|
|
68
|
+
document: getDocument(),
|
|
69
|
+
title: getTitle(),
|
|
70
|
+
metadata: getMetadata(),
|
|
71
|
+
wordCount: getWordCount(),
|
|
72
|
+
pendingCount: getPendingChangeCount(),
|
|
73
|
+
lastModified: new Date(getStatus().lastModified),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Non-active: try cache, then disk
|
|
77
|
+
const filePath = resolveDocPath(filename);
|
|
78
|
+
const cached = getCachedDocument(filePath);
|
|
79
|
+
if (cached) {
|
|
80
|
+
const text = extractText(cached.document.content);
|
|
81
|
+
return {
|
|
82
|
+
filename,
|
|
83
|
+
filePath,
|
|
84
|
+
docId: cached.docId,
|
|
85
|
+
isActive: false,
|
|
86
|
+
document: cached.document,
|
|
87
|
+
title: cached.title,
|
|
88
|
+
metadata: cached.metadata,
|
|
89
|
+
wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
|
|
90
|
+
pendingCount: countPending(cached.document.content),
|
|
91
|
+
lastModified: cached.lastModified,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Read from disk
|
|
95
|
+
if (!existsSync(filePath))
|
|
96
|
+
throw new Error(`Document file not found: ${filename}`);
|
|
97
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
98
|
+
const parsed = markdownToTiptap(raw);
|
|
99
|
+
const meta = parsed.metadata || {};
|
|
100
|
+
const resolvedDocId = meta.docId || docId;
|
|
101
|
+
const text = extractText(parsed.document.content);
|
|
102
|
+
return {
|
|
103
|
+
filename,
|
|
104
|
+
filePath,
|
|
105
|
+
docId: resolvedDocId,
|
|
106
|
+
isActive: false,
|
|
107
|
+
document: parsed.document,
|
|
108
|
+
title: parsed.title,
|
|
109
|
+
metadata: parsed.metadata || {},
|
|
110
|
+
wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
|
|
111
|
+
pendingCount: countPending(parsed.document.content),
|
|
112
|
+
lastModified: statSync(filePath).mtime,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
58
115
|
export const TOOL_REGISTRY = [
|
|
59
116
|
{
|
|
60
117
|
name: 'read_pad',
|
|
61
|
-
description: 'Read
|
|
62
|
-
schema: {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
118
|
+
description: 'Read a document by docId. Returns compact tagged-line format with [type:id] per node, inline markdown formatting. Much more token-efficient than JSON.',
|
|
119
|
+
schema: {
|
|
120
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
121
|
+
},
|
|
122
|
+
handler: async ({ docId }) => {
|
|
123
|
+
const target = resolveDocTarget(docId);
|
|
124
|
+
const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
|
|
125
|
+
const localCount = getMarkCount(target.filename);
|
|
126
|
+
const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(target.filename);
|
|
69
127
|
let hint = '';
|
|
70
128
|
if (localCount > 0)
|
|
71
129
|
hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
|
|
@@ -101,6 +159,20 @@ export const TOOL_REGISTRY = [
|
|
|
101
159
|
}
|
|
102
160
|
return resolved;
|
|
103
161
|
});
|
|
162
|
+
// Auto-clean: if doc has only a single empty paragraph and first change is
|
|
163
|
+
// an insert, convert to a rewrite so the empty node gets replaced silently
|
|
164
|
+
// (shows as green insert decoration, not a red delete).
|
|
165
|
+
const activeDoc = getDocument();
|
|
166
|
+
if (activeDoc.content?.length === 1) {
|
|
167
|
+
const first = activeDoc.content[0];
|
|
168
|
+
if (first.type === 'paragraph' && (!first.content || first.content.length === 0) && first.attrs?.id) {
|
|
169
|
+
const insertIdx = processed.findIndex((c) => c.operation === 'insert');
|
|
170
|
+
if (insertIdx !== -1) {
|
|
171
|
+
processed[insertIdx] = { ...processed[insertIdx], operation: 'rewrite', nodeId: first.attrs.id };
|
|
172
|
+
delete processed[insertIdx].afterNodeId;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
104
176
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
105
177
|
if (targetIsNonActive) {
|
|
106
178
|
const { count: appliedCount, lastNodeId } = applyChangesToFile(filename, processed);
|
|
@@ -134,10 +206,18 @@ export const TOOL_REGISTRY = [
|
|
|
134
206
|
},
|
|
135
207
|
{
|
|
136
208
|
name: 'get_pad_status',
|
|
137
|
-
description: 'Get the
|
|
138
|
-
schema: {
|
|
139
|
-
|
|
140
|
-
|
|
209
|
+
description: 'Get the status of a document: word count, pending changes. Cheap call for polling.',
|
|
210
|
+
schema: {
|
|
211
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
212
|
+
},
|
|
213
|
+
handler: async ({ docId }) => {
|
|
214
|
+
const target = resolveDocTarget(docId);
|
|
215
|
+
const status = {
|
|
216
|
+
title: target.title,
|
|
217
|
+
wordCount: target.wordCount,
|
|
218
|
+
pendingChanges: target.pendingCount,
|
|
219
|
+
lastModified: target.lastModified.toISOString(),
|
|
220
|
+
};
|
|
141
221
|
const latestVersion = getUpdateInfo();
|
|
142
222
|
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
143
223
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
@@ -147,10 +227,13 @@ export const TOOL_REGISTRY = [
|
|
|
147
227
|
name: 'get_nodes',
|
|
148
228
|
description: 'Get specific nodes by ID. Returns compact tagged-line format per node.',
|
|
149
229
|
schema: {
|
|
230
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
150
231
|
nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
|
|
151
232
|
},
|
|
152
|
-
handler: async ({ nodeIds }) => {
|
|
153
|
-
|
|
233
|
+
handler: async ({ docId, nodeIds }) => {
|
|
234
|
+
const target = resolveDocTarget(docId);
|
|
235
|
+
const nodes = target.isActive ? getNodesByIds(nodeIds) : findNodesByIds(target.document.content, nodeIds);
|
|
236
|
+
return { content: [{ type: 'text', text: compactNodes(nodes) }] };
|
|
154
237
|
},
|
|
155
238
|
},
|
|
156
239
|
{
|
|
@@ -170,7 +253,7 @@ export const TOOL_REGISTRY = [
|
|
|
170
253
|
},
|
|
171
254
|
{
|
|
172
255
|
name: 'switch_document',
|
|
173
|
-
description: '
|
|
256
|
+
description: 'Show a document in the user\'s browser. NOT required before reading or editing — all tools target documents by docId directly. Use only when you want to change what the user sees. Saves the current document first. Returns a compact read of the newly active document.',
|
|
174
257
|
schema: {
|
|
175
258
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
176
259
|
},
|
|
@@ -364,10 +447,12 @@ export const TOOL_REGISTRY = [
|
|
|
364
447
|
handler: async ({ docId }) => {
|
|
365
448
|
const filename = resolveDocId(docId);
|
|
366
449
|
const result = await deleteDocument(filename);
|
|
450
|
+
removeDocFromAllWorkspaces(filename);
|
|
367
451
|
if (result.switched && result.newDoc) {
|
|
368
452
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
369
453
|
}
|
|
370
454
|
broadcastDocumentsChanged();
|
|
455
|
+
broadcastWorkspacesChanged();
|
|
371
456
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
372
457
|
if (result.switched && result.newDoc) {
|
|
373
458
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -384,10 +469,12 @@ export const TOOL_REGISTRY = [
|
|
|
384
469
|
handler: async ({ docId }) => {
|
|
385
470
|
const filename = resolveDocId(docId);
|
|
386
471
|
const result = archiveDocument(filename);
|
|
472
|
+
removeDocFromAllWorkspaces(filename);
|
|
387
473
|
if (result.switched && result.newDoc) {
|
|
388
474
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
389
475
|
}
|
|
390
476
|
broadcastDocumentsChanged();
|
|
477
|
+
broadcastWorkspacesChanged();
|
|
391
478
|
let text = `Archived "${filename}"`;
|
|
392
479
|
if (result.switched && result.newDoc) {
|
|
393
480
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -410,20 +497,24 @@ export const TOOL_REGISTRY = [
|
|
|
410
497
|
},
|
|
411
498
|
{
|
|
412
499
|
name: 'get_metadata',
|
|
413
|
-
description: 'Get the JSON frontmatter metadata for
|
|
414
|
-
schema: {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
500
|
+
description: 'Get the JSON frontmatter metadata for a document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
|
|
501
|
+
schema: {
|
|
502
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
503
|
+
},
|
|
504
|
+
handler: async ({ docId }) => {
|
|
505
|
+
const target = resolveDocTarget(docId);
|
|
506
|
+
return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
|
|
418
507
|
},
|
|
419
508
|
},
|
|
420
509
|
{
|
|
421
510
|
name: 'set_metadata',
|
|
422
|
-
description: 'Update frontmatter metadata on
|
|
511
|
+
description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
|
|
423
512
|
schema: {
|
|
513
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
424
514
|
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
425
515
|
},
|
|
426
|
-
handler: async ({ metadata: updates }) => {
|
|
516
|
+
handler: async ({ docId, metadata: updates }) => {
|
|
517
|
+
const target = resolveDocTarget(docId);
|
|
427
518
|
const setKeys = [];
|
|
428
519
|
const removed = [];
|
|
429
520
|
for (const [key, value] of Object.entries(updates)) {
|
|
@@ -437,20 +528,41 @@ export const TOOL_REGISTRY = [
|
|
|
437
528
|
const cleaned = {};
|
|
438
529
|
for (const key of setKeys)
|
|
439
530
|
cleaned[key] = updates[key];
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
531
|
+
if (target.isActive) {
|
|
532
|
+
// Active doc: use in-memory path
|
|
533
|
+
if (Object.keys(cleaned).length > 0)
|
|
534
|
+
setMetadata(cleaned);
|
|
535
|
+
const meta = getMetadata();
|
|
536
|
+
for (const key of removed)
|
|
537
|
+
delete meta[key];
|
|
538
|
+
save();
|
|
539
|
+
broadcastMetadataChanged(getMetadata());
|
|
540
|
+
if (cleaned.title) {
|
|
541
|
+
const promoted = promoteTempFile(cleaned.title);
|
|
542
|
+
broadcastTitleChanged(cleaned.title);
|
|
543
|
+
broadcastDocumentsChanged();
|
|
544
|
+
if (promoted) {
|
|
545
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
// Non-active doc: read → merge → write file
|
|
551
|
+
let meta = { ...target.metadata };
|
|
552
|
+
if (Object.keys(cleaned).length > 0) {
|
|
553
|
+
const merged = mergeMetadataUpdates(meta, cleaned);
|
|
554
|
+
if (merged)
|
|
555
|
+
meta = merged;
|
|
556
|
+
}
|
|
557
|
+
for (const key of removed)
|
|
558
|
+
delete meta[key];
|
|
559
|
+
const newTitle = cleaned.title || meta.title || target.title;
|
|
560
|
+
const markdown = tiptapToMarkdown(target.document, newTitle, meta);
|
|
561
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
562
|
+
invalidateDocCache(target.filePath);
|
|
563
|
+
if (cleaned.title) {
|
|
564
|
+
updateDocumentTitle(target.filename, cleaned.title);
|
|
565
|
+
broadcastDocumentsChanged();
|
|
454
566
|
}
|
|
455
567
|
}
|
|
456
568
|
const keys = Object.keys(cleaned);
|
|
@@ -633,17 +745,18 @@ export const TOOL_REGISTRY = [
|
|
|
633
745
|
return { content: [{ type: 'text', text: 'Error: workspaceFile is required for doc moves' }] };
|
|
634
746
|
const filename = resolveDocId(itemId);
|
|
635
747
|
const ws = getWorkspace(workspaceFile);
|
|
636
|
-
const
|
|
637
|
-
if (
|
|
748
|
+
const inTarget = findDocNode(ws.root, filename);
|
|
749
|
+
if (inTarget) {
|
|
750
|
+
// Within same workspace — reorder/move to container
|
|
638
751
|
moveDoc(workspaceFile, filename, targetContainerId ?? null, afterId ?? null);
|
|
639
752
|
}
|
|
640
753
|
else {
|
|
641
|
-
|
|
642
|
-
|
|
754
|
+
// Cross-workspace move: remove from old, add to new
|
|
755
|
+
removeDocFromAllWorkspaces(filename);
|
|
756
|
+
addDoc(workspaceFile, targetContainerId ?? null, filename, getDocTitle(filename), afterId ?? null);
|
|
643
757
|
}
|
|
644
758
|
broadcastWorkspacesChanged();
|
|
645
|
-
|
|
646
|
-
return { content: [{ type: 'text', text: `${action} "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
759
|
+
return { content: [{ type: 'text', text: `Moved "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
647
760
|
}
|
|
648
761
|
if (type === 'container') {
|
|
649
762
|
if (!workspaceFile)
|
|
@@ -755,104 +868,30 @@ export const TOOL_REGISTRY = [
|
|
|
755
868
|
return { content: [{ type: 'text', text }] };
|
|
756
869
|
},
|
|
757
870
|
},
|
|
758
|
-
{
|
|
759
|
-
name: 'generate_image',
|
|
760
|
-
description: 'Generate an image using Gemini Nano Banana 2. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
|
|
761
|
-
schema: {
|
|
762
|
-
prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
|
|
763
|
-
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
764
|
-
set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
765
|
-
},
|
|
766
|
-
handler: async ({ prompt, aspect_ratio, set_cover }) => {
|
|
767
|
-
const apiKey = process.env.GEMINI_API_KEY;
|
|
768
|
-
if (!apiKey) {
|
|
769
|
-
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
770
|
-
}
|
|
771
|
-
// Capture document context BEFORE the async image generation.
|
|
772
|
-
// The active document can change during the await (user switches docs),
|
|
773
|
-
// so we snapshot the metadata and filePath now to stay scoped.
|
|
774
|
-
const preAwaitFilePath = getFilePath();
|
|
775
|
-
const preAwaitMeta = structuredClone(getMetadata());
|
|
776
|
-
const { GoogleGenAI } = await import('@google/genai');
|
|
777
|
-
const ai = new GoogleGenAI({ apiKey });
|
|
778
|
-
const response = await ai.models.generateContent({
|
|
779
|
-
model: 'gemini-3.1-flash-image-preview',
|
|
780
|
-
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
781
|
-
config: {
|
|
782
|
-
responseModalities: ['IMAGE'],
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
const parts = response.candidates?.[0]?.content?.parts;
|
|
786
|
-
const imagePart = parts?.find((p) => p.inlineData);
|
|
787
|
-
if (!imagePart?.inlineData?.data) {
|
|
788
|
-
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
789
|
-
}
|
|
790
|
-
// Save to ~/.openwriter/_images/
|
|
791
|
-
ensureDataDir();
|
|
792
|
-
const imagesDir = join(getDataDir(), '_images');
|
|
793
|
-
if (!existsSync(imagesDir))
|
|
794
|
-
mkdirSync(imagesDir, { recursive: true });
|
|
795
|
-
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
796
|
-
const filePath = join(imagesDir, filename);
|
|
797
|
-
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
798
|
-
const src = `/_images/${filename}`;
|
|
799
|
-
// Optionally set as article cover + append to carousel history
|
|
800
|
-
if (set_cover) {
|
|
801
|
-
const docChanged = getFilePath() !== preAwaitFilePath;
|
|
802
|
-
if (docChanged) {
|
|
803
|
-
// Active document changed during image generation — skip set_cover
|
|
804
|
-
// to avoid leaking cover images across documents.
|
|
805
|
-
return {
|
|
806
|
-
content: [{
|
|
807
|
-
type: 'text',
|
|
808
|
-
text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
|
|
809
|
-
}],
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
// Use LIVE metadata for coverImages (not stale pre-await snapshot)
|
|
813
|
-
// so concurrent generate_image calls don't overwrite each other's results
|
|
814
|
-
const liveMeta = getMetadata();
|
|
815
|
-
const articleContext = liveMeta.articleContext || {};
|
|
816
|
-
let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
|
|
817
|
-
// Seed with current coverImage if array is empty (first carousel entry)
|
|
818
|
-
if (existing.length === 0 && articleContext.coverImage) {
|
|
819
|
-
existing = [articleContext.coverImage];
|
|
820
|
-
}
|
|
821
|
-
existing.push(src);
|
|
822
|
-
articleContext.coverImage = src;
|
|
823
|
-
articleContext.coverImages = existing;
|
|
824
|
-
setMetadata({ articleContext });
|
|
825
|
-
save();
|
|
826
|
-
broadcastMetadataChanged(getMetadata());
|
|
827
|
-
}
|
|
828
|
-
return {
|
|
829
|
-
content: [{
|
|
830
|
-
type: 'text',
|
|
831
|
-
text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
|
|
832
|
-
}],
|
|
833
|
-
};
|
|
834
|
-
},
|
|
835
|
-
},
|
|
836
871
|
{
|
|
837
872
|
name: 'insert_image',
|
|
838
|
-
description: 'Generate an image via Gemini and insert it inline
|
|
873
|
+
description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path. Requires GEMINI_API_KEY env var.',
|
|
839
874
|
schema: {
|
|
840
|
-
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
841
875
|
prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
|
|
842
|
-
|
|
876
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
|
|
877
|
+
afterNodeId: z.string().optional().describe('Insert after this node ID, or "end" to append. Required for inline insert.'),
|
|
843
878
|
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
844
879
|
alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
|
|
880
|
+
set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
845
881
|
},
|
|
846
|
-
handler: async ({
|
|
882
|
+
handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
|
|
847
883
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
848
884
|
if (!apiKey) {
|
|
849
885
|
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
850
886
|
}
|
|
851
|
-
const
|
|
887
|
+
const inlineMode = docId && afterNodeId;
|
|
888
|
+
const filename = inlineMode ? resolveDocId(docId) : undefined;
|
|
852
889
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
853
|
-
//
|
|
890
|
+
// Capture context before async work (for set_cover)
|
|
891
|
+
const preAwaitFilePath = getFilePath();
|
|
892
|
+
// Phase 1: Insert imageLoading placeholder immediately (inline + active doc only)
|
|
854
893
|
const loadingNodeId = generateNodeId();
|
|
855
|
-
if (!targetIsNonActive) {
|
|
894
|
+
if (inlineMode && !targetIsNonActive) {
|
|
856
895
|
const loadingChange = {
|
|
857
896
|
operation: 'insert',
|
|
858
897
|
afterNodeId,
|
|
@@ -874,8 +913,7 @@ export const TOOL_REGISTRY = [
|
|
|
874
913
|
const parts = response.candidates?.[0]?.content?.parts;
|
|
875
914
|
const imagePart = parts?.find((p) => p.inlineData);
|
|
876
915
|
if (!imagePart?.inlineData?.data) {
|
|
877
|
-
|
|
878
|
-
if (!targetIsNonActive) {
|
|
916
|
+
if (inlineMode && !targetIsNonActive) {
|
|
879
917
|
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
880
918
|
}
|
|
881
919
|
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
@@ -886,39 +924,63 @@ export const TOOL_REGISTRY = [
|
|
|
886
924
|
if (!existsSync(imagesDir))
|
|
887
925
|
mkdirSync(imagesDir, { recursive: true });
|
|
888
926
|
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
889
|
-
const
|
|
890
|
-
writeFileSync(
|
|
927
|
+
const imgPath = join(imagesDir, imgFilename);
|
|
928
|
+
writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
891
929
|
const src = `/_images/${imgFilename}`;
|
|
892
|
-
//
|
|
893
|
-
if (
|
|
930
|
+
// Mode 1: Inline insert
|
|
931
|
+
if (inlineMode) {
|
|
894
932
|
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
content: [{
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
933
|
+
if (targetIsNonActive) {
|
|
934
|
+
const change = { operation: 'insert', afterNodeId, content: [imageNode] };
|
|
935
|
+
const { lastNodeId } = applyChangesToFile(filename, [change]);
|
|
936
|
+
broadcastPendingDocsChanged();
|
|
937
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }) }] };
|
|
938
|
+
}
|
|
939
|
+
// Hard-replace: mutate server doc directly, bypass applyChanges.
|
|
940
|
+
// During async generation (5-10s) the agent lock expires and browser doc-updates
|
|
941
|
+
// can change the imageLoading node's ID. Find it by type, not stale ID.
|
|
942
|
+
// Then broadcast document-switched so the browser rebuilds from server truth.
|
|
943
|
+
const doc = getDocument();
|
|
944
|
+
const imgId = generateNodeId();
|
|
945
|
+
const pendingImage = { ...imageNode, attrs: { ...imageNode.attrs, id: imgId, pendingStatus: 'insert' } };
|
|
946
|
+
const idx = doc.content?.findIndex((n) => n.type === 'imageLoading') ?? -1;
|
|
947
|
+
if (idx >= 0) {
|
|
948
|
+
doc.content.splice(idx, 1, pendingImage);
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
doc.content.push(pendingImage);
|
|
952
|
+
}
|
|
953
|
+
updateDocument(doc);
|
|
954
|
+
save();
|
|
955
|
+
setAgentLock();
|
|
956
|
+
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
|
|
957
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
|
|
904
958
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
959
|
+
// Mode 2: Set as article cover
|
|
960
|
+
if (set_cover) {
|
|
961
|
+
const docChanged = getFilePath() !== preAwaitFilePath;
|
|
962
|
+
if (docChanged) {
|
|
963
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set.' }) }] };
|
|
964
|
+
}
|
|
965
|
+
const liveMeta = getMetadata();
|
|
966
|
+
const articleContext = liveMeta.articleContext || {};
|
|
967
|
+
let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
|
|
968
|
+
if (existing.length === 0 && articleContext.coverImage) {
|
|
969
|
+
existing = [articleContext.coverImage];
|
|
970
|
+
}
|
|
971
|
+
existing.push(src);
|
|
972
|
+
articleContext.coverImage = src;
|
|
973
|
+
articleContext.coverImages = existing;
|
|
974
|
+
setMetadata({ articleContext });
|
|
975
|
+
save();
|
|
976
|
+
broadcastMetadataChanged(getMetadata());
|
|
977
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
|
|
978
|
+
}
|
|
979
|
+
// Mode 3: Generate to disk only
|
|
980
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src }) }] };
|
|
918
981
|
}
|
|
919
982
|
catch (err) {
|
|
920
|
-
|
|
921
|
-
if (!targetIsNonActive) {
|
|
983
|
+
if (inlineMode && !targetIsNonActive) {
|
|
922
984
|
try {
|
|
923
985
|
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
924
986
|
}
|
|
@@ -930,13 +992,13 @@ export const TOOL_REGISTRY = [
|
|
|
930
992
|
},
|
|
931
993
|
{
|
|
932
994
|
name: 'list_versions',
|
|
933
|
-
description: 'List version history for
|
|
934
|
-
schema: {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const versions = listVersions(docId);
|
|
995
|
+
description: 'List version history for a document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
|
|
996
|
+
schema: {
|
|
997
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
998
|
+
},
|
|
999
|
+
handler: async ({ docId }) => {
|
|
1000
|
+
const target = resolveDocTarget(docId);
|
|
1001
|
+
const versions = listVersions(target.docId);
|
|
940
1002
|
if (versions.length === 0)
|
|
941
1003
|
return { content: [{ type: 'text', text: 'No versions found for this document.' }] };
|
|
942
1004
|
const lines = versions.map((v, i) => ` ${i + 1}. ${v.date} ts:${v.timestamp} ${v.wordCount.toLocaleString()} words ${(v.size / 1024).toFixed(1)}KB`);
|
|
@@ -945,60 +1007,71 @@ export const TOOL_REGISTRY = [
|
|
|
945
1007
|
},
|
|
946
1008
|
{
|
|
947
1009
|
name: 'create_checkpoint',
|
|
948
|
-
description: 'Force a version snapshot of
|
|
949
|
-
schema: {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
forceSnapshot(docId, filePath);
|
|
1010
|
+
description: 'Force a version snapshot of a document right now. Use before risky operations as a safety net.',
|
|
1011
|
+
schema: {
|
|
1012
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
1013
|
+
},
|
|
1014
|
+
handler: async ({ docId }) => {
|
|
1015
|
+
const target = resolveDocTarget(docId);
|
|
1016
|
+
forceSnapshot(target.docId, target.filePath);
|
|
956
1017
|
return { content: [{ type: 'text', text: `Checkpoint created at ${new Date().toISOString()}` }] };
|
|
957
1018
|
},
|
|
958
1019
|
},
|
|
959
1020
|
{
|
|
960
1021
|
name: 'restore_version',
|
|
961
|
-
description: 'Restore
|
|
1022
|
+
description: 'Restore a document to a previous version by timestamp. Automatically creates a safety checkpoint of the current state first. Get timestamps from list_versions.',
|
|
962
1023
|
schema: {
|
|
1024
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
963
1025
|
timestamp: z.number().describe('Version timestamp to restore (from list_versions)'),
|
|
964
1026
|
},
|
|
965
|
-
handler: async ({ timestamp }) => {
|
|
966
|
-
const
|
|
967
|
-
const filePath = getFilePath();
|
|
968
|
-
if (!docId || !filePath)
|
|
969
|
-
return { content: [{ type: 'text', text: 'Error: No active document.' }] };
|
|
1027
|
+
handler: async ({ docId, timestamp }) => {
|
|
1028
|
+
const target = resolveDocTarget(docId);
|
|
970
1029
|
// Safety net: snapshot current state before restoring
|
|
971
1030
|
try {
|
|
972
|
-
forceSnapshot(docId, filePath);
|
|
1031
|
+
forceSnapshot(target.docId, target.filePath);
|
|
973
1032
|
}
|
|
974
1033
|
catch { /* best effort */ }
|
|
975
|
-
const parsed = restoreVersion(docId, timestamp);
|
|
1034
|
+
const parsed = restoreVersion(target.docId, timestamp);
|
|
976
1035
|
if (!parsed)
|
|
977
1036
|
return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1037
|
+
if (target.isActive) {
|
|
1038
|
+
updateDocument(parsed.document);
|
|
1039
|
+
save();
|
|
1040
|
+
broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
// Write restored content to file without switching active doc
|
|
1044
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1045
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
1046
|
+
invalidateDocCache(target.filePath);
|
|
1047
|
+
broadcastDocumentsChanged();
|
|
1048
|
+
}
|
|
982
1049
|
return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
|
|
983
1050
|
},
|
|
984
1051
|
},
|
|
985
1052
|
{
|
|
986
1053
|
name: 'reload_from_disk',
|
|
987
|
-
description: 'Re-read
|
|
988
|
-
schema: {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
if (!existsSync(filePath))
|
|
994
|
-
return { content: [{ type: 'text', text: `Error: File not found: ${filePath}` }] };
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1054
|
+
description: 'Re-read a document from its file on disk. Use when the file was modified externally and the editor needs to pick up changes. Does NOT rescan the full document list.',
|
|
1055
|
+
schema: {
|
|
1056
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
1057
|
+
},
|
|
1058
|
+
handler: async ({ docId }) => {
|
|
1059
|
+
const target = resolveDocTarget(docId);
|
|
1060
|
+
if (!existsSync(target.filePath))
|
|
1061
|
+
return { content: [{ type: 'text', text: `Error: File not found: ${target.filePath}` }] };
|
|
1062
|
+
if (target.isActive) {
|
|
1063
|
+
const markdown = readFileSync(target.filePath, 'utf-8');
|
|
1064
|
+
const parsed = markdownToTiptap(markdown);
|
|
1065
|
+
updateDocument(parsed.document);
|
|
1066
|
+
save();
|
|
1067
|
+
broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
|
|
1068
|
+
return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
// Non-active: just invalidate cache so next access re-reads from disk
|
|
1072
|
+
invalidateDocCache(target.filePath);
|
|
1073
|
+
return { content: [{ type: 'text', text: `Cache invalidated for "${target.title}" — next access will re-read from disk` }] };
|
|
1074
|
+
}
|
|
1002
1075
|
},
|
|
1003
1076
|
},
|
|
1004
1077
|
{
|
|
@@ -1046,6 +1119,56 @@ export const TOOL_REGISTRY = [
|
|
|
1046
1119
|
};
|
|
1047
1120
|
},
|
|
1048
1121
|
},
|
|
1122
|
+
// ---- Task management ----
|
|
1123
|
+
{
|
|
1124
|
+
name: 'list_tasks',
|
|
1125
|
+
description: 'List all tasks for the current profile.',
|
|
1126
|
+
schema: {},
|
|
1127
|
+
handler: async () => {
|
|
1128
|
+
const tasks = readTasks();
|
|
1129
|
+
if (tasks.length === 0)
|
|
1130
|
+
return { content: [{ type: 'text', text: 'No tasks.' }] };
|
|
1131
|
+
const lines = tasks.map((t) => `[${t.completed ? 'x' : ' '}] ${t.text} (id:${t.id})`);
|
|
1132
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
name: 'add_task',
|
|
1137
|
+
description: 'Add a new task to the checklist.',
|
|
1138
|
+
schema: {
|
|
1139
|
+
text: z.string().describe('The task description.'),
|
|
1140
|
+
},
|
|
1141
|
+
handler: async ({ text }) => {
|
|
1142
|
+
const task = addTask(text);
|
|
1143
|
+
return { content: [{ type: 'text', text: `Added task: ${task.text} (id:${task.id})` }] };
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
name: 'update_task',
|
|
1148
|
+
description: 'Update a task (text or completion status).',
|
|
1149
|
+
schema: {
|
|
1150
|
+
id: z.string().describe('Task ID to update.'),
|
|
1151
|
+
text: z.string().optional().describe('New task text.'),
|
|
1152
|
+
completed: z.boolean().optional().describe('Mark completed (true) or incomplete (false).'),
|
|
1153
|
+
},
|
|
1154
|
+
handler: async ({ id, text, completed }) => {
|
|
1155
|
+
const task = updateTask(id, { text, completed });
|
|
1156
|
+
if (!task)
|
|
1157
|
+
return { content: [{ type: 'text', text: `Task ${id} not found.` }] };
|
|
1158
|
+
return { content: [{ type: 'text', text: `Updated: [${task.completed ? 'x' : ' '}] ${task.text}` }] };
|
|
1159
|
+
},
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'remove_task',
|
|
1163
|
+
description: 'Remove a task from the checklist.',
|
|
1164
|
+
schema: {
|
|
1165
|
+
id: z.string().describe('Task ID to remove.'),
|
|
1166
|
+
},
|
|
1167
|
+
handler: async ({ id }) => {
|
|
1168
|
+
const ok = removeTask(id);
|
|
1169
|
+
return { content: [{ type: 'text', text: ok ? `Removed task ${id}.` : `Task ${id} not found.` }] };
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1049
1172
|
];
|
|
1050
1173
|
/** Live MCP server instance — used to register plugin tools dynamically. */
|
|
1051
1174
|
let mcpServerInstance = null;
|