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.
- package/dist/bin/pad.js +66 -9
- package/dist/client/assets/index-BAbqg4Q8.js +210 -0
- package/dist/client/assets/index-BR_sMmFf.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +30 -1
- package/dist/server/documents.js +303 -2
- package/dist/server/index.js +103 -37
- package/dist/server/marks.js +166 -0
- package/dist/server/mcp.js +272 -55
- package/dist/server/state.js +7 -1
- package/dist/server/workspaces.js +16 -0
- package/dist/server/ws.js +18 -3
- package/package.json +2 -4
- package/skill/SKILL.md +34 -2
- package/dist/client/assets/index-Be3gaGeo.css +0 -1
- package/dist/client/assets/index-BwT1KW6a.js +0 -207
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
+
}
|