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.
@@ -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-DNJs7lC-.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-WweytMO1.css">
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>
@@ -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
- const nodes = markdownToNodes(content);
213
- return nodes.length === 1 ? nodes[0] : nodes;
212
+ return markdownToNodes(content);
214
213
  }
@@ -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, unlinkSync, readdirSync, statSync, mkdirSync } from 'fs';
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
- // Stable sort: alphabetical by filename (no reordering on switch)
71
- files.sort((a, b) => a.filename.localeCompare(b.filename));
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
- unlinkSync(targetPath);
157
+ await trash(targetPath);
157
158
  }
158
159
  if (isDeletingActive) {
159
160
  const remaining = readdirSync(DATA_DIR)
@@ -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, registerPluginTools } from './mcp.js';
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 { loadPlugins } from './plugin-loader.js';
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
- // Mount workspace CRUD + doc/container/tag routes
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
- // Load plugins
235
- const pluginResult = await loadPlugins(options.plugins || [], options.pluginConfig || {});
236
- for (const err of pluginResult.errors)
237
- console.error(`[Plugin] ${err}`);
238
- for (const { plugin, config } of pluginResult.plugins) {
239
- if (plugin.registerRoutes)
240
- await plugin.registerRoutes({ app, config });
241
- if (plugin.mcpTools)
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
- // Plugin discovery endpoint — client fetches to build dynamic context menu
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
- const descriptors = pluginResult.plugins.map(({ plugin }) => ({
248
- name: plugin.name,
249
- contextMenuItems: plugin.contextMenuItems?.() || [],
250
- }));
251
- res.json({ plugins: descriptors });
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, tagDoc } from './workspaces.js';
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
- tagDoc(wsInfo.filename, filename, 'linked');
65
+ addDocTag(filename, 'linked');
65
66
  }
66
67
  catch {
67
- // Doc not in workspace or already tagged
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
- tagDoc(wsInfo.filename, targetFile, 'linked');
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.broadcastWorkspacesChanged();
109
+ b.broadcastDocumentsChanged();
109
110
  res.json({ success: true, tagged });
110
111
  }
111
112
  catch (err) {
@@ -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, tagDoc, untagDoc, moveDoc } from './workspaces.js';
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 — documents without one show as "Untitled". Saves the current document first. Accepts optional content as markdown string or TipTap JSONif provided, the document is created with that content. Without content, creates an empty document. Use `path` to create the file at a specific location instead of ~/.openwriter/.',
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 ({ title, content, path }) => {
114
- const result = createDocument(title, content, path);
115
- if (content) {
116
- const doc = getDocument();
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: 'replace_document',
147
- description: 'Only for importing external content into a new/blank document. Never use to edit a document you already wrote use write_to_pad instead. Accepts markdown string (preferred) or TipTap JSON. Optionally updates the title.',
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
- content: z.any().describe('New document content: markdown string (preferred) or TipTap JSON { type: "doc", content: [...] }'),
150
- title: z.string().optional().describe('New title for the document. If omitted, title is unchanged (or extracted from markdown frontmatter).'),
151
- },
152
- handler: async ({ content, title }) => {
153
- let doc;
154
- let newTitle = title;
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
- else {
165
- return {
166
- content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }) }],
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
- const status = getWordCount() === 0 ? 'insert' : 'rewrite';
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 index, plus context (characters, settings, rules). Use to understand workspace organization before writing.',
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
- lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
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 in a workspace. Tags are cross-cuttinga doc can have multiple tags.',
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
- workspaceFile: z.string().describe('Workspace manifest filename'),
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 ({ workspaceFile, docFile, tag }) => {
360
- tagDoc(workspaceFile, docFile, tag);
361
- broadcastWorkspacesChanged();
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 in a workspace.',
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 ({ workspaceFile, docFile, tag }) => {
374
- untagDoc(workspaceFile, docFile, tag);
375
- broadcastWorkspacesChanged();
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',