openwriter 0.6.11 → 0.8.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/dist/client/assets/index-02FEqxwZ.js +210 -0
- package/dist/client/assets/index-D9laiJ2-.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/publish/dist/index.js +244 -13
- 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/connection-routes.js +8 -1
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +315 -220
- package/dist/server/post-sync.js +114 -0
- package/dist/server/scheduler-routes.js +33 -0
- package/dist/server/state.js +74 -18
- package/dist/server/workspaces.js +35 -0
- package/dist/server/ws.js +12 -2
- package/package.json +4 -2
- package/skill/SKILL.md +28 -6
- package/dist/client/assets/index-BFXmrfky.js +0 -210
- package/dist/client/assets/index-DnndZMJ9.css +0 -1
package/dist/server/mcp.js
CHANGED
|
@@ -4,26 +4,24 @@
|
|
|
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, 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 { broadcastMarksChanged } from './ws.js';
|
|
27
25
|
/** Map a content type string to its frontmatter metadata object. */
|
|
28
26
|
function resolveTypeMeta(type) {
|
|
29
27
|
switch (type) {
|
|
@@ -55,17 +53,76 @@ function isTweetDoc(filename) {
|
|
|
55
53
|
return false;
|
|
56
54
|
}
|
|
57
55
|
}
|
|
56
|
+
/** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
|
|
57
|
+
function resolveDocTarget(docId) {
|
|
58
|
+
const filename = resolveDocId(docId);
|
|
59
|
+
const activeFilename = getActiveFilename();
|
|
60
|
+
// Fast path: active document — use in-memory state
|
|
61
|
+
if (filename === activeFilename) {
|
|
62
|
+
return {
|
|
63
|
+
filename,
|
|
64
|
+
filePath: getFilePath(),
|
|
65
|
+
docId,
|
|
66
|
+
isActive: true,
|
|
67
|
+
document: getDocument(),
|
|
68
|
+
title: getTitle(),
|
|
69
|
+
metadata: getMetadata(),
|
|
70
|
+
wordCount: getWordCount(),
|
|
71
|
+
pendingCount: getPendingChangeCount(),
|
|
72
|
+
lastModified: new Date(getStatus().lastModified),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Non-active: try cache, then disk
|
|
76
|
+
const filePath = resolveDocPath(filename);
|
|
77
|
+
const cached = getCachedDocument(filePath);
|
|
78
|
+
if (cached) {
|
|
79
|
+
const text = extractText(cached.document.content);
|
|
80
|
+
return {
|
|
81
|
+
filename,
|
|
82
|
+
filePath,
|
|
83
|
+
docId: cached.docId,
|
|
84
|
+
isActive: false,
|
|
85
|
+
document: cached.document,
|
|
86
|
+
title: cached.title,
|
|
87
|
+
metadata: cached.metadata,
|
|
88
|
+
wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
|
|
89
|
+
pendingCount: countPending(cached.document.content),
|
|
90
|
+
lastModified: cached.lastModified,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Read from disk
|
|
94
|
+
if (!existsSync(filePath))
|
|
95
|
+
throw new Error(`Document file not found: ${filename}`);
|
|
96
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
97
|
+
const parsed = markdownToTiptap(raw);
|
|
98
|
+
const meta = parsed.metadata || {};
|
|
99
|
+
const resolvedDocId = meta.docId || docId;
|
|
100
|
+
const text = extractText(parsed.document.content);
|
|
101
|
+
return {
|
|
102
|
+
filename,
|
|
103
|
+
filePath,
|
|
104
|
+
docId: resolvedDocId,
|
|
105
|
+
isActive: false,
|
|
106
|
+
document: parsed.document,
|
|
107
|
+
title: parsed.title,
|
|
108
|
+
metadata: parsed.metadata || {},
|
|
109
|
+
wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
|
|
110
|
+
pendingCount: countPending(parsed.document.content),
|
|
111
|
+
lastModified: statSync(filePath).mtime,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
58
114
|
export const TOOL_REGISTRY = [
|
|
59
115
|
{
|
|
60
116
|
name: 'read_pad',
|
|
61
|
-
description: 'Read
|
|
62
|
-
schema: {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
117
|
+
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.',
|
|
118
|
+
schema: {
|
|
119
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
120
|
+
},
|
|
121
|
+
handler: async ({ docId }) => {
|
|
122
|
+
const target = resolveDocTarget(docId);
|
|
123
|
+
const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
|
|
124
|
+
const localCount = getMarkCount(target.filename);
|
|
125
|
+
const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(target.filename);
|
|
69
126
|
let hint = '';
|
|
70
127
|
if (localCount > 0)
|
|
71
128
|
hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
|
|
@@ -101,6 +158,20 @@ export const TOOL_REGISTRY = [
|
|
|
101
158
|
}
|
|
102
159
|
return resolved;
|
|
103
160
|
});
|
|
161
|
+
// Auto-clean: if doc has only a single empty paragraph and first change is
|
|
162
|
+
// an insert, convert to a rewrite so the empty node gets replaced silently
|
|
163
|
+
// (shows as green insert decoration, not a red delete).
|
|
164
|
+
const activeDoc = getDocument();
|
|
165
|
+
if (activeDoc.content?.length === 1) {
|
|
166
|
+
const first = activeDoc.content[0];
|
|
167
|
+
if (first.type === 'paragraph' && (!first.content || first.content.length === 0) && first.attrs?.id) {
|
|
168
|
+
const insertIdx = processed.findIndex((c) => c.operation === 'insert');
|
|
169
|
+
if (insertIdx !== -1) {
|
|
170
|
+
processed[insertIdx] = { ...processed[insertIdx], operation: 'rewrite', nodeId: first.attrs.id };
|
|
171
|
+
delete processed[insertIdx].afterNodeId;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
104
175
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
105
176
|
if (targetIsNonActive) {
|
|
106
177
|
const { count: appliedCount, lastNodeId } = applyChangesToFile(filename, processed);
|
|
@@ -134,10 +205,18 @@ export const TOOL_REGISTRY = [
|
|
|
134
205
|
},
|
|
135
206
|
{
|
|
136
207
|
name: 'get_pad_status',
|
|
137
|
-
description: 'Get the
|
|
138
|
-
schema: {
|
|
139
|
-
|
|
140
|
-
|
|
208
|
+
description: 'Get the status of a document: word count, pending changes. Cheap call for polling.',
|
|
209
|
+
schema: {
|
|
210
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
211
|
+
},
|
|
212
|
+
handler: async ({ docId }) => {
|
|
213
|
+
const target = resolveDocTarget(docId);
|
|
214
|
+
const status = {
|
|
215
|
+
title: target.title,
|
|
216
|
+
wordCount: target.wordCount,
|
|
217
|
+
pendingChanges: target.pendingCount,
|
|
218
|
+
lastModified: target.lastModified.toISOString(),
|
|
219
|
+
};
|
|
141
220
|
const latestVersion = getUpdateInfo();
|
|
142
221
|
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
143
222
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
@@ -147,10 +226,13 @@ export const TOOL_REGISTRY = [
|
|
|
147
226
|
name: 'get_nodes',
|
|
148
227
|
description: 'Get specific nodes by ID. Returns compact tagged-line format per node.',
|
|
149
228
|
schema: {
|
|
229
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
150
230
|
nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
|
|
151
231
|
},
|
|
152
|
-
handler: async ({ nodeIds }) => {
|
|
153
|
-
|
|
232
|
+
handler: async ({ docId, nodeIds }) => {
|
|
233
|
+
const target = resolveDocTarget(docId);
|
|
234
|
+
const nodes = target.isActive ? getNodesByIds(nodeIds) : findNodesByIds(target.document.content, nodeIds);
|
|
235
|
+
return { content: [{ type: 'text', text: compactNodes(nodes) }] };
|
|
154
236
|
},
|
|
155
237
|
},
|
|
156
238
|
{
|
|
@@ -170,7 +252,7 @@ export const TOOL_REGISTRY = [
|
|
|
170
252
|
},
|
|
171
253
|
{
|
|
172
254
|
name: 'switch_document',
|
|
173
|
-
description: '
|
|
255
|
+
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
256
|
schema: {
|
|
175
257
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
176
258
|
},
|
|
@@ -364,10 +446,12 @@ export const TOOL_REGISTRY = [
|
|
|
364
446
|
handler: async ({ docId }) => {
|
|
365
447
|
const filename = resolveDocId(docId);
|
|
366
448
|
const result = await deleteDocument(filename);
|
|
449
|
+
removeDocFromAllWorkspaces(filename);
|
|
367
450
|
if (result.switched && result.newDoc) {
|
|
368
451
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
369
452
|
}
|
|
370
453
|
broadcastDocumentsChanged();
|
|
454
|
+
broadcastWorkspacesChanged();
|
|
371
455
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
372
456
|
if (result.switched && result.newDoc) {
|
|
373
457
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -384,10 +468,12 @@ export const TOOL_REGISTRY = [
|
|
|
384
468
|
handler: async ({ docId }) => {
|
|
385
469
|
const filename = resolveDocId(docId);
|
|
386
470
|
const result = archiveDocument(filename);
|
|
471
|
+
removeDocFromAllWorkspaces(filename);
|
|
387
472
|
if (result.switched && result.newDoc) {
|
|
388
473
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
389
474
|
}
|
|
390
475
|
broadcastDocumentsChanged();
|
|
476
|
+
broadcastWorkspacesChanged();
|
|
391
477
|
let text = `Archived "${filename}"`;
|
|
392
478
|
if (result.switched && result.newDoc) {
|
|
393
479
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -410,20 +496,24 @@ export const TOOL_REGISTRY = [
|
|
|
410
496
|
},
|
|
411
497
|
{
|
|
412
498
|
name: 'get_metadata',
|
|
413
|
-
description: 'Get the JSON frontmatter metadata for
|
|
414
|
-
schema: {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
499
|
+
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.',
|
|
500
|
+
schema: {
|
|
501
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
502
|
+
},
|
|
503
|
+
handler: async ({ docId }) => {
|
|
504
|
+
const target = resolveDocTarget(docId);
|
|
505
|
+
return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
|
|
418
506
|
},
|
|
419
507
|
},
|
|
420
508
|
{
|
|
421
509
|
name: 'set_metadata',
|
|
422
|
-
description: 'Update frontmatter metadata on
|
|
510
|
+
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
511
|
schema: {
|
|
512
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
424
513
|
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
425
514
|
},
|
|
426
|
-
handler: async ({ metadata: updates }) => {
|
|
515
|
+
handler: async ({ docId, metadata: updates }) => {
|
|
516
|
+
const target = resolveDocTarget(docId);
|
|
427
517
|
const setKeys = [];
|
|
428
518
|
const removed = [];
|
|
429
519
|
for (const [key, value] of Object.entries(updates)) {
|
|
@@ -437,20 +527,41 @@ export const TOOL_REGISTRY = [
|
|
|
437
527
|
const cleaned = {};
|
|
438
528
|
for (const key of setKeys)
|
|
439
529
|
cleaned[key] = updates[key];
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
530
|
+
if (target.isActive) {
|
|
531
|
+
// Active doc: use in-memory path
|
|
532
|
+
if (Object.keys(cleaned).length > 0)
|
|
533
|
+
setMetadata(cleaned);
|
|
534
|
+
const meta = getMetadata();
|
|
535
|
+
for (const key of removed)
|
|
536
|
+
delete meta[key];
|
|
537
|
+
save();
|
|
538
|
+
broadcastMetadataChanged(getMetadata());
|
|
539
|
+
if (cleaned.title) {
|
|
540
|
+
const promoted = promoteTempFile(cleaned.title);
|
|
541
|
+
broadcastTitleChanged(cleaned.title);
|
|
542
|
+
broadcastDocumentsChanged();
|
|
543
|
+
if (promoted) {
|
|
544
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
// Non-active doc: read → merge → write file
|
|
550
|
+
let meta = { ...target.metadata };
|
|
551
|
+
if (Object.keys(cleaned).length > 0) {
|
|
552
|
+
const merged = mergeMetadataUpdates(meta, cleaned);
|
|
553
|
+
if (merged)
|
|
554
|
+
meta = merged;
|
|
555
|
+
}
|
|
556
|
+
for (const key of removed)
|
|
557
|
+
delete meta[key];
|
|
558
|
+
const newTitle = cleaned.title || meta.title || target.title;
|
|
559
|
+
const markdown = tiptapToMarkdown(target.document, newTitle, meta);
|
|
560
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
561
|
+
invalidateDocCache(target.filePath);
|
|
562
|
+
if (cleaned.title) {
|
|
563
|
+
updateDocumentTitle(target.filename, cleaned.title);
|
|
564
|
+
broadcastDocumentsChanged();
|
|
454
565
|
}
|
|
455
566
|
}
|
|
456
567
|
const keys = Object.keys(cleaned);
|
|
@@ -618,28 +729,52 @@ export const TOOL_REGISTRY = [
|
|
|
618
729
|
},
|
|
619
730
|
},
|
|
620
731
|
{
|
|
621
|
-
name: '
|
|
622
|
-
description: '
|
|
732
|
+
name: 'move_item',
|
|
733
|
+
description: 'Move or reorder a doc, container, or workspace. For docs: add to workspace or move within it. For containers: move to different parent or reorder within current parent. For workspaces: reorder in sidebar.',
|
|
623
734
|
schema: {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
735
|
+
type: z.enum(['doc', 'container', 'workspace']).describe('What to move'),
|
|
736
|
+
workspaceFile: z.string().optional().describe('Workspace manifest filename (required for doc/container)'),
|
|
737
|
+
itemId: z.string().describe('docId (8-char hex), containerId, or workspace filename'),
|
|
738
|
+
targetContainerId: z.string().optional().describe('Destination container (omit for root or same-parent reorder). Doc/container only.'),
|
|
739
|
+
afterId: z.string().optional().describe('Place after this item (omit for beginning)'),
|
|
740
|
+
},
|
|
741
|
+
handler: async ({ type, workspaceFile, itemId, targetContainerId, afterId }) => {
|
|
742
|
+
if (type === 'doc') {
|
|
743
|
+
if (!workspaceFile)
|
|
744
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile is required for doc moves' }] };
|
|
745
|
+
const filename = resolveDocId(itemId);
|
|
746
|
+
const ws = getWorkspace(workspaceFile);
|
|
747
|
+
const inTarget = findDocNode(ws.root, filename);
|
|
748
|
+
if (inTarget) {
|
|
749
|
+
// Within same workspace — reorder/move to container
|
|
750
|
+
moveDoc(workspaceFile, filename, targetContainerId ?? null, afterId ?? null);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
// Cross-workspace move: remove from old, add to new
|
|
754
|
+
removeDocFromAllWorkspaces(filename);
|
|
755
|
+
addDoc(workspaceFile, targetContainerId ?? null, filename, getDocTitle(filename), afterId ?? null);
|
|
756
|
+
}
|
|
757
|
+
broadcastWorkspacesChanged();
|
|
758
|
+
return { content: [{ type: 'text', text: `Moved "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
635
759
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
760
|
+
if (type === 'container') {
|
|
761
|
+
if (!workspaceFile)
|
|
762
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile is required for container moves' }] };
|
|
763
|
+
if (targetContainerId !== undefined) {
|
|
764
|
+
moveContainer(workspaceFile, itemId, targetContainerId, afterId ?? null);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
moveContainer(workspaceFile, itemId, null, afterId ?? null);
|
|
768
|
+
}
|
|
769
|
+
broadcastWorkspacesChanged();
|
|
770
|
+
return { content: [{ type: 'text', text: `Moved container "${itemId}"${targetContainerId ? ` to container ${targetContainerId}` : ''}${afterId ? ` after ${afterId}` : ' to beginning'}` }] };
|
|
639
771
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
772
|
+
if (type === 'workspace') {
|
|
773
|
+
reorderWorkspaceAfter(itemId, afterId ?? null);
|
|
774
|
+
broadcastWorkspacesChanged();
|
|
775
|
+
return { content: [{ type: 'text', text: `Reordered workspace "${itemId}"${afterId ? ` after ${afterId}` : ' to beginning'}` }] };
|
|
776
|
+
}
|
|
777
|
+
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
643
778
|
},
|
|
644
779
|
},
|
|
645
780
|
{
|
|
@@ -732,104 +867,30 @@ export const TOOL_REGISTRY = [
|
|
|
732
867
|
return { content: [{ type: 'text', text }] };
|
|
733
868
|
},
|
|
734
869
|
},
|
|
735
|
-
{
|
|
736
|
-
name: 'generate_image',
|
|
737
|
-
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.',
|
|
738
|
-
schema: {
|
|
739
|
-
prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
|
|
740
|
-
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
741
|
-
set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
742
|
-
},
|
|
743
|
-
handler: async ({ prompt, aspect_ratio, set_cover }) => {
|
|
744
|
-
const apiKey = process.env.GEMINI_API_KEY;
|
|
745
|
-
if (!apiKey) {
|
|
746
|
-
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
747
|
-
}
|
|
748
|
-
// Capture document context BEFORE the async image generation.
|
|
749
|
-
// The active document can change during the await (user switches docs),
|
|
750
|
-
// so we snapshot the metadata and filePath now to stay scoped.
|
|
751
|
-
const preAwaitFilePath = getFilePath();
|
|
752
|
-
const preAwaitMeta = structuredClone(getMetadata());
|
|
753
|
-
const { GoogleGenAI } = await import('@google/genai');
|
|
754
|
-
const ai = new GoogleGenAI({ apiKey });
|
|
755
|
-
const response = await ai.models.generateContent({
|
|
756
|
-
model: 'gemini-3.1-flash-image-preview',
|
|
757
|
-
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
758
|
-
config: {
|
|
759
|
-
responseModalities: ['IMAGE'],
|
|
760
|
-
},
|
|
761
|
-
});
|
|
762
|
-
const parts = response.candidates?.[0]?.content?.parts;
|
|
763
|
-
const imagePart = parts?.find((p) => p.inlineData);
|
|
764
|
-
if (!imagePart?.inlineData?.data) {
|
|
765
|
-
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
766
|
-
}
|
|
767
|
-
// Save to ~/.openwriter/_images/
|
|
768
|
-
ensureDataDir();
|
|
769
|
-
const imagesDir = join(getDataDir(), '_images');
|
|
770
|
-
if (!existsSync(imagesDir))
|
|
771
|
-
mkdirSync(imagesDir, { recursive: true });
|
|
772
|
-
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
773
|
-
const filePath = join(imagesDir, filename);
|
|
774
|
-
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
775
|
-
const src = `/_images/${filename}`;
|
|
776
|
-
// Optionally set as article cover + append to carousel history
|
|
777
|
-
if (set_cover) {
|
|
778
|
-
const docChanged = getFilePath() !== preAwaitFilePath;
|
|
779
|
-
if (docChanged) {
|
|
780
|
-
// Active document changed during image generation — skip set_cover
|
|
781
|
-
// to avoid leaking cover images across documents.
|
|
782
|
-
return {
|
|
783
|
-
content: [{
|
|
784
|
-
type: 'text',
|
|
785
|
-
text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
|
|
786
|
-
}],
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
// Use LIVE metadata for coverImages (not stale pre-await snapshot)
|
|
790
|
-
// so concurrent generate_image calls don't overwrite each other's results
|
|
791
|
-
const liveMeta = getMetadata();
|
|
792
|
-
const articleContext = liveMeta.articleContext || {};
|
|
793
|
-
let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
|
|
794
|
-
// Seed with current coverImage if array is empty (first carousel entry)
|
|
795
|
-
if (existing.length === 0 && articleContext.coverImage) {
|
|
796
|
-
existing = [articleContext.coverImage];
|
|
797
|
-
}
|
|
798
|
-
existing.push(src);
|
|
799
|
-
articleContext.coverImage = src;
|
|
800
|
-
articleContext.coverImages = existing;
|
|
801
|
-
setMetadata({ articleContext });
|
|
802
|
-
save();
|
|
803
|
-
broadcastMetadataChanged(getMetadata());
|
|
804
|
-
}
|
|
805
|
-
return {
|
|
806
|
-
content: [{
|
|
807
|
-
type: 'text',
|
|
808
|
-
text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
|
|
809
|
-
}],
|
|
810
|
-
};
|
|
811
|
-
},
|
|
812
|
-
},
|
|
813
870
|
{
|
|
814
871
|
name: 'insert_image',
|
|
815
|
-
description: 'Generate an image via Gemini and insert it inline
|
|
872
|
+
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.',
|
|
816
873
|
schema: {
|
|
817
|
-
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
818
874
|
prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
|
|
819
|
-
|
|
875
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
|
|
876
|
+
afterNodeId: z.string().optional().describe('Insert after this node ID, or "end" to append. Required for inline insert.'),
|
|
820
877
|
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
821
878
|
alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
|
|
879
|
+
set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
822
880
|
},
|
|
823
|
-
handler: async ({
|
|
881
|
+
handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
|
|
824
882
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
825
883
|
if (!apiKey) {
|
|
826
884
|
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
827
885
|
}
|
|
828
|
-
const
|
|
886
|
+
const inlineMode = docId && afterNodeId;
|
|
887
|
+
const filename = inlineMode ? resolveDocId(docId) : undefined;
|
|
829
888
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
830
|
-
//
|
|
889
|
+
// Capture context before async work (for set_cover)
|
|
890
|
+
const preAwaitFilePath = getFilePath();
|
|
891
|
+
// Phase 1: Insert imageLoading placeholder immediately (inline + active doc only)
|
|
831
892
|
const loadingNodeId = generateNodeId();
|
|
832
|
-
if (!targetIsNonActive) {
|
|
893
|
+
if (inlineMode && !targetIsNonActive) {
|
|
833
894
|
const loadingChange = {
|
|
834
895
|
operation: 'insert',
|
|
835
896
|
afterNodeId,
|
|
@@ -851,8 +912,7 @@ export const TOOL_REGISTRY = [
|
|
|
851
912
|
const parts = response.candidates?.[0]?.content?.parts;
|
|
852
913
|
const imagePart = parts?.find((p) => p.inlineData);
|
|
853
914
|
if (!imagePart?.inlineData?.data) {
|
|
854
|
-
|
|
855
|
-
if (!targetIsNonActive) {
|
|
915
|
+
if (inlineMode && !targetIsNonActive) {
|
|
856
916
|
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
857
917
|
}
|
|
858
918
|
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
@@ -863,39 +923,63 @@ export const TOOL_REGISTRY = [
|
|
|
863
923
|
if (!existsSync(imagesDir))
|
|
864
924
|
mkdirSync(imagesDir, { recursive: true });
|
|
865
925
|
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
866
|
-
const
|
|
867
|
-
writeFileSync(
|
|
926
|
+
const imgPath = join(imagesDir, imgFilename);
|
|
927
|
+
writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
868
928
|
const src = `/_images/${imgFilename}`;
|
|
869
|
-
//
|
|
870
|
-
if (
|
|
929
|
+
// Mode 1: Inline insert
|
|
930
|
+
if (inlineMode) {
|
|
871
931
|
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
content: [{
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
932
|
+
if (targetIsNonActive) {
|
|
933
|
+
const change = { operation: 'insert', afterNodeId, content: [imageNode] };
|
|
934
|
+
const { lastNodeId } = applyChangesToFile(filename, [change]);
|
|
935
|
+
broadcastPendingDocsChanged();
|
|
936
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }) }] };
|
|
937
|
+
}
|
|
938
|
+
// Hard-replace: mutate server doc directly, bypass applyChanges.
|
|
939
|
+
// During async generation (5-10s) the agent lock expires and browser doc-updates
|
|
940
|
+
// can change the imageLoading node's ID. Find it by type, not stale ID.
|
|
941
|
+
// Then broadcast document-switched so the browser rebuilds from server truth.
|
|
942
|
+
const doc = getDocument();
|
|
943
|
+
const imgId = generateNodeId();
|
|
944
|
+
const pendingImage = { ...imageNode, attrs: { ...imageNode.attrs, id: imgId, pendingStatus: 'insert' } };
|
|
945
|
+
const idx = doc.content?.findIndex((n) => n.type === 'imageLoading') ?? -1;
|
|
946
|
+
if (idx >= 0) {
|
|
947
|
+
doc.content.splice(idx, 1, pendingImage);
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
doc.content.push(pendingImage);
|
|
951
|
+
}
|
|
952
|
+
updateDocument(doc);
|
|
953
|
+
save();
|
|
954
|
+
setAgentLock();
|
|
955
|
+
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
|
|
956
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
|
|
881
957
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
958
|
+
// Mode 2: Set as article cover
|
|
959
|
+
if (set_cover) {
|
|
960
|
+
const docChanged = getFilePath() !== preAwaitFilePath;
|
|
961
|
+
if (docChanged) {
|
|
962
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set.' }) }] };
|
|
963
|
+
}
|
|
964
|
+
const liveMeta = getMetadata();
|
|
965
|
+
const articleContext = liveMeta.articleContext || {};
|
|
966
|
+
let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
|
|
967
|
+
if (existing.length === 0 && articleContext.coverImage) {
|
|
968
|
+
existing = [articleContext.coverImage];
|
|
969
|
+
}
|
|
970
|
+
existing.push(src);
|
|
971
|
+
articleContext.coverImage = src;
|
|
972
|
+
articleContext.coverImages = existing;
|
|
973
|
+
setMetadata({ articleContext });
|
|
974
|
+
save();
|
|
975
|
+
broadcastMetadataChanged(getMetadata());
|
|
976
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
|
|
977
|
+
}
|
|
978
|
+
// Mode 3: Generate to disk only
|
|
979
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src }) }] };
|
|
895
980
|
}
|
|
896
981
|
catch (err) {
|
|
897
|
-
|
|
898
|
-
if (!targetIsNonActive) {
|
|
982
|
+
if (inlineMode && !targetIsNonActive) {
|
|
899
983
|
try {
|
|
900
984
|
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
901
985
|
}
|
|
@@ -907,13 +991,13 @@ export const TOOL_REGISTRY = [
|
|
|
907
991
|
},
|
|
908
992
|
{
|
|
909
993
|
name: 'list_versions',
|
|
910
|
-
description: 'List version history for
|
|
911
|
-
schema: {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const versions = listVersions(docId);
|
|
994
|
+
description: 'List version history for a document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
|
|
995
|
+
schema: {
|
|
996
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
997
|
+
},
|
|
998
|
+
handler: async ({ docId }) => {
|
|
999
|
+
const target = resolveDocTarget(docId);
|
|
1000
|
+
const versions = listVersions(target.docId);
|
|
917
1001
|
if (versions.length === 0)
|
|
918
1002
|
return { content: [{ type: 'text', text: 'No versions found for this document.' }] };
|
|
919
1003
|
const lines = versions.map((v, i) => ` ${i + 1}. ${v.date} ts:${v.timestamp} ${v.wordCount.toLocaleString()} words ${(v.size / 1024).toFixed(1)}KB`);
|
|
@@ -922,60 +1006,71 @@ export const TOOL_REGISTRY = [
|
|
|
922
1006
|
},
|
|
923
1007
|
{
|
|
924
1008
|
name: 'create_checkpoint',
|
|
925
|
-
description: 'Force a version snapshot of
|
|
926
|
-
schema: {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
forceSnapshot(docId, filePath);
|
|
1009
|
+
description: 'Force a version snapshot of a document right now. Use before risky operations as a safety net.',
|
|
1010
|
+
schema: {
|
|
1011
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
1012
|
+
},
|
|
1013
|
+
handler: async ({ docId }) => {
|
|
1014
|
+
const target = resolveDocTarget(docId);
|
|
1015
|
+
forceSnapshot(target.docId, target.filePath);
|
|
933
1016
|
return { content: [{ type: 'text', text: `Checkpoint created at ${new Date().toISOString()}` }] };
|
|
934
1017
|
},
|
|
935
1018
|
},
|
|
936
1019
|
{
|
|
937
1020
|
name: 'restore_version',
|
|
938
|
-
description: 'Restore
|
|
1021
|
+
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.',
|
|
939
1022
|
schema: {
|
|
1023
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
940
1024
|
timestamp: z.number().describe('Version timestamp to restore (from list_versions)'),
|
|
941
1025
|
},
|
|
942
|
-
handler: async ({ timestamp }) => {
|
|
943
|
-
const
|
|
944
|
-
const filePath = getFilePath();
|
|
945
|
-
if (!docId || !filePath)
|
|
946
|
-
return { content: [{ type: 'text', text: 'Error: No active document.' }] };
|
|
1026
|
+
handler: async ({ docId, timestamp }) => {
|
|
1027
|
+
const target = resolveDocTarget(docId);
|
|
947
1028
|
// Safety net: snapshot current state before restoring
|
|
948
1029
|
try {
|
|
949
|
-
forceSnapshot(docId, filePath);
|
|
1030
|
+
forceSnapshot(target.docId, target.filePath);
|
|
950
1031
|
}
|
|
951
1032
|
catch { /* best effort */ }
|
|
952
|
-
const parsed = restoreVersion(docId, timestamp);
|
|
1033
|
+
const parsed = restoreVersion(target.docId, timestamp);
|
|
953
1034
|
if (!parsed)
|
|
954
1035
|
return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1036
|
+
if (target.isActive) {
|
|
1037
|
+
updateDocument(parsed.document);
|
|
1038
|
+
save();
|
|
1039
|
+
broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
// Write restored content to file without switching active doc
|
|
1043
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1044
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
1045
|
+
invalidateDocCache(target.filePath);
|
|
1046
|
+
broadcastDocumentsChanged();
|
|
1047
|
+
}
|
|
959
1048
|
return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
|
|
960
1049
|
},
|
|
961
1050
|
},
|
|
962
1051
|
{
|
|
963
1052
|
name: 'reload_from_disk',
|
|
964
|
-
description: 'Re-read
|
|
965
|
-
schema: {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
if (!existsSync(filePath))
|
|
971
|
-
return { content: [{ type: 'text', text: `Error: File not found: ${filePath}` }] };
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1053
|
+
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.',
|
|
1054
|
+
schema: {
|
|
1055
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
1056
|
+
},
|
|
1057
|
+
handler: async ({ docId }) => {
|
|
1058
|
+
const target = resolveDocTarget(docId);
|
|
1059
|
+
if (!existsSync(target.filePath))
|
|
1060
|
+
return { content: [{ type: 'text', text: `Error: File not found: ${target.filePath}` }] };
|
|
1061
|
+
if (target.isActive) {
|
|
1062
|
+
const markdown = readFileSync(target.filePath, 'utf-8');
|
|
1063
|
+
const parsed = markdownToTiptap(markdown);
|
|
1064
|
+
updateDocument(parsed.document);
|
|
1065
|
+
save();
|
|
1066
|
+
broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
|
|
1067
|
+
return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
// Non-active: just invalidate cache so next access re-reads from disk
|
|
1071
|
+
invalidateDocCache(target.filePath);
|
|
1072
|
+
return { content: [{ type: 'text', text: `Cache invalidated for "${target.title}" — next access will re-read from disk` }] };
|
|
1073
|
+
}
|
|
979
1074
|
},
|
|
980
1075
|
},
|
|
981
1076
|
{
|