openwriter 0.2.1 → 0.3.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/README.md +2 -2
- package/dist/bin/pad.js +35 -3
- package/dist/client/assets/index-BLVKwyNi.js +209 -0
- package/dist/client/assets/index-NIq_FmFc.css +1 -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 +131 -62
- package/dist/server/link-routes.js +6 -5
- package/dist/server/mcp-client.js +29 -28
- package/dist/server/mcp.js +229 -79
- package/dist/server/plugin-discovery.js +64 -0
- package/dist/server/plugin-manager.js +155 -0
- package/dist/server/state.js +266 -51
- package/dist/server/tweet-routes.js +98 -0
- package/dist/server/update-check.js +96 -0
- 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 +101 -14
- package/package.json +3 -1
- package/skill/SKILL.md +92 -23
- package/dist/client/assets/index-DNJs7lC-.js +0 -205
- package/dist/client/assets/index-WweytMO1.css +0 -1
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client-mode MCP server:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Client-mode MCP server: lightweight proxy to the running primary server.
|
|
3
|
+
* Zero local imports — fetches tool metadata via HTTP, proxies calls via HTTP.
|
|
4
|
+
* Used when another terminal already owns the port.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
-
import {
|
|
8
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
export async function startMcpClientServer(port) {
|
|
10
|
-
const server = new McpServer({
|
|
11
|
-
name: 'open-writer-client',
|
|
12
|
-
version: '0.2.0',
|
|
13
|
-
});
|
|
14
10
|
const baseUrl = `http://localhost:${port}`;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
11
|
+
// Fetch tool metadata from the primary server
|
|
12
|
+
const res = await fetch(`${baseUrl}/api/mcp-tools`);
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error(`Failed to fetch tools from ${baseUrl}: ${res.status}`);
|
|
15
|
+
const { tools } = await res.json();
|
|
16
|
+
const server = new Server({ name: 'openwriter-client', version: '0.2.0' }, { capabilities: { tools: {} } });
|
|
17
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
18
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
19
|
+
try {
|
|
20
|
+
const callRes = await fetch(`${baseUrl}/api/mcp-call`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ tool: request.params.name, arguments: request.params.arguments }),
|
|
24
|
+
});
|
|
25
|
+
if (!callRes.ok) {
|
|
26
|
+
const text = await callRes.text();
|
|
27
|
+
return { content: [{ type: 'text', text: `Server error (${callRes.status}): ${text}` }] };
|
|
31
28
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
return await callRes.json();
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return { content: [{ type: 'text', text: `Connection error: ${err.message}` }] };
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
console.error(`[MCP-Client] Proxying ${tools.length} tools to ${baseUrl}`);
|
|
35
36
|
const transport = new StdioServerTransport();
|
|
36
37
|
await server.connect(transport);
|
|
37
38
|
}
|
package/dist/server/mcp.js
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
* Uses compact wire format for token efficiency.
|
|
4
4
|
* Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
|
|
5
5
|
*/
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
6
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
11
|
import { z } from 'zod';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { DATA_DIR, ensureDataDir } from './helpers.js';
|
|
13
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
|
|
14
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } 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 } from './workspaces.js';
|
|
17
|
+
import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
|
|
13
18
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
14
19
|
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
15
|
-
import {
|
|
20
|
+
import { getUpdateInfo } from './update-check.js';
|
|
16
21
|
export const TOOL_REGISTRY = [
|
|
17
22
|
{
|
|
18
23
|
name: 'read_pad',
|
|
@@ -26,7 +31,7 @@ export const TOOL_REGISTRY = [
|
|
|
26
31
|
},
|
|
27
32
|
{
|
|
28
33
|
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.',
|
|
34
|
+
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
35
|
schema: {
|
|
31
36
|
changes: z.array(z.object({
|
|
32
37
|
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
@@ -43,7 +48,7 @@ export const TOOL_REGISTRY = [
|
|
|
43
48
|
}
|
|
44
49
|
return resolved;
|
|
45
50
|
});
|
|
46
|
-
const appliedCount = applyChanges(processed);
|
|
51
|
+
const { count: appliedCount, lastNodeId } = applyChanges(processed);
|
|
47
52
|
broadcastPendingDocsChanged();
|
|
48
53
|
return {
|
|
49
54
|
content: [{
|
|
@@ -51,6 +56,7 @@ export const TOOL_REGISTRY = [
|
|
|
51
56
|
text: JSON.stringify({
|
|
52
57
|
success: appliedCount > 0,
|
|
53
58
|
appliedCount,
|
|
59
|
+
...(lastNodeId ? { lastNodeId } : {}),
|
|
54
60
|
...(appliedCount < processed.length ? { skipped: processed.length - appliedCount } : {}),
|
|
55
61
|
}),
|
|
56
62
|
}],
|
|
@@ -62,7 +68,10 @@ export const TOOL_REGISTRY = [
|
|
|
62
68
|
description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
|
|
63
69
|
schema: {},
|
|
64
70
|
handler: async () => {
|
|
65
|
-
|
|
71
|
+
const status = getStatus();
|
|
72
|
+
const latestVersion = getUpdateInfo();
|
|
73
|
+
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
74
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
66
75
|
},
|
|
67
76
|
},
|
|
68
77
|
{
|
|
@@ -96,6 +105,7 @@ export const TOOL_REGISTRY = [
|
|
|
96
105
|
filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
|
|
97
106
|
},
|
|
98
107
|
handler: async ({ filename }) => {
|
|
108
|
+
broadcastWritingFinished(); // Clear any in-progress creation spinner
|
|
99
109
|
const result = switchDocument(filename);
|
|
100
110
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
101
111
|
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
@@ -104,29 +114,117 @@ export const TOOL_REGISTRY = [
|
|
|
104
114
|
},
|
|
105
115
|
{
|
|
106
116
|
name: 'create_document',
|
|
107
|
-
description: 'Create a new document and switch to it. Always provide a title
|
|
117
|
+
description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent 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
118
|
schema: {
|
|
109
119
|
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
120
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
121
|
+
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
122
|
+
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.'),
|
|
123
|
+
empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
|
|
124
|
+
},
|
|
125
|
+
handler: async ({ title, path, workspace, container, empty }) => {
|
|
126
|
+
// Resolve workspace/container up front so spinner renders in the right place
|
|
127
|
+
let wsTarget;
|
|
128
|
+
if (workspace) {
|
|
129
|
+
const ws = findOrCreateWorkspace(workspace);
|
|
130
|
+
let containerId = null;
|
|
131
|
+
if (container) {
|
|
132
|
+
const c = findOrCreateContainer(ws.filename, container);
|
|
133
|
+
containerId = c.containerId;
|
|
134
|
+
}
|
|
135
|
+
wsTarget = { wsFilename: ws.filename, containerId };
|
|
136
|
+
broadcastWorkspacesChanged(); // Browser sees container structure before spinner
|
|
137
|
+
}
|
|
138
|
+
if (!empty) {
|
|
139
|
+
broadcastWritingStarted(title || 'Untitled', wsTarget);
|
|
140
|
+
// Yield so the browser receives and renders the placeholder before heavy work
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// Lock browser doc-updates: prevents race where browser sends a doc-update
|
|
145
|
+
// for the previous document but server has already switched active doc.
|
|
146
|
+
setAgentLock();
|
|
147
|
+
const result = createDocument(title, undefined, path);
|
|
148
|
+
// Auto-add to workspace if specified
|
|
149
|
+
let wsInfo = '';
|
|
150
|
+
if (wsTarget) {
|
|
151
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
152
|
+
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
153
|
+
}
|
|
154
|
+
if (empty) {
|
|
155
|
+
// Immediate switch — no spinner, no populate_document needed
|
|
156
|
+
save();
|
|
157
|
+
broadcastDocumentsChanged();
|
|
158
|
+
broadcastWorkspacesChanged();
|
|
159
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
|
|
160
|
+
return {
|
|
161
|
+
content: [{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
|
|
164
|
+
}],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Two-step flow: spinner persists until populate_document is called
|
|
168
|
+
setMetadata({ agentCreated: true });
|
|
169
|
+
save(); // Persist agentCreated flag to frontmatter
|
|
170
|
+
return {
|
|
171
|
+
content: [{
|
|
172
|
+
type: 'text',
|
|
173
|
+
text: `Created "${result.title}" (${result.filename})${wsInfo} — empty. Call populate_document to add content.`,
|
|
174
|
+
}],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (!empty)
|
|
179
|
+
broadcastWritingFinished();
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'populate_document',
|
|
186
|
+
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.',
|
|
187
|
+
schema: {
|
|
188
|
+
content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
|
|
112
189
|
},
|
|
113
|
-
handler: async ({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
190
|
+
handler: async ({ content }) => {
|
|
191
|
+
try {
|
|
192
|
+
let doc;
|
|
193
|
+
if (typeof content === 'string') {
|
|
194
|
+
doc = { type: 'doc', content: parseMarkdownContent(content) };
|
|
195
|
+
}
|
|
196
|
+
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
197
|
+
doc = content;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
broadcastWritingFinished();
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
setAgentLock(); // Block browser doc-updates during population
|
|
117
206
|
markAllNodesAsPending(doc, 'insert');
|
|
118
207
|
updateDocument(doc);
|
|
119
208
|
save();
|
|
209
|
+
// Broadcast sidebar updates first (deferred from create_document) so the doc
|
|
210
|
+
// entry and spinner removal arrive in the same render cycle
|
|
211
|
+
broadcastDocumentsChanged();
|
|
212
|
+
broadcastWorkspacesChanged();
|
|
213
|
+
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
|
|
214
|
+
broadcastPendingDocsChanged();
|
|
215
|
+
broadcastWritingFinished();
|
|
216
|
+
const wordCount = getWordCount();
|
|
217
|
+
return {
|
|
218
|
+
content: [{
|
|
219
|
+
type: 'text',
|
|
220
|
+
text: `Populated "${getTitle()}" — ${wordCount.toLocaleString()} words`,
|
|
221
|
+
}],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
broadcastWritingFinished();
|
|
226
|
+
throw err;
|
|
120
227
|
}
|
|
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
228
|
},
|
|
131
229
|
},
|
|
132
230
|
{
|
|
@@ -143,43 +241,22 @@ export const TOOL_REGISTRY = [
|
|
|
143
241
|
},
|
|
144
242
|
},
|
|
145
243
|
{
|
|
146
|
-
name: '
|
|
147
|
-
description: '
|
|
244
|
+
name: 'delete_document',
|
|
245
|
+
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
246
|
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;
|
|
247
|
+
filename: z.string().describe('Filename of the document to delete (e.g. "My Essay.md")'),
|
|
248
|
+
},
|
|
249
|
+
handler: async ({ filename }) => {
|
|
250
|
+
const result = await deleteDocument(filename);
|
|
251
|
+
if (result.switched && result.newDoc) {
|
|
252
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
163
253
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
254
|
+
broadcastDocumentsChanged();
|
|
255
|
+
let text = `Deleted "${filename}" (moved to trash)`;
|
|
256
|
+
if (result.switched && result.newDoc) {
|
|
257
|
+
text += `. Switched to "${result.newDoc.filename}"`;
|
|
168
258
|
}
|
|
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
|
-
};
|
|
259
|
+
return { content: [{ type: 'text', text }] };
|
|
183
260
|
},
|
|
184
261
|
},
|
|
185
262
|
{
|
|
@@ -217,6 +294,7 @@ export const TOOL_REGISTRY = [
|
|
|
217
294
|
for (const key of removed)
|
|
218
295
|
delete meta[key];
|
|
219
296
|
save();
|
|
297
|
+
broadcastMetadataChanged(getMetadata());
|
|
220
298
|
if (cleaned.title) {
|
|
221
299
|
broadcastTitleChanged(cleaned.title);
|
|
222
300
|
broadcastDocumentsChanged();
|
|
@@ -253,9 +331,26 @@ export const TOOL_REGISTRY = [
|
|
|
253
331
|
return { content: [{ type: 'text', text: `Created workspace "${info.title}" (${info.filename})` }] };
|
|
254
332
|
},
|
|
255
333
|
},
|
|
334
|
+
{
|
|
335
|
+
name: 'delete_workspace',
|
|
336
|
+
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.',
|
|
337
|
+
schema: {
|
|
338
|
+
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
339
|
+
},
|
|
340
|
+
handler: async ({ filename }) => {
|
|
341
|
+
const result = await deleteWorkspace(filename);
|
|
342
|
+
broadcastWorkspacesChanged();
|
|
343
|
+
broadcastDocumentsChanged();
|
|
344
|
+
let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
|
|
345
|
+
if (result.skippedExternal.length > 0) {
|
|
346
|
+
text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
|
|
347
|
+
}
|
|
348
|
+
return { content: [{ type: 'text', text }] };
|
|
349
|
+
},
|
|
350
|
+
},
|
|
256
351
|
{
|
|
257
352
|
name: 'get_workspace_structure',
|
|
258
|
-
description: 'Get the full structure of a workspace: tree of containers and docs, tags
|
|
353
|
+
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
354
|
schema: {
|
|
260
355
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
261
356
|
},
|
|
@@ -265,7 +360,9 @@ export const TOOL_REGISTRY = [
|
|
|
265
360
|
const lines = [];
|
|
266
361
|
for (const node of nodes) {
|
|
267
362
|
if (node.type === 'doc') {
|
|
268
|
-
|
|
363
|
+
const tags = getDocTagsByFilename(node.file);
|
|
364
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
365
|
+
lines.push(`${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`);
|
|
269
366
|
}
|
|
270
367
|
else {
|
|
271
368
|
lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
|
|
@@ -276,13 +373,6 @@ export const TOOL_REGISTRY = [
|
|
|
276
373
|
}
|
|
277
374
|
const treeLines = renderTree(ws.root, ' ');
|
|
278
375
|
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
376
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
287
377
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
288
378
|
}
|
|
@@ -350,29 +440,27 @@ export const TOOL_REGISTRY = [
|
|
|
350
440
|
},
|
|
351
441
|
{
|
|
352
442
|
name: 'tag_doc',
|
|
353
|
-
description: 'Add a tag to a document
|
|
443
|
+
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
444
|
schema: {
|
|
355
|
-
|
|
356
|
-
docFile: z.string().describe('Document filename'),
|
|
445
|
+
docFile: z.string().describe('Document filename (e.g. "Chapter 1.md")'),
|
|
357
446
|
tag: z.string().describe('Tag name to add'),
|
|
358
447
|
},
|
|
359
|
-
handler: async ({
|
|
360
|
-
|
|
361
|
-
|
|
448
|
+
handler: async ({ docFile, tag }) => {
|
|
449
|
+
addDocTag(docFile, tag);
|
|
450
|
+
broadcastDocumentsChanged();
|
|
362
451
|
return { content: [{ type: 'text', text: `Tagged "${docFile}" with [${tag}]` }] };
|
|
363
452
|
},
|
|
364
453
|
},
|
|
365
454
|
{
|
|
366
455
|
name: 'untag_doc',
|
|
367
|
-
description: 'Remove a tag from a document
|
|
456
|
+
description: 'Remove a tag from a document.',
|
|
368
457
|
schema: {
|
|
369
|
-
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
370
458
|
docFile: z.string().describe('Document filename'),
|
|
371
459
|
tag: z.string().describe('Tag name to remove'),
|
|
372
460
|
},
|
|
373
|
-
handler: async ({
|
|
374
|
-
|
|
375
|
-
|
|
461
|
+
handler: async ({ docFile, tag }) => {
|
|
462
|
+
removeDocTag(docFile, tag);
|
|
463
|
+
broadcastDocumentsChanged();
|
|
376
464
|
return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${docFile}"` }] };
|
|
377
465
|
},
|
|
378
466
|
},
|
|
@@ -429,8 +517,61 @@ export const TOOL_REGISTRY = [
|
|
|
429
517
|
return { content: [{ type: 'text', text }] };
|
|
430
518
|
},
|
|
431
519
|
},
|
|
520
|
+
{
|
|
521
|
+
name: 'generate_image',
|
|
522
|
+
description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
|
|
523
|
+
schema: {
|
|
524
|
+
prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
|
|
525
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Use "5:2" for article covers.'),
|
|
526
|
+
set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
527
|
+
},
|
|
528
|
+
handler: async ({ prompt, aspect_ratio, set_cover }) => {
|
|
529
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
530
|
+
if (!apiKey) {
|
|
531
|
+
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
532
|
+
}
|
|
533
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
534
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
535
|
+
const response = await ai.models.generateImages({
|
|
536
|
+
model: 'imagen-4.0-generate-001',
|
|
537
|
+
prompt,
|
|
538
|
+
config: {
|
|
539
|
+
numberOfImages: 1,
|
|
540
|
+
aspectRatio: (aspect_ratio || '16:9'),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const image = response.generatedImages?.[0];
|
|
544
|
+
if (!image?.image?.imageBytes) {
|
|
545
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
546
|
+
}
|
|
547
|
+
// Save to ~/.openwriter/_images/
|
|
548
|
+
ensureDataDir();
|
|
549
|
+
const imagesDir = join(DATA_DIR, '_images');
|
|
550
|
+
if (!existsSync(imagesDir))
|
|
551
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
552
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
553
|
+
const filePath = join(imagesDir, filename);
|
|
554
|
+
writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
|
|
555
|
+
const src = `/_images/${filename}`;
|
|
556
|
+
// Optionally set as article cover
|
|
557
|
+
if (set_cover) {
|
|
558
|
+
const meta = getMetadata();
|
|
559
|
+
const articleContext = meta.articleContext || {};
|
|
560
|
+
articleContext.coverImage = src;
|
|
561
|
+
setMetadata({ articleContext });
|
|
562
|
+
save();
|
|
563
|
+
broadcastMetadataChanged(getMetadata());
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
content: [{
|
|
567
|
+
type: 'text',
|
|
568
|
+
text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
|
|
569
|
+
}],
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
},
|
|
432
573
|
];
|
|
433
|
-
/** Register MCP tools from plugins.
|
|
574
|
+
/** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
|
|
434
575
|
export function registerPluginTools(tools) {
|
|
435
576
|
for (const tool of tools) {
|
|
436
577
|
TOOL_REGISTRY.push({
|
|
@@ -444,9 +585,18 @@ export function registerPluginTools(tools) {
|
|
|
444
585
|
});
|
|
445
586
|
}
|
|
446
587
|
}
|
|
588
|
+
/** Remove MCP tools by name. Existing MCP stdio sessions won't see removal until reconnect. */
|
|
589
|
+
export function removePluginTools(names) {
|
|
590
|
+
const nameSet = new Set(names);
|
|
591
|
+
for (let i = TOOL_REGISTRY.length - 1; i >= 0; i--) {
|
|
592
|
+
if (nameSet.has(TOOL_REGISTRY[i].name)) {
|
|
593
|
+
TOOL_REGISTRY.splice(i, 1);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
447
597
|
export async function startMcpServer() {
|
|
448
598
|
const server = new McpServer({
|
|
449
|
-
name: '
|
|
599
|
+
name: 'openwriter',
|
|
450
600
|
version: '0.2.0',
|
|
451
601
|
});
|
|
452
602
|
for (const tool of TOOL_REGISTRY) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin discovery: scans the plugins/ directory for available plugins.
|
|
3
|
+
* Reads package.json metadata without importing or loading the plugin code.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname } from 'path';
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
/**
|
|
12
|
+
* Scan the plugins/ directory at the monorepo root.
|
|
13
|
+
* Returns metadata from each plugin's package.json without importing code.
|
|
14
|
+
* Returns [] if plugins/ doesn't exist (e.g. npm install scenario).
|
|
15
|
+
*/
|
|
16
|
+
export function discoverPlugins() {
|
|
17
|
+
// At runtime: dist/server/ → ../../../.. → monorepo root → /plugins/
|
|
18
|
+
const pluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
|
|
19
|
+
if (!existsSync(pluginsDir))
|
|
20
|
+
return [];
|
|
21
|
+
const results = [];
|
|
22
|
+
for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) {
|
|
23
|
+
if (!entry.isDirectory())
|
|
24
|
+
continue;
|
|
25
|
+
const pkgPath = join(pluginsDir, entry.name, 'package.json');
|
|
26
|
+
if (!existsSync(pkgPath))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
30
|
+
if (!pkg.name)
|
|
31
|
+
continue;
|
|
32
|
+
results.push({
|
|
33
|
+
name: pkg.name,
|
|
34
|
+
dirName: entry.name,
|
|
35
|
+
version: pkg.version || '0.0.0',
|
|
36
|
+
description: pkg.description || '',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Skip malformed package.json
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Import a plugin by npm package name and extract its metadata.
|
|
47
|
+
* Returns the plugin's configSchema and full module export.
|
|
48
|
+
*/
|
|
49
|
+
export async function loadPluginModule(name) {
|
|
50
|
+
try {
|
|
51
|
+
const mod = await import(name);
|
|
52
|
+
const plugin = mod.default || mod.plugin || mod;
|
|
53
|
+
if (!plugin.name || !plugin.version)
|
|
54
|
+
return null;
|
|
55
|
+
return {
|
|
56
|
+
plugin,
|
|
57
|
+
configSchema: plugin.configSchema || {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`[PluginDiscovery] Failed to import "${name}":`, err.message);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|