openwriter 0.5.1 → 0.5.3

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,13 +7,12 @@ import { createServer } from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
9
  import { existsSync, readFileSync } from 'fs';
10
- import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
14
  import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
15
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs } from './documents.js';
16
- import { writePromptDebug } from './prompt-debug.js';
15
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
17
16
  import { createWorkspaceRouter } from './workspace-routes.js';
18
17
  import { createLinkRouter } from './link-routes.js';
19
18
  import { createTweetRouter } from './tweet-routes.js';
@@ -27,6 +26,7 @@ import { createImageRouter } from './image-upload.js';
27
26
  import { createExportRouter } from './export-routes.js';
28
27
  import { PluginManager } from './plugin-manager.js';
29
28
  import { checkForUpdate } from './update-check.js';
29
+ import { addMark, getMarks, resolveMarks } from './marks.js';
30
30
  const __filename = fileURLToPath(import.meta.url);
31
31
  const __dirname = dirname(__filename);
32
32
  export async function startHttpServer(options = {}) {
@@ -55,7 +55,14 @@ export async function startHttpServer(options = {}) {
55
55
  res.status(404).json({ error: `Unknown tool: ${toolName}` });
56
56
  return;
57
57
  }
58
- const result = await tool.handler(args || {});
58
+ // Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
59
+ const schema = z.object(tool.schema);
60
+ const parsed = schema.safeParse(args || {});
61
+ if (!parsed.success) {
62
+ res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
63
+ return;
64
+ }
65
+ const result = await tool.handler(parsed.data);
59
66
  res.json(result);
60
67
  }
61
68
  catch (err) {
@@ -222,6 +229,39 @@ export async function startHttpServer(options = {}) {
222
229
  res.status(500).json({ error: err.message });
223
230
  }
224
231
  });
232
+ app.get('/api/documents/archived', (_req, res) => {
233
+ res.json(listArchivedDocuments());
234
+ });
235
+ app.get('/api/documents/search', (req, res) => {
236
+ const q = req.query.q || '';
237
+ const includeArchived = req.query.archived === 'true';
238
+ res.json(searchDocuments(q, includeArchived));
239
+ });
240
+ app.post('/api/documents/:filename/archive', (req, res) => {
241
+ try {
242
+ removeDocFromAllWorkspaces(req.params.filename);
243
+ const result = archiveDocument(req.params.filename);
244
+ if (result.switched && result.newDoc) {
245
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
246
+ }
247
+ broadcastDocumentsChanged();
248
+ broadcastWorkspacesChanged();
249
+ res.json(result);
250
+ }
251
+ catch (err) {
252
+ res.status(400).json({ error: err.message });
253
+ }
254
+ });
255
+ app.post('/api/documents/:filename/unarchive', (req, res) => {
256
+ try {
257
+ const result = unarchiveDocument(req.params.filename);
258
+ broadcastDocumentsChanged();
259
+ res.json(result);
260
+ }
261
+ catch (err) {
262
+ res.status(400).json({ error: err.message });
263
+ }
264
+ });
225
265
  app.get('/api/documents/:filename/content', (req, res) => {
226
266
  try {
227
267
  const targetPath = resolveDocPath(req.params.filename);
@@ -296,6 +336,45 @@ export async function startHttpServer(options = {}) {
296
336
  res.status(400).json({ error: err.message });
297
337
  }
298
338
  });
339
+ // Agent marks
340
+ app.post('/api/marks', (req, res) => {
341
+ try {
342
+ const { filename, text, note, nodeId } = req.body;
343
+ if (!filename || !text || !nodeId) {
344
+ res.status(400).json({ error: 'filename, text, and nodeId are required' });
345
+ return;
346
+ }
347
+ const mark = addMark(filename, text, note || '', nodeId);
348
+ broadcastMarksChanged(filename);
349
+ res.json({ success: true, mark });
350
+ }
351
+ catch (err) {
352
+ res.status(500).json({ error: err.message });
353
+ }
354
+ });
355
+ app.get('/api/marks/:filename', (req, res) => {
356
+ try {
357
+ const marks = getMarks(req.params.filename);
358
+ res.json({ marks: marks[req.params.filename] || [] });
359
+ }
360
+ catch (err) {
361
+ res.status(500).json({ error: err.message });
362
+ }
363
+ });
364
+ app.delete('/api/marks', (req, res) => {
365
+ try {
366
+ const { ids } = req.body;
367
+ if (!Array.isArray(ids)) {
368
+ res.status(400).json({ error: 'ids must be an array' });
369
+ return;
370
+ }
371
+ const resolved = resolveMarks(ids);
372
+ res.json({ success: true, resolved });
373
+ }
374
+ catch (err) {
375
+ res.status(500).json({ error: err.message });
376
+ }
377
+ });
299
378
  // Mount workspace CRUD + doc/container routes
300
379
  app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
301
380
  // Mount link-doc routes (create-link-doc, auto-tag-link)
@@ -321,22 +400,6 @@ export async function startHttpServer(options = {}) {
321
400
  res.status(500).json({ error: err.message });
322
401
  }
323
402
  });
