openwriter 0.5.5 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,17 +5,17 @@
5
5
  import { join } from 'path';
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
7
7
  import { randomUUID } from 'crypto';
8
- import { DATA_DIR, ensureDataDir } from './helpers.js';
9
- const MARKS_DIR = join(DATA_DIR, '_marks');
8
+ import { getDataDir, ensureDataDir } from './helpers.js';
9
+ function getMarksDir() { return join(getDataDir(), '_marks'); }
10
10
  function ensureMarksDir() {
11
11
  ensureDataDir();
12
- if (!existsSync(MARKS_DIR))
13
- mkdirSync(MARKS_DIR, { recursive: true });
12
+ if (!existsSync(getMarksDir()))
13
+ mkdirSync(getMarksDir(), { recursive: true });
14
14
  }
15
15
  function markFilePath(filename) {
16
16
  // Sanitize: replace path separators to avoid nested paths
17
17
  const safe = filename.replace(/[/\\]/g, '_');
18
- return join(MARKS_DIR, `${safe}.json`);
18
+ return join(getMarksDir(), `${safe}.json`);
19
19
  }
20
20
  function readMarkFile(filename) {
21
21
  const path = markFilePath(filename);
@@ -63,13 +63,13 @@ export function getMarks(filename) {
63
63
  ensureMarksDir();
64
64
  const result = {};
65
65
  try {
66
- const files = readdirSync(MARKS_DIR);
66
+ const files = readdirSync(getMarksDir());
67
67
  for (const file of files) {
68
68
  if (!file.endsWith('.json'))
69
69
  continue;
70
70
  const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
71
71
  // Read raw to avoid filename roundtrip issues
72
- const path = join(MARKS_DIR, file);
72
+ const path = join(getMarksDir(), file);
73
73
  try {
74
74
  const data = JSON.parse(readFileSync(path, 'utf-8'));
75
75
  if (data.marks.length > 0)
@@ -90,7 +90,7 @@ export function getGlobalMarkSummary(excludeFilename) {
90
90
  let totalMarks = 0;
91
91
  let docCount = 0;
92
92
  try {
93
- const files = readdirSync(MARKS_DIR);
93
+ const files = readdirSync(getMarksDir());
94
94
  for (const file of files) {
95
95
  if (!file.endsWith('.json'))
96
96
  continue;
@@ -99,7 +99,7 @@ export function getGlobalMarkSummary(excludeFilename) {
99
99
  if (file === `${safe}.json`)
100
100
  continue;
101
101
  }
102
- const path = join(MARKS_DIR, file);
102
+ const path = join(getMarksDir(), file);
103
103
  try {
104
104
  const data = JSON.parse(readFileSync(path, 'utf-8'));
105
105
  if (data.marks.length > 0) {
@@ -118,11 +118,11 @@ export function resolveMarks(ids) {
118
118
  const resolved = [];
119
119
  ensureMarksDir();
120
120
  try {
121
- const files = readdirSync(MARKS_DIR);
121
+ const files = readdirSync(getMarksDir());
122
122
  for (const file of files) {
123
123
  if (!file.endsWith('.json'))
124
124
  continue;
125
- const filePath = join(MARKS_DIR, file);
125
+ const filePath = join(getMarksDir(), file);
126
126
  try {
127
127
  const data = JSON.parse(readFileSync(filePath, 'utf-8'));
128
128
  const before = data.marks.length;
@@ -9,7 +9,7 @@ import { randomUUID } from 'crypto';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
- import { DATA_DIR, ensureDataDir, resolveDocPath } from './helpers.js';
12
+ import { getDataDir, ensureDataDir, resolveDocPath } from './helpers.js';
13
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
14
14
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
15
15
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
@@ -24,6 +24,19 @@ import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
24
24
  import { markdownToTiptap } from './markdown.js';
25
25
  import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
26
26
  import { broadcastMarksChanged } from './ws.js';
27
+ /** Map a content type string to its frontmatter metadata object. */
28
+ function resolveTypeMeta(type) {
29
+ switch (type) {
30
+ case 'tweet': return { tweetContext: { mode: 'tweet' } };
31
+ case 'reply': return { tweetContext: { mode: 'reply' } };
32
+ case 'quote': return { tweetContext: { mode: 'quote' } };
33
+ case 'article': return { articleContext: { active: true } };
34
+ case 'linkedin': return { linkedinContext: { active: true } };
35
+ case 'newsletter': return { newsletterContext: { active: true } };
36
+ case 'blog': return { blogContext: { active: true } };
37
+ default: return undefined;
38
+ }
39
+ }
27
40
  /** Check if a document is in tweet compose mode (has tweetContext metadata). */
28
41
  function isTweetDoc(filename) {
29
42
  if (!filename || filename === getActiveFilename()) {
@@ -172,15 +185,24 @@ export const TOOL_REGISTRY = [
172
185
  },
173
186
  {
174
187
  name: 'create_document',
175
- 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).',
188
+ 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). Use content_type to create typed documents (tweet, article, linkedin, etc.) with the correct metadata pre-set.',
176
189
  schema: {
177
190
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
178
191
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
179
192
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
180
193
  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.'),
181
194
  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.'),
182
- },
183
- handler: async ({ title, path, workspace, container, empty }) => {
195
+ content_type: z.string().optional().describe('Content type: tweet, reply, quote, article, linkedin, newsletter, or blog. Sets metadata so the doc is recognized as that type. For reply/quote, use set_metadata after creation to set the target tweet URL.'),
196
+ },
197
+ handler: async ({ title, path, workspace, container, empty, content_type }) => {
198
+ // Default title from content_type if not provided
199
+ if (!title && content_type) {
200
+ const typeDefaults = {
201
+ tweet: 'Tweet', reply: 'Reply', quote: 'Quote Tweet', article: 'Article',
202
+ linkedin: 'LinkedIn Post', newsletter: 'Newsletter', blog: 'Blog Post',
203
+ };
204
+ title = typeDefaults[content_type];
205
+ }
184
206
  // Resolve workspace/container up front so spinner renders in the right place
185
207
  let wsTarget;
186
208
  if (workspace) {
@@ -203,6 +225,13 @@ export const TOOL_REGISTRY = [
203
225
  // Immediate switch — no spinner, no populate_document needed
204
226
  setAgentLock();
205
227
  const result = createDocument(title, undefined, path);
228
+ // Apply type-specific metadata
229
+ if (content_type) {
230
+ const typeMeta = resolveTypeMeta(content_type);
231
+ if (typeMeta) {
232
+ setMetadata(typeMeta);
233
+ }
234
+ }
206
235
  let wsInfo = '';
207
236
  if (wsTarget) {
208
237
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
@@ -212,17 +241,18 @@ export const TOOL_REGISTRY = [
212
241
  save();
213
242
  broadcastDocumentsChanged();
214
243
  broadcastWorkspacesChanged();
215
- broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
244
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
216
245
  return {
217
246
  content: [{
218
247
  type: 'text',
219
- text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
248
+ text: `Created "${result.title}" [${newDocId}]${wsInfo}${content_type ? ` (${content_type})` : ''} — ready.`,
220
249
  }],
221
250
  };
222
251
  }
223
252
  // Two-step flow: create file on disk WITHOUT switching the user's view.
224
253
  // The spinner persists in the sidebar until populate_document is called.
225
- const result = createDocumentFile(title, path);
254
+ const typeMeta = content_type ? resolveTypeMeta(content_type) : undefined;
255
+ const result = createDocumentFile(title, path, typeMeta);
226
256
  let wsInfo = '';
227
257
  if (wsTarget) {
228
258
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
@@ -736,7 +766,7 @@ export const TOOL_REGISTRY = [
736
766
  }
737
767
  // Save to ~/.openwriter/_images/
738
768
  ensureDataDir();
739
- const imagesDir = join(DATA_DIR, '_images');
769
+ const imagesDir = join(getDataDir(), '_images');
740
770
  if (!existsSync(imagesDir))
741
771
  mkdirSync(imagesDir, { recursive: true });
742
772
  const filename = `${randomUUID().slice(0, 8)}.png`;
@@ -756,8 +786,10 @@ export const TOOL_REGISTRY = [
756
786
  }],
757
787
  };
758
788
  }
759
- // Use pre-await metadata snapshot to build the update (not live state)
760
- const articleContext = preAwaitMeta.articleContext || {};
789
+ // Use LIVE metadata for coverImages (not stale pre-await snapshot)
790
+ // so concurrent generate_image calls don't overwrite each other's results
791
+ const liveMeta = getMetadata();
792
+ const articleContext = liveMeta.articleContext || {};
761
793
  let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
762
794
  // Seed with current coverImage if array is empty (first carousel entry)
763
795
  if (existing.length === 0 && articleContext.coverImage) {
@@ -811,7 +843,7 @@ export const TOOL_REGISTRY = [
811
843
  }
812
844
  // Save to ~/.openwriter/_images/
813
845
  ensureDataDir();
814
- const imagesDir = join(DATA_DIR, '_images');
846
+ const imagesDir = join(getDataDir(), '_images');
815
847
  if (!existsSync(imagesDir))
816
848
  mkdirSync(imagesDir, { recursive: true });
817
849
  const imgFilename = `${randomUUID().slice(0, 8)}.png`;
@@ -960,21 +992,59 @@ export const TOOL_REGISTRY = [
960
992
  },
961
993
  },
962
994
  ];
963
- /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
995
+ /** Live MCP server instance used to register plugin tools dynamically. */
996
+ let mcpServerInstance = null;
997
+ /** Convert a JSON Schema properties object to a Zod shape for MCP tool registration. */
998
+ function jsonSchemaToZodShape(inputSchema) {
999
+ const properties = (inputSchema.properties || {});
1000
+ const required = new Set((inputSchema.required || []));
1001
+ const shape = {};
1002
+ for (const [key, prop] of Object.entries(properties)) {
1003
+ let field;
1004
+ switch (prop.type) {
1005
+ case 'number':
1006
+ field = z.number();
1007
+ break;
1008
+ case 'boolean':
1009
+ field = z.boolean();
1010
+ break;
1011
+ default:
1012
+ field = z.string();
1013
+ break;
1014
+ }
1015
+ if (prop.description)
1016
+ field = field.describe(prop.description);
1017
+ if (!required.has(key))
1018
+ field = field.optional();
1019
+ shape[key] = field;
1020
+ }
1021
+ return shape;
1022
+ }
1023
+ /** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
964
1024
  export function registerPluginTools(tools) {
965
1025
  for (const tool of tools) {
966
- TOOL_REGISTRY.push({
1026
+ const zodShape = jsonSchemaToZodShape(tool.inputSchema);
1027
+ const toolDef = {
967
1028
  name: tool.name,
968
1029
  description: tool.description,
969
- schema: {},
1030
+ schema: zodShape,
970
1031
  handler: async (args) => {
971
1032
  const result = await tool.handler(args);
972
1033
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
973
1034
  },
974
- });
1035
+ };
1036
+ TOOL_REGISTRY.push(toolDef);
1037
+ // Register on live MCP server so existing sessions see it immediately
1038
+ if (mcpServerInstance) {
1039
+ mcpServerInstance.tool(tool.name, tool.description, zodShape, toolDef.handler);
1040
+ }
1041
+ }
1042
+ // Notify connected clients that the tool list changed
1043
+ if (mcpServerInstance) {
1044
+ mcpServerInstance.server.sendToolListChanged().catch(() => { });
975
1045
  }
976
1046
  }
977
- /** Remove MCP tools by name. Existing MCP stdio sessions won't see removal until reconnect. */
1047
+ /** Remove MCP tools by name. Notifies connected clients of the change. */
978
1048
  export function removePluginTools(names) {
979
1049
  const nameSet = new Set(names);
980
1050
  for (let i = TOOL_REGISTRY.length - 1; i >= 0; i--) {
@@ -982,6 +1052,9 @@ export function removePluginTools(names) {
982
1052
  TOOL_REGISTRY.splice(i, 1);
983
1053
  }
984
1054
  }
1055
+ if (mcpServerInstance) {
1056
+ mcpServerInstance.server.sendToolListChanged().catch(() => { });
1057
+ }
985
1058
  }
986
1059
  export async function startMcpServer() {
987
1060
  const server = new McpServer({
@@ -991,6 +1064,7 @@ export async function startMcpServer() {
991
1064
  for (const tool of TOOL_REGISTRY) {
992
1065
  server.tool(tool.name, tool.description, tool.schema, tool.handler);
993
1066
  }
1067
+ mcpServerInstance = server;
994
1068
  const transport = new StdioServerTransport();
995
1069
  await server.connect(transport);
996
1070
  }
@@ -5,7 +5,7 @@
5
5
  import { Router as createRouter } from 'express';
6
6
  import { discoverPlugins, loadPluginModule } from './plugin-discovery.js';
7
7
  import { registerPluginTools, removePluginTools } from './mcp.js';
8
- import { readConfig, saveConfig } from './helpers.js';
8
+ import { readConfig, saveConfig, getDataDir } from './helpers.js';
9
9
  import { broadcastPluginsChanged } from './ws.js';
10
10
  export class PluginManager {
11
11
  app;
@@ -55,7 +55,7 @@ export class PluginManager {
55
55
  // Register routes via togglable middleware
56
56
  if (plugin.registerRoutes) {
57
57
  const router = createRouter();
58
- await plugin.registerRoutes({ app: router, config: resolvedConfig });
58
+ await plugin.registerRoutes({ app: router, config: resolvedConfig, dataDir: getDataDir() });
59
59
  managed.router = router;
60
60
  // Wrap in middleware that skips when disabled
61
61
  managed.middleware = (req, res, next) => {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Scheduler routes — proxy all requests to the platform API.
3
+ */
4
+ import { Router } from 'express';
5
+ import { platformFetch, isAuthenticated } from './connections.js';
6
+ export function createSchedulerRouter() {
7
+ const router = Router();
8
+ function proxy(path, method = 'GET') {
9
+ return async (req, res) => {
10
+ try {
11
+ if (!isAuthenticated()) {
12
+ res.json({ error: 'Not authenticated' });
13
+ return;
14
+ }
15
+ const options = { method };
16
+ if (method !== 'GET' && method !== 'DELETE') {
17
+ options.body = JSON.stringify(req.body);
18
+ }
19
+ const upstream = await platformFetch(path, options);
20
+ const data = await upstream.json();
21
+ if (!upstream.ok) {
22
+ res.status(upstream.status).json(data);
23
+ return;
24
+ }
25
+ res.json(data);
26
+ }
27
+ catch (err) {
28
+ res.status(500).json({ error: err.message });
29
+ }
30
+ };
31
+ }
32
+ // Slots
33
+ router.get('/api/scheduler/slots', proxy('/scheduler/slots'));
34
+ router.post('/api/scheduler/slots', proxy('/scheduler/slots', 'POST'));
35
+ router.patch('/api/scheduler/slots/:id', async (req, res) => {
36
+ try {
37
+ if (!isAuthenticated()) {
38
+ res.json({ error: 'Not authenticated' });
39
+ return;
40
+ }
41
+ const upstream = await platformFetch(`/scheduler/slots/${req.params.id}`, {
42
+ method: 'PATCH',
43
+ body: JSON.stringify(req.body),
44
+ });
45
+ const data = await upstream.json();
46
+ if (!upstream.ok) {
47
+ res.status(upstream.status).json(data);
48
+ return;
49
+ }
50
+ res.json(data);
51
+ }
52
+ catch (err) {
53
+ res.status(500).json({ error: err.message });
54
+ }
55
+ });
56
+ router.delete('/api/scheduler/slots/:id', async (req, res) => {
57
+ try {
58
+ if (!isAuthenticated()) {
59
+ res.json({ error: 'Not authenticated' });
60
+ return;
61
+ }
62
+ const upstream = await platformFetch(`/scheduler/slots/${req.params.id}`, { method: 'DELETE' });
63
+ const data = await upstream.json();
64
+ if (!upstream.ok) {
65
+ res.status(upstream.status).json(data);
66
+ return;
67
+ }
68
+ res.json(data);
69
+ }
70
+ catch (err) {
71
+ res.status(500).json({ error: err.message });
72
+ }
73
+ });
74
+ // Queue
75
+ router.get('/api/scheduler/queue', proxy('/scheduler/queue'));
76
+ router.post('/api/scheduler/queue', proxy('/scheduler/queue', 'POST'));
77
+ router.patch('/api/scheduler/queue/:id', async (req, res) => {
78
+ try {
79
+ if (!isAuthenticated()) {
80
+ res.json({ error: 'Not authenticated' });
81
+ return;
82
+ }
83
+ const upstream = await platformFetch(`/scheduler/queue/${req.params.id}`, {
84
+ method: 'PATCH',
85
+ body: JSON.stringify(req.body),
86
+ });
87
+ const data = await upstream.json();
88
+ if (!upstream.ok) {
89
+ res.status(upstream.status).json(data);
90
+ return;
91
+ }
92
+ res.json(data);
93
+ }
94
+ catch (err) {
95
+ res.status(500).json({ error: err.message });
96
+ }
97
+ });
98
+ router.delete('/api/scheduler/queue/:id', async (req, res) => {
99
+ try {
100
+ if (!isAuthenticated()) {
101
+ res.json({ error: 'Not authenticated' });
102
+ return;
103
+ }
104
+ const upstream = await platformFetch(`/scheduler/queue/${req.params.id}`, { method: 'DELETE' });
105
+ const data = await upstream.json();
106
+ if (!upstream.ok) {
107
+ res.status(upstream.status).json(data);
108
+ return;
109
+ }
110
+ res.json(data);
111
+ }
112
+ catch (err) {
113
+ res.status(500).json({ error: err.message });
114
+ }
115
+ });
116
+ // History
117
+ router.get('/api/scheduler/history', proxy('/scheduler/history'));
118
+ // Available connections for scheduler
119
+ router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
120
+ return router;
121
+ }