openwriter 0.2.0 → 0.2.2
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/README.md +7 -5
- package/dist/client/assets/index-De-jpZgc.css +1 -0
- package/dist/client/assets/index-FOERHzGc.js +205 -0
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +1 -2
- package/dist/server/documents.js +6 -5
- package/dist/server/index.js +88 -30
- package/dist/server/link-routes.js +6 -5
- package/dist/server/mcp.js +148 -76
- package/dist/server/plugin-discovery.js +64 -0
- package/dist/server/plugin-manager.js +155 -0
- package/dist/server/state.js +197 -26
- package/dist/server/workspace-routes.js +3 -24
- package/dist/server/workspace-tags.js +0 -3
- package/dist/server/workspace-types.js +0 -8
- package/dist/server/workspaces.js +128 -38
- package/dist/server/ws.js +63 -12
- package/package.json +3 -2
- package/skill/SKILL.md +112 -32
- package/dist/client/assets/index-DNJs7lC-.js +0 -205
- package/dist/client/assets/index-WweytMO1.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-FOERHzGc.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-De-jpZgc.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
package/dist/server/compact.js
CHANGED
|
@@ -209,6 +209,5 @@ export function compactNodes(nodes) {
|
|
|
209
209
|
* Used when agents send markdown strings as content in write_to_pad.
|
|
210
210
|
*/
|
|
211
211
|
export function parseMarkdownContent(content) {
|
|
212
|
-
|
|
213
|
-
return nodes.length === 1 ? nodes[0] : nodes;
|
|
212
|
+
return markdownToNodes(content);
|
|
214
213
|
}
|
package/dist/server/documents.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* Manages listing, switching, creating, deleting documents.
|
|
4
4
|
* Each document is a .md file in ~/.openwriter/.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
|
+
import trash from 'trash';
|
|
9
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
10
11
|
import { parseMarkdownContent } from './compact.js';
|
|
11
12
|
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, } from './state.js';
|
|
@@ -67,8 +68,8 @@ export function listDocuments() {
|
|
|
67
68
|
}
|
|
68
69
|
catch { /* skip unreadable external files */ }
|
|
69
70
|
}
|
|
70
|
-
//
|
|
71
|
-
files.sort((a, b) =>
|
|
71
|
+
// Most recently modified first — new docs appear at top (matches spinner position)
|
|
72
|
+
files.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
72
73
|
return files;
|
|
73
74
|
}
|
|
74
75
|
export function switchDocument(filename) {
|
|
@@ -140,7 +141,7 @@ export function createDocument(title, content, path) {
|
|
|
140
141
|
writeFileSync(filePath, markdown, 'utf-8');
|
|
141
142
|
return { document: getDocument(), title: getTitle(), filename };
|
|
142
143
|
}
|
|
143
|
-
export function deleteDocument(filename) {
|
|
144
|
+
export async function deleteDocument(filename) {
|
|
144
145
|
ensureDataDir();
|
|
145
146
|
const targetPath = resolveDocPath(filename);
|
|
146
147
|
// Unregister if external
|
|
@@ -153,7 +154,7 @@ export function deleteDocument(filename) {
|
|
|
153
154
|
}
|
|
154
155
|
const isDeletingActive = targetPath === getFilePath();
|
|
155
156
|
if (existsSync(targetPath)) {
|
|
156
|
-
|
|
157
|
+
await trash(targetPath);
|
|
157
158
|
}
|
|
158
159
|
if (isDeletingActive) {
|
|
159
160
|
const remaining = readdirSync(DATA_DIR)
|
package/dist/server/index.js
CHANGED
|
@@ -8,9 +8,9 @@ import { fileURLToPath } from 'url';
|
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
9
|
import { existsSync } from 'fs';
|
|
10
10
|
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastSyncStatus } from './ws.js';
|
|
11
|
-
import { startMcpServer, TOOL_REGISTRY
|
|
11
|
+
import { startMcpServer, TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { startMcpClientServer } from './mcp-client.js';
|
|
13
|
-
import { load, save, getDocument, getTitle, getFilePath, getDocId, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocFilenames, getPendingDocCounts } from './state.js';
|
|
13
|
+
import { load, save, getDocument, getTitle, getFilePath, getDocId, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, getDocTagsByFilename, addDocTag, removeDocTag } from './state.js';
|
|
14
14
|
import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
|
|
15
15
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
16
16
|
import { createLinkRouter } from './link-routes.js';
|
|
@@ -20,7 +20,7 @@ import { createVersionRouter } from './version-routes.js';
|
|
|
20
20
|
import { createSyncRouter } from './sync-routes.js';
|
|
21
21
|
import { createImageRouter } from './image-upload.js';
|
|
22
22
|
import { createExportRouter } from './export-routes.js';
|
|
23
|
-
import {
|
|
23
|
+
import { PluginManager } from './plugin-manager.js';
|
|
24
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
25
|
const __dirname = dirname(__filename);
|
|
26
26
|
function isPortTaken(port) {
|
|
@@ -170,9 +170,9 @@ export async function startServer(options = {}) {
|
|
|
170
170
|
res.status(500).json({ error: err.message });
|
|
171
171
|
}
|
|
172
172
|
});
|
|
173
|
-
app.delete('/api/documents/:filename', (req, res) => {
|
|
173
|
+
app.delete('/api/documents/:filename', async (req, res) => {
|
|
174
174
|
try {
|
|
175
|
-
const result = deleteDocument(req.params.filename);
|
|
175
|
+
const result = await deleteDocument(req.params.filename);
|
|
176
176
|
if (result.switched && result.newDoc) {
|
|
177
177
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
178
178
|
}
|
|
@@ -196,7 +196,36 @@ export async function startServer(options = {}) {
|
|
|
196
196
|
res.status(400).json({ error: err.message });
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
|
-
//
|
|
199
|
+
// Document-level tag routes
|
|
200
|
+
app.get('/api/doc-tags/:filename', (req, res) => {
|
|
201
|
+
res.json({ tags: getDocTagsByFilename(req.params.filename) });
|
|
202
|
+
});
|
|
203
|
+
app.post('/api/doc-tags/:filename', (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
const { tag } = req.body;
|
|
206
|
+
if (!tag?.trim()) {
|
|
207
|
+
res.status(400).json({ error: 'tag is required' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
addDocTag(req.params.filename, tag.trim());
|
|
211
|
+
broadcastDocumentsChanged();
|
|
212
|
+
res.json({ success: true });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
res.status(400).json({ error: err.message });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
app.delete('/api/doc-tags/:filename/:tag', (req, res) => {
|
|
219
|
+
try {
|
|
220
|
+
removeDocTag(req.params.filename, req.params.tag);
|
|
221
|
+
broadcastDocumentsChanged();
|
|
222
|
+
res.json({ success: true });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
res.status(400).json({ error: err.message });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
// Mount workspace CRUD + doc/container routes
|
|
200
229
|
app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
|
|
201
230
|
// Mount link-doc routes (create-link-doc, auto-tag-link)
|
|
202
231
|
app.use(createLinkRouter({ broadcastDocumentsChanged, broadcastWorkspacesChanged }));
|
|
@@ -231,37 +260,66 @@ export async function startServer(options = {}) {
|
|
|
231
260
|
res.status(400).json({ error: err.message });
|
|
232
261
|
}
|
|
233
262
|
});
|
|
234
|
-
//
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
for (const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
registerPluginTools(plugin.mcpTools(config));
|
|
243
|
-
console.log(`[Plugin] Loaded: ${plugin.name} v${plugin.version}`);
|
|
263
|
+
// Plugin Manager — discover, enable/disable, config persistence
|
|
264
|
+
const pluginManager = new PluginManager(app);
|
|
265
|
+
await pluginManager.discover();
|
|
266
|
+
// Auto-enable from --plugins CLI flag
|
|
267
|
+
for (const name of (options.plugins || [])) {
|
|
268
|
+
const result = await pluginManager.enable(name);
|
|
269
|
+
if (!result.success)
|
|
270
|
+
console.error(`[Plugin] ${result.error}`);
|
|
244
271
|
}
|
|
245
|
-
//
|
|
272
|
+
// Auto-enable from saved config.json
|
|
273
|
+
const savedConfig = (await import('./helpers.js')).readConfig();
|
|
274
|
+
for (const [name, state] of Object.entries(savedConfig.plugins || {})) {
|
|
275
|
+
if (state.enabled && !((options.plugins || []).includes(name))) {
|
|
276
|
+
const result = await pluginManager.enable(name);
|
|
277
|
+
if (!result.success)
|
|
278
|
+
console.error(`[Plugin] ${result.error}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Enabled plugins' context menu items (backward-compatible)
|
|
246
282
|
app.get('/api/plugins', (_req, res) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
res.json({ plugins:
|
|
283
|
+
res.json({ plugins: pluginManager.getEnabledPluginDescriptors() });
|
|
284
|
+
});
|
|
285
|
+
// All discovered plugins with enabled status, configSchema, current config
|
|
286
|
+
app.get('/api/available-plugins', (_req, res) => {
|
|
287
|
+
res.json({ plugins: pluginManager.getAvailablePlugins() });
|
|
288
|
+
});
|
|
289
|
+
// Enable a plugin
|
|
290
|
+
app.post('/api/plugins/enable', async (req, res) => {
|
|
291
|
+
const { name } = req.body;
|
|
292
|
+
if (!name) {
|
|
293
|
+
res.status(400).json({ error: 'name is required' });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const result = await pluginManager.enable(name);
|
|
297
|
+
res.json(result);
|
|
298
|
+
});
|
|
299
|
+
// Disable a plugin
|
|
300
|
+
app.post('/api/plugins/disable', async (req, res) => {
|
|
301
|
+
const { name } = req.body;
|
|
302
|
+
if (!name) {
|
|
303
|
+
res.status(400).json({ error: 'name is required' });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const result = await pluginManager.disable(name);
|
|
307
|
+
res.json(result);
|
|
308
|
+
});
|
|
309
|
+
// Update plugin config
|
|
310
|
+
app.post('/api/plugins/config', (req, res) => {
|
|
311
|
+
const { name, config } = req.body;
|
|
312
|
+
if (!name || !config) {
|
|
313
|
+
res.status(400).json({ error: 'name and config are required' });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const result = pluginManager.updateConfig(name, config);
|
|
317
|
+
res.json(result);
|
|
252
318
|
});
|
|
253
319
|
// Plugin action dispatch — client sends action payload, routed to correct plugin
|
|
254
320
|
app.post('/api/plugin-action', async (req, res) => {
|
|
255
321
|
try {
|
|
256
322
|
const payload = req.body;
|
|
257
|
-
const prefix = payload.action.split(':')[0];
|
|
258
|
-
const loaded = pluginResult.plugins.find(({ plugin }) => plugin.contextMenuItems?.().some((item) => item.action.startsWith(prefix + ':')));
|
|
259
|
-
if (!loaded) {
|
|
260
|
-
res.status(404).json({ error: `No plugin handles action "${payload.action}"` });
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
// Forward to plugin's registered route — plugins handle their own actions
|
|
264
|
-
// via routes registered in registerRoutes(). The client calls those directly.
|
|
265
323
|
res.status(404).json({ error: 'Use plugin-registered routes directly' });
|
|
266
324
|
}
|
|
267
325
|
catch (err) {
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
6
|
import { existsSync, writeFileSync } from 'fs';
|
|
7
|
-
import { listWorkspaces, getWorkspace, addDoc, addContainerToWorkspace
|
|
7
|
+
import { listWorkspaces, getWorkspace, addDoc, addContainerToWorkspace } from './workspaces.js';
|
|
8
8
|
import { collectAllFiles } from './workspace-tree.js';
|
|
9
9
|
import { getActiveFilename } from './documents.js';
|
|
10
|
+
import { addDocTag } from './state.js';
|
|
10
11
|
import { filePathForTitle, ensureDataDir } from './helpers.js';
|
|
11
12
|
import { tiptapToMarkdown } from './markdown.js';
|
|
12
13
|
export function createLinkRouter(b) {
|
|
@@ -61,10 +62,10 @@ export function createLinkRouter(b) {
|
|
|
61
62
|
}
|
|
62
63
|
// 5. Tag with "linked"
|
|
63
64
|
try {
|
|
64
|
-
|
|
65
|
+
addDocTag(filename, 'linked');
|
|
65
66
|
}
|
|
66
67
|
catch {
|
|
67
|
-
//
|
|
68
|
+
// Already tagged or file missing
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
catch {
|
|
@@ -97,7 +98,7 @@ export function createLinkRouter(b) {
|
|
|
97
98
|
// Both source and target must be in same workspace
|
|
98
99
|
if (!wsFiles.includes(currentFilename) || !wsFiles.includes(targetFile))
|
|
99
100
|
continue;
|
|
100
|
-
|
|
101
|
+
addDocTag(targetFile, 'linked');
|
|
101
102
|
tagged = true;
|
|
102
103
|
}
|
|
103
104
|
catch {
|
|
@@ -105,7 +106,7 @@ export function createLinkRouter(b) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
if (tagged)
|
|
108
|
-
b.
|
|
109
|
+
b.broadcastDocumentsChanged();
|
|
109
110
|
res.json({ success: true, tagged });
|
|
110
111
|
}
|
|
111
112
|
catch (err) {
|
package/dist/server/mcp.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, } from './state.js';
|
|
10
|
-
import { listDocuments, switchDocument, createDocument, openFile, getActiveFilename } from './documents.js';
|
|
11
|
-
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged } from './ws.js';
|
|
12
|
-
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, addContainerToWorkspace,
|
|
9
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
|
|
10
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
|
|
11
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
12
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc } from './workspaces.js';
|
|
13
|
+
import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
|
|
13
14
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
14
15
|
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
15
|
-
import { markdownToTiptap } from './markdown.js';
|
|
16
16
|
export const TOOL_REGISTRY = [
|
|
17
17
|
{
|
|
18
18
|
name: 'read_pad',
|
|
@@ -26,7 +26,7 @@ export const TOOL_REGISTRY = [
|
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
name: 'write_to_pad',
|
|
29
|
-
description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects.',
|
|
29
|
+
description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts.',
|
|
30
30
|
schema: {
|
|
31
31
|
changes: z.array(z.object({
|
|
32
32
|
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
@@ -43,7 +43,7 @@ export const TOOL_REGISTRY = [
|
|
|
43
43
|
}
|
|
44
44
|
return resolved;
|
|
45
45
|
});
|
|
46
|
-
const appliedCount = applyChanges(processed);
|
|
46
|
+
const { count: appliedCount, lastNodeId } = applyChanges(processed);
|
|
47
47
|
broadcastPendingDocsChanged();
|
|
48
48
|
return {
|
|
49
49
|
content: [{
|
|
@@ -51,6 +51,7 @@ export const TOOL_REGISTRY = [
|
|
|
51
51
|
text: JSON.stringify({
|
|
52
52
|
success: appliedCount > 0,
|
|
53
53
|
appliedCount,
|
|
54
|
+
...(lastNodeId ? { lastNodeId } : {}),
|
|
54
55
|
...(appliedCount < processed.length ? { skipped: processed.length - appliedCount } : {}),
|
|
55
56
|
}),
|
|
56
57
|
}],
|
|
@@ -96,6 +97,7 @@ export const TOOL_REGISTRY = [
|
|
|
96
97
|
filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
|
|
97
98
|
},
|
|
98
99
|
handler: async ({ filename }) => {
|
|
100
|
+
broadcastWritingFinished(); // Clear any in-progress creation spinner
|
|
99
101
|
const result = switchDocument(filename);
|
|
100
102
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
101
103
|
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
@@ -104,29 +106,101 @@ export const TOOL_REGISTRY = [
|
|
|
104
106
|
},
|
|
105
107
|
{
|
|
106
108
|
name: 'create_document',
|
|
107
|
-
description: 'Create a new document and switch to it. Always provide a title
|
|
109
|
+
description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. Shows a sidebar spinner that persists until populate_document is called — always call populate_document next to add content. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
|
|
108
110
|
schema: {
|
|
109
111
|
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
110
|
-
content: z.any().optional().describe('Initial content: markdown string (preferred) or TipTap JSON doc object. If omitted, document starts empty.'),
|
|
111
112
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
113
|
+
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
114
|
+
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
|
|
115
|
+
},
|
|
116
|
+
handler: async ({ title, path, workspace, container }) => {
|
|
117
|
+
// Resolve workspace/container up front so spinner renders in the right place
|
|
118
|
+
let wsTarget;
|
|
119
|
+
if (workspace) {
|
|
120
|
+
const ws = findOrCreateWorkspace(workspace);
|
|
121
|
+
let containerId = null;
|
|
122
|
+
if (container) {
|
|
123
|
+
const c = findOrCreateContainer(ws.filename, container);
|
|
124
|
+
containerId = c.containerId;
|
|
125
|
+
}
|
|
126
|
+
wsTarget = { wsFilename: ws.filename, containerId };
|
|
127
|
+
broadcastWorkspacesChanged(); // Browser sees container structure before spinner
|
|
128
|
+
}
|
|
129
|
+
broadcastWritingStarted(title || 'Untitled', wsTarget);
|
|
130
|
+
// Yield so the browser receives and renders the placeholder before heavy work
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
132
|
+
try {
|
|
133
|
+
// Lock browser doc-updates: prevents race where browser sends a doc-update
|
|
134
|
+
// for the previous document but server has already switched active doc.
|
|
135
|
+
setAgentLock();
|
|
136
|
+
const result = createDocument(title, undefined, path);
|
|
137
|
+
setMetadata({ agentCreated: true });
|
|
138
|
+
save(); // Persist agentCreated flag to frontmatter
|
|
139
|
+
// Auto-add to workspace if specified (defer sidebar broadcasts to populate_document
|
|
140
|
+
// so the real doc entry doesn't appear alongside the spinner placeholder)
|
|
141
|
+
let wsInfo = '';
|
|
142
|
+
if (wsTarget) {
|
|
143
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
144
|
+
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
145
|
+
}
|
|
146
|
+
// Spinner persists until populate_document is called
|
|
147
|
+
return {
|
|
148
|
+
content: [{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: `Created "${result.title}" (${result.filename})${wsInfo} — empty. Call populate_document to add content.`,
|
|
151
|
+
}],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
broadcastWritingFinished();
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'populate_document',
|
|
162
|
+
description: 'Populate the active document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document.',
|
|
163
|
+
schema: {
|
|
164
|
+
content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
|
|
112
165
|
},
|
|
113
|
-
handler: async ({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
166
|
+
handler: async ({ content }) => {
|
|
167
|
+
try {
|
|
168
|
+
let doc;
|
|
169
|
+
if (typeof content === 'string') {
|
|
170
|
+
doc = { type: 'doc', content: parseMarkdownContent(content) };
|
|
171
|
+
}
|
|
172
|
+
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
173
|
+
doc = content;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
broadcastWritingFinished();
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
setAgentLock(); // Block browser doc-updates during population
|
|
117
182
|
markAllNodesAsPending(doc, 'insert');
|
|
118
183
|
updateDocument(doc);
|
|
119
184
|
save();
|
|
185
|
+
// Broadcast sidebar updates first (deferred from create_document) so the doc
|
|
186
|
+
// entry and spinner removal arrive in the same render cycle
|
|
187
|
+
broadcastDocumentsChanged();
|
|
188
|
+
broadcastWorkspacesChanged();
|
|
189
|
+
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
|
|
190
|
+
broadcastPendingDocsChanged();
|
|
191
|
+
broadcastWritingFinished();
|
|
192
|
+
const wordCount = getWordCount();
|
|
193
|
+
return {
|
|
194
|
+
content: [{
|
|
195
|
+
type: 'text',
|
|
196
|
+
text: `Populated "${getTitle()}" — ${wordCount.toLocaleString()} words`,
|
|
197
|
+
}],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
broadcastWritingFinished();
|
|
202
|
+
throw err;
|
|
120
203
|
}
|
|
121
|
-
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
122
|
-
broadcastPendingDocsChanged();
|
|
123
|
-
const wordCount = getWordCount();
|
|
124
|
-
return {
|
|
125
|
-
content: [{
|
|
126
|
-
type: 'text',
|
|
127
|
-
text: `Created "${result.title}" (${result.filename})${wordCount > 0 ? ` — ${wordCount} words` : ''}`,
|
|
128
|
-
}],
|
|
129
|
-
};
|
|
130
204
|
},
|
|
131
205
|
},
|
|
132
206
|
{
|
|
@@ -143,43 +217,22 @@ export const TOOL_REGISTRY = [
|
|
|
143
217
|
},
|
|
144
218
|
},
|
|
145
219
|
{
|
|
146
|
-
name: '
|
|
147
|
-
description: '
|
|
220
|
+
name: 'delete_document',
|
|
221
|
+
description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool.',
|
|
148
222
|
schema: {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (typeof content === 'string') {
|
|
156
|
-
const parsed = markdownToTiptap(content);
|
|
157
|
-
doc = parsed.document;
|
|
158
|
-
if (!newTitle && parsed.title !== 'Untitled')
|
|
159
|
-
newTitle = parsed.title;
|
|
160
|
-
}
|
|
161
|
-
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
162
|
-
doc = content;
|
|
223
|
+
filename: z.string().describe('Filename of the document to delete (e.g. "My Essay.md")'),
|
|
224
|
+
},
|
|
225
|
+
handler: async ({ filename }) => {
|
|
226
|
+
const result = await deleteDocument(filename);
|
|
227
|
+
if (result.switched && result.newDoc) {
|
|
228
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
163
229
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
230
|
+
broadcastDocumentsChanged();
|
|
231
|
+
let text = `Deleted "${filename}" (moved to trash)`;
|
|
232
|
+
if (result.switched && result.newDoc) {
|
|
233
|
+
text += `. Switched to "${result.newDoc.filename}"`;
|
|
168
234
|
}
|
|
169
|
-
|
|
170
|
-
markAllNodesAsPending(doc, status);
|
|
171
|
-
updateDocument(doc);
|
|
172
|
-
if (newTitle)
|
|
173
|
-
setMetadata({ title: newTitle });
|
|
174
|
-
save();
|
|
175
|
-
broadcastDocumentSwitched(doc, newTitle || getTitle(), getActiveFilename());
|
|
176
|
-
broadcastPendingDocsChanged();
|
|
177
|
-
return {
|
|
178
|
-
content: [{
|
|
179
|
-
type: 'text',
|
|
180
|
-
text: `Document replaced — ${getWordCount().toLocaleString()} words${newTitle ? `, title: "${newTitle}"` : ''}`,
|
|
181
|
-
}],
|
|
182
|
-
};
|
|
235
|
+
return { content: [{ type: 'text', text }] };
|
|
183
236
|
},
|
|
184
237
|
},
|
|
185
238
|
{
|
|
@@ -253,9 +306,26 @@ export const TOOL_REGISTRY = [
|
|
|
253
306
|
return { content: [{ type: 'text', text: `Created workspace "${info.title}" (${info.filename})` }] };
|
|
254
307
|
},
|
|
255
308
|
},
|
|
309
|
+
{
|
|
310
|
+
name: 'delete_workspace',
|
|
311
|
+
description: 'Delete a workspace and all its document files. Files go to OS trash (Recycle Bin / macOS Trash). IMPORTANT: Always confirm with the user before calling this tool.',
|
|
312
|
+
schema: {
|
|
313
|
+
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
314
|
+
},
|
|
315
|
+
handler: async ({ filename }) => {
|
|
316
|
+
const result = await deleteWorkspace(filename);
|
|
317
|
+
broadcastWorkspacesChanged();
|
|
318
|
+
broadcastDocumentsChanged();
|
|
319
|
+
let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
|
|
320
|
+
if (result.skippedExternal.length > 0) {
|
|
321
|
+
text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
|
|
322
|
+
}
|
|
323
|
+
return { content: [{ type: 'text', text }] };
|
|
324
|
+
},
|
|
325
|
+
},
|
|
256
326
|
{
|
|
257
327
|
name: 'get_workspace_structure',
|
|
258
|
-
description: 'Get the full structure of a workspace: tree of containers and docs, tags
|
|
328
|
+
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc tags, plus context (characters, settings, rules). Use to understand workspace organization before writing.',
|
|
259
329
|
schema: {
|
|
260
330
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
261
331
|
},
|
|
@@ -265,7 +335,9 @@ export const TOOL_REGISTRY = [
|
|
|
265
335
|
const lines = [];
|
|
266
336
|
for (const node of nodes) {
|
|
267
337
|
if (node.type === 'doc') {
|
|
268
|
-
|
|
338
|
+
const tags = getDocTagsByFilename(node.file);
|
|
339
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
340
|
+
lines.push(`${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`);
|
|
269
341
|
}
|
|
270
342
|
else {
|
|
271
343
|
lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
|
|
@@ -276,13 +348,6 @@ export const TOOL_REGISTRY = [
|
|
|
276
348
|
}
|
|
277
349
|
const treeLines = renderTree(ws.root, ' ');
|
|
278
350
|
let text = `workspace: "${ws.title}"\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
279
|
-
const tagEntries = Object.entries(ws.tags);
|
|
280
|
-
if (tagEntries.length > 0) {
|
|
281
|
-
text += '\ntags:';
|
|
282
|
-
for (const [tag, files] of tagEntries) {
|
|
283
|
-
text += `\n ${tag}: ${files.join(', ')}`;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
351
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
287
352
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
288
353
|
}
|
|
@@ -350,29 +415,27 @@ export const TOOL_REGISTRY = [
|
|
|
350
415
|
},
|
|
351
416
|
{
|
|
352
417
|
name: 'tag_doc',
|
|
353
|
-
description: 'Add a tag to a document
|
|
418
|
+
description: 'Add a tag to a document. Tags are stored in the document\'s frontmatter — they travel with the file. A doc can have multiple tags.',
|
|
354
419
|
schema: {
|
|
355
|
-
|
|
356
|
-
docFile: z.string().describe('Document filename'),
|
|
420
|
+
docFile: z.string().describe('Document filename (e.g. "Chapter 1.md")'),
|
|
357
421
|
tag: z.string().describe('Tag name to add'),
|
|
358
422
|
},
|
|
359
|
-
handler: async ({
|
|
360
|
-
|
|
361
|
-
|
|
423
|
+
handler: async ({ docFile, tag }) => {
|
|
424
|
+
addDocTag(docFile, tag);
|
|
425
|
+
broadcastDocumentsChanged();
|
|
362
426
|
return { content: [{ type: 'text', text: `Tagged "${docFile}" with [${tag}]` }] };
|
|
363
427
|
},
|
|
364
428
|
},
|
|
365
429
|
{
|
|
366
430
|
name: 'untag_doc',
|
|
367
|
-
description: 'Remove a tag from a document
|
|
431
|
+
description: 'Remove a tag from a document.',
|
|
368
432
|
schema: {
|
|
369
|
-
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
370
433
|
docFile: z.string().describe('Document filename'),
|
|
371
434
|
tag: z.string().describe('Tag name to remove'),
|
|
372
435
|
},
|
|
373
|
-
handler: async ({
|
|
374
|
-
|
|
375
|
-
|
|
436
|
+
handler: async ({ docFile, tag }) => {
|
|
437
|
+
removeDocTag(docFile, tag);
|
|
438
|
+
broadcastDocumentsChanged();
|
|
376
439
|
return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${docFile}"` }] };
|
|
377
440
|
},
|
|
378
441
|
},
|
|
@@ -444,6 +507,15 @@ export function registerPluginTools(tools) {
|
|
|
444
507
|
});
|
|
445
508
|
}
|
|
446
509
|
}
|
|
510
|
+
/** Remove MCP tools by name. Existing MCP stdio sessions won't see removal until reconnect. */
|
|
511
|
+
export function removePluginTools(names) {
|
|
512
|
+
const nameSet = new Set(names);
|
|
513
|
+
for (let i = TOOL_REGISTRY.length - 1; i >= 0; i--) {
|
|
514
|
+
if (nameSet.has(TOOL_REGISTRY[i].name)) {
|
|
515
|
+
TOOL_REGISTRY.splice(i, 1);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
447
519
|
export async function startMcpServer() {
|
|
448
520
|
const server = new McpServer({
|
|
449
521
|
name: 'open-writer',
|