324
- // Prompt debug: write full prompt to a timestamped .md file for inspection
325
- app.post('/api/prompt-debug', (req, res) => {
326
- try {
327
- const { action, debug, metadata } = req.body;
328
- if (!debug) {
329
- res.status(400).json({ error: 'debug payload is required' });
330
- return;
331
- }
332
- const filename = writePromptDebug(action, debug, metadata);
333
- broadcastDocumentsChanged();
334
- res.json({ success: true, filename });
335
- }
336
- catch (err) {
337
- res.status(500).json({ error: err.message });
338
- }
339
- });
340
403
  // Google Doc import
341
404
  app.post('/api/import/gdoc', (req, res) => {
342
405
  try {
@@ -478,28 +541,31 @@ export async function startHttpServer(options = {}) {
478
541
  res.sendFile(join(clientDir, 'index.html'));
479
542
  });
480
543
  }
481
- else {
482
- // Dev mode: proxy to Vite
483
- app.get('/', (_req, res) => {
484
- res.send(`
485
- <html>
486
- <body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
487
- <div style="text-align:center">
488
- <h2>OpenWriter Server Running</h2>
489
- <p>In development, run <code>npm run dev:client</code> and visit <a href="http://localhost:5173">localhost:5173</a></p>
490
- </div>
491
- </body>
492
- </html>
493
- `);
494
- });
495
- }
496
544
  const server = createServer(app);
497
545
  // Setup WebSocket on same server
498
546
  setupWebSocket(server);
499
547
  // Broadcast agent status now that WS is ready
500
548
  broadcastAgentStatus(true);
501
- server.listen(port, '127.0.0.1', () => {
502
- console.log(`OpenWriter running at http://localhost:${port}`);
549
+ await new Promise((resolve, reject) => {
550
+ server.on('error', (err) => {
551
+ if (err.code === 'EADDRINUSE') {
552
+ console.error(`[HTTP] Port ${port} in use — retrying in 2s...`);
553
+ setTimeout(() => {
554
+ server.listen(port, '127.0.0.1', () => {
555
+ console.log(`OpenWriter running at http://localhost:${port}`);
556
+ resolve();
557
+ });
558
+ }, 2000);
559
+ }
560
+ else {
561
+ console.error(`[HTTP] Server error:`, err);
562
+ reject(err);
563
+ }
564
+ });
565
+ server.listen(port, '127.0.0.1', () => {
566
+ console.log(`OpenWriter running at http://localhost:${port}`);
567
+ resolve();
568
+ });
503
569
  });
504
570
  // Open browser unless --no-open or running as MCP stdio pipe
505
571
  const isMcpStdio = !process.stdout.isTTY;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Agent Marks: sidecar JSON storage for inline user feedback.
3
+ * Each document gets a sidecar file at DATA_DIR/_marks/{filename}.json.
4
+ */
5
+ import { join } from 'path';
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
7
+ import { randomUUID } from 'crypto';
8
+ import { DATA_DIR, ensureDataDir } from './helpers.js';
9
+ const MARKS_DIR = join(DATA_DIR, '_marks');
10
+ function ensureMarksDir() {
11
+ ensureDataDir();
12
+ if (!existsSync(MARKS_DIR))
13
+ mkdirSync(MARKS_DIR, { recursive: true });
14
+ }
15
+ function markFilePath(filename) {
16
+ // Sanitize: replace path separators to avoid nested paths
17
+ const safe = filename.replace(/[/\\]/g, '_');
18
+ return join(MARKS_DIR, `${safe}.json`);
19
+ }
20
+ function readMarkFile(filename) {
21
+ const path = markFilePath(filename);
22
+ if (!existsSync(path))
23
+ return { marks: [] };
24
+ try {
25
+ return JSON.parse(readFileSync(path, 'utf-8'));
26
+ }
27
+ catch {
28
+ return { marks: [] };
29
+ }
30
+ }
31
+ function writeMarkFile(filename, data) {
32
+ ensureMarksDir();
33
+ const path = markFilePath(filename);
34
+ if (data.marks.length === 0) {
35
+ // Clean up empty sidecar files
36
+ if (existsSync(path))
37
+ unlinkSync(path);
38
+ return;
39
+ }
40
+ writeFileSync(path, JSON.stringify(data, null, 2));
41
+ }
42
+ export function addMark(filename, text, note, nodeId) {
43
+ const data = readMarkFile(filename);
44
+ const mark = {
45
+ id: randomUUID().slice(0, 8),
46
+ text,
47
+ note,
48
+ nodeId,
49
+ createdAt: new Date().toISOString(),
50
+ };
51
+ data.marks.push(mark);
52
+ writeMarkFile(filename, data);
53
+ return mark;
54
+ }
55
+ export function getMarks(filename) {
56
+ if (filename) {
57
+ const data = readMarkFile(filename);
58
+ if (data.marks.length === 0)
59
+ return {};
60
+ return { [filename]: data.marks };
61
+ }
62
+ // All docs: scan _marks directory
63
+ ensureMarksDir();
64
+ const result = {};
65
+ try {
66
+ const files = readdirSync(MARKS_DIR);
67
+ for (const file of files) {
68
+ if (!file.endsWith('.json'))
69
+ continue;
70
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
71
+ // Read raw to avoid filename roundtrip issues
72
+ const path = join(MARKS_DIR, file);
73
+ try {
74
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
75
+ if (data.marks.length > 0)
76
+ result[docFilename] = data.marks;
77
+ }
78
+ catch { /* skip corrupt files */ }
79
+ }
80
+ }
81
+ catch { /* dir doesn't exist yet */ }
82
+ return result;
83
+ }
84
+ export function getMarkCount(filename) {
85
+ return readMarkFile(filename).marks.length;
86
+ }
87
+ /** Count marks across all documents, optionally excluding one filename. */
88
+ export function getGlobalMarkSummary(excludeFilename) {
89
+ ensureMarksDir();
90
+ let totalMarks = 0;
91
+ let docCount = 0;
92
+ try {
93
+ const files = readdirSync(MARKS_DIR);
94
+ for (const file of files) {
95
+ if (!file.endsWith('.json'))
96
+ continue;
97
+ if (excludeFilename) {
98
+ const safe = excludeFilename.replace(/[/\\]/g, '_');
99
+ if (file === `${safe}.json`)
100
+ continue;
101
+ }
102
+ const path = join(MARKS_DIR, file);
103
+ try {
104
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
105
+ if (data.marks.length > 0) {
106
+ totalMarks += data.marks.length;
107
+ docCount++;
108
+ }
109
+ }
110
+ catch { /* skip */ }
111
+ }
112
+ }
113
+ catch { /* dir doesn't exist */ }
114
+ return { totalMarks, docCount };
115
+ }
116
+ export function resolveMarks(ids) {
117
+ const idSet = new Set(ids);
118
+ const resolved = [];
119
+ ensureMarksDir();
120
+ try {
121
+ const files = readdirSync(MARKS_DIR);
122
+ for (const file of files) {
123
+ if (!file.endsWith('.json'))
124
+ continue;
125
+ const filePath = join(MARKS_DIR, file);
126
+ try {
127
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
128
+ const before = data.marks.length;
129
+ data.marks = data.marks.filter((m) => {
130
+ if (idSet.has(m.id)) {
131
+ resolved.push(m.id);
132
+ return false;
133
+ }
134
+ return true;
135
+ });
136
+ if (data.marks.length !== before) {
137
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
138
+ writeMarkFile(docFilename, data);
139
+ }
140
+ }
141
+ catch { /* skip */ }
142
+ }
143
+ }
144
+ catch { /* dir doesn't exist */ }
145
+ return resolved;
146
+ }
147
+ export function pruneStaleMarks(filename, validNodeIds) {
148
+ const data = readMarkFile(filename);
149
+ if (data.marks.length === 0)
150
+ return 0;
151
+ const validSet = new Set(validNodeIds);
152
+ const before = data.marks.length;
153
+ data.marks = data.marks.filter((m) => validSet.has(m.nodeId));
154
+ const pruned = before - data.marks.length;
155
+ if (pruned > 0)
156
+ writeMarkFile(filename, data);
157
+ return pruned;
158
+ }
159
+ /** Rename a mark sidecar file when a document is renamed. */
160
+ export function renameMark(oldFilename, newFilename) {
161
+ const oldPath = markFilePath(oldFilename);
162
+ if (!existsSync(oldPath))
163
+ return;
164
+ const newPath = markFilePath(newFilename);
165
+ renameSync(oldPath, newPath);
166
+ }