openwriter 0.4.0 → 0.5.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.
- package/dist/client/assets/index-Be3gaGeo.css +1 -0
- package/dist/client/assets/index-BwT1KW6a.js +207 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +105 -8
- package/dist/server/git-sync.js +3 -2
- package/dist/server/helpers.js +17 -4
- package/dist/server/index.js +115 -10
- package/dist/server/markdown-parse.js +12 -0
- package/dist/server/markdown-serialize.js +12 -0
- package/dist/server/mcp.js +96 -19
- package/dist/server/plugin-manager.js +1 -0
- package/dist/server/prompt-debug.js +58 -0
- package/dist/server/state.js +269 -25
- package/dist/server/workspace-routes.js +11 -1
- package/dist/server/workspaces.js +6 -0
- package/dist/server/ws.js +36 -9
- package/package.json +1 -1
- package/skill/SKILL.md +10 -9
- package/dist/client/assets/index-CqeJ7cMy.css +0 -1
- package/dist/client/assets/index-DiDoklNt.js +0 -209
package/dist/server/state.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from 'path';
|
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
|
-
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
11
|
+
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
13
|
const DEFAULT_DOC = {
|
|
14
14
|
type: 'doc',
|
|
@@ -31,7 +31,7 @@ const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
|
|
|
31
31
|
const externalDocs = new Set();
|
|
32
32
|
function persistExternalDocs() {
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
atomicWriteFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]));
|
|
35
35
|
}
|
|
36
36
|
catch { /* best-effort */ }
|
|
37
37
|
}
|
|
@@ -172,6 +172,15 @@ export function getStatus() {
|
|
|
172
172
|
// SETTERS
|
|
173
173
|
// ============================================================================
|
|
174
174
|
export function updateDocument(doc) {
|
|
175
|
+
// Safety: reject dramatically smaller documents (same logic as destructive save check).
|
|
176
|
+
// Prevents stale browser tabs from overwriting the correct in-memory state with
|
|
177
|
+
// corrupted content (e.g. tweet compose view sending 4-node doc vs 40-node original).
|
|
178
|
+
const currentNodes = state.document?.content?.length ?? 0;
|
|
179
|
+
const incomingNodes = doc?.content?.length ?? 0;
|
|
180
|
+
if (currentNodes > 5 && incomingNodes < currentNodes * 0.3) {
|
|
181
|
+
console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
175
184
|
// Preserve pending attrs that the browser doesn't track in its document model.
|
|
176
185
|
// Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
|
|
177
186
|
// Without this, browser overwrites server state and pending info is lost on next save.
|
|
@@ -291,13 +300,11 @@ export function onChanges(listener) {
|
|
|
291
300
|
// ============================================================================
|
|
292
301
|
// generateNodeId imported from helpers.ts
|
|
293
302
|
/**
|
|
294
|
-
* Find a node by ID in
|
|
295
|
-
*
|
|
303
|
+
* Find a node by ID in any document tree.
|
|
304
|
+
* topLevel is used to resolve the "end" sentinel.
|
|
296
305
|
*/
|
|
297
|
-
function
|
|
298
|
-
// Special sentinel: "end" resolves to the last top-level node in the document
|
|
306
|
+
function findNode(nodes, id, topLevel) {
|
|
299
307
|
if (id === 'end') {
|
|
300
|
-
const topLevel = state.document.content;
|
|
301
308
|
if (topLevel && topLevel.length > 0) {
|
|
302
309
|
return { parent: topLevel, index: topLevel.length - 1 };
|
|
303
310
|
}
|
|
@@ -308,36 +315,43 @@ function findNodeInDoc(nodes, id) {
|
|
|
308
315
|
return { parent: nodes, index: i };
|
|
309
316
|
}
|
|
310
317
|
if (nodes[i].content && Array.isArray(nodes[i].content)) {
|
|
311
|
-
const result =
|
|
318
|
+
const result = findNode(nodes[i].content, id, topLevel);
|
|
312
319
|
if (result)
|
|
313
320
|
return result;
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
return null;
|
|
317
324
|
}
|
|
325
|
+
/** Find a node in the active document. */
|
|
326
|
+
function findNodeInDoc(nodes, id) {
|
|
327
|
+
return findNode(nodes, id, state.document.content);
|
|
328
|
+
}
|
|
318
329
|
/**
|
|
319
|
-
*
|
|
320
|
-
* with server-assigned IDs
|
|
330
|
+
* Core change application logic — operates on any document object.
|
|
331
|
+
* Mutates doc in place and returns processed changes with server-assigned IDs.
|
|
321
332
|
*/
|
|
322
|
-
function
|
|
333
|
+
function applyChangesToDoc(doc, changes) {
|
|
323
334
|
const processed = [];
|
|
324
335
|
for (const change of changes) {
|
|
325
336
|
if (change.operation === 'rewrite' && change.nodeId && change.content) {
|
|
326
|
-
const found =
|
|
337
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
327
338
|
if (!found)
|
|
328
339
|
continue;
|
|
329
340
|
const contentArray = Array.isArray(change.content) ? change.content : [change.content];
|
|
330
341
|
const originalNode = structuredClone(found.parent[found.index]);
|
|
342
|
+
// Empty node rewrite → treat as insert (green, not blue)
|
|
343
|
+
const originalText = extractText(originalNode.content || []);
|
|
344
|
+
const isEmptyNode = !originalText.trim();
|
|
331
345
|
// Only store original on first rewrite (preserve baseline for reject)
|
|
332
346
|
const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
|
|
333
|
-
// First node replaces the target (rewrite)
|
|
347
|
+
// First node replaces the target (rewrite or insert if empty)
|
|
334
348
|
const firstNode = {
|
|
335
349
|
...contentArray[0],
|
|
336
350
|
attrs: {
|
|
337
351
|
...contentArray[0].attrs,
|
|
338
352
|
id: change.nodeId,
|
|
339
|
-
pendingStatus: 'rewrite',
|
|
340
|
-
pendingOriginalContent: existingOriginal || originalNode,
|
|
353
|
+
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
354
|
+
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
341
355
|
},
|
|
342
356
|
};
|
|
343
357
|
// Additional nodes get inserted after as pending inserts
|
|
@@ -367,17 +381,20 @@ function applyChangesToDocument(changes) {
|
|
|
367
381
|
}));
|
|
368
382
|
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
369
383
|
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
384
|
+
let resolvedAfterId;
|
|
370
385
|
if (change.nodeId && !change.afterNodeId) {
|
|
371
386
|
// Replace empty node
|
|
372
|
-
const found =
|
|
387
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
373
388
|
if (!found)
|
|
374
389
|
continue;
|
|
375
390
|
found.parent.splice(found.index, 1, ...contentWithIds);
|
|
376
391
|
}
|
|
377
392
|
else if (change.afterNodeId) {
|
|
378
|
-
const found =
|
|
393
|
+
const found = findNode(doc.content, change.afterNodeId, doc.content);
|
|
379
394
|
if (!found)
|
|
380
395
|
continue;
|
|
396
|
+
// Resolve "end" sentinel to actual node ID so browser can find it
|
|
397
|
+
resolvedAfterId = found.parent[found.index]?.attrs?.id;
|
|
381
398
|
found.parent.splice(found.index + 1, 0, ...contentWithIds);
|
|
382
399
|
}
|
|
383
400
|
else {
|
|
@@ -386,11 +403,13 @@ function applyChangesToDocument(changes) {
|
|
|
386
403
|
// Broadcast with server-assigned IDs so browser uses the same IDs
|
|
387
404
|
processed.push({
|
|
388
405
|
...change,
|
|
406
|
+
// Replace "end" with the resolved node ID so browser can look it up
|
|
407
|
+
...(resolvedAfterId && change.afterNodeId === 'end' ? { afterNodeId: resolvedAfterId } : {}),
|
|
389
408
|
content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
|
|
390
409
|
});
|
|
391
410
|
}
|
|
392
411
|
else if (change.operation === 'delete' && change.nodeId) {
|
|
393
|
-
const found =
|
|
412
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
394
413
|
if (!found)
|
|
395
414
|
continue;
|
|
396
415
|
found.parent[found.index] = {
|
|
@@ -403,6 +422,11 @@ function applyChangesToDocument(changes) {
|
|
|
403
422
|
processed.push(change);
|
|
404
423
|
}
|
|
405
424
|
}
|
|
425
|
+
return processed;
|
|
426
|
+
}
|
|
427
|
+
/** Apply changes to the active document singleton. */
|
|
428
|
+
function applyChangesToDocument(changes) {
|
|
429
|
+
const processed = applyChangesToDoc(state.document, changes);
|
|
406
430
|
if (processed.length > 0) {
|
|
407
431
|
state.lastModified = new Date();
|
|
408
432
|
}
|
|
@@ -471,6 +495,15 @@ export function updatePendingCacheForActiveDoc() {
|
|
|
471
495
|
export function removePendingCacheEntry(filename) {
|
|
472
496
|
pendingDocCache.delete(filename);
|
|
473
497
|
}
|
|
498
|
+
/** Set the pending cache entry for a specific filename (for non-active doc population). */
|
|
499
|
+
export function setPendingCacheEntry(filename, count) {
|
|
500
|
+
if (count > 0) {
|
|
501
|
+
pendingDocCache.set(filename, count);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
pendingDocCache.delete(filename);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
474
507
|
/** Populate the pending cache from a full disk scan. Called once on startup. */
|
|
475
508
|
function populatePendingCache() {
|
|
476
509
|
pendingDocCache.clear();
|
|
@@ -502,6 +535,67 @@ function populatePendingCache() {
|
|
|
502
535
|
catch { /* skip unreadable files */ }
|
|
503
536
|
}
|
|
504
537
|
}
|
|
538
|
+
const docCache = new Map(); // key = filePath
|
|
539
|
+
/** Cache the active document's full state, keyed by filePath. Call after save(). */
|
|
540
|
+
export function cacheActiveDocument() {
|
|
541
|
+
if (!state.filePath)
|
|
542
|
+
return;
|
|
543
|
+
let fileMtime = 0;
|
|
544
|
+
try {
|
|
545
|
+
fileMtime = statSync(state.filePath).mtimeMs;
|
|
546
|
+
}
|
|
547
|
+
catch { /* file may not exist yet */ }
|
|
548
|
+
docCache.set(state.filePath, {
|
|
549
|
+
document: structuredClone(state.document),
|
|
550
|
+
metadata: structuredClone(state.metadata),
|
|
551
|
+
title: state.title,
|
|
552
|
+
isTemp: state.isTemp,
|
|
553
|
+
lastModified: state.lastModified,
|
|
554
|
+
docId: state.docId,
|
|
555
|
+
fileMtime,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
|
|
559
|
+
export function getCachedDocument(filePath) {
|
|
560
|
+
const cached = docCache.get(filePath);
|
|
561
|
+
if (!cached)
|
|
562
|
+
return null;
|
|
563
|
+
try {
|
|
564
|
+
const currentMtime = statSync(filePath).mtimeMs;
|
|
565
|
+
if (currentMtime !== cached.fileMtime) {
|
|
566
|
+
// File changed on disk — invalidate cache
|
|
567
|
+
docCache.delete(filePath);
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// File doesn't exist or can't be read — invalidate
|
|
573
|
+
docCache.delete(filePath);
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return cached;
|
|
577
|
+
}
|
|
578
|
+
/** Remove a specific file from the document cache. */
|
|
579
|
+
export function invalidateDocCache(filePath) {
|
|
580
|
+
docCache.delete(filePath);
|
|
581
|
+
}
|
|
582
|
+
/** Update the cache entry for a file after writing changes (without cloning the active state). */
|
|
583
|
+
function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
584
|
+
let fileMtime = 0;
|
|
585
|
+
try {
|
|
586
|
+
fileMtime = statSync(filePath).mtimeMs;
|
|
587
|
+
}
|
|
588
|
+
catch { /* best-effort */ }
|
|
589
|
+
docCache.set(filePath, {
|
|
590
|
+
document: structuredClone(doc),
|
|
591
|
+
metadata: structuredClone(metadata),
|
|
592
|
+
title,
|
|
593
|
+
isTemp,
|
|
594
|
+
lastModified: new Date(),
|
|
595
|
+
docId,
|
|
596
|
+
fileMtime,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
505
599
|
// ============================================================================
|
|
506
600
|
// PENDING DOCUMENT STORE OPERATIONS
|
|
507
601
|
// ============================================================================
|
|
@@ -567,10 +661,20 @@ export function markAllNodesAsPending(doc, status) {
|
|
|
567
661
|
export function getPendingDocInfo() {
|
|
568
662
|
const filenames = [];
|
|
569
663
|
const counts = {};
|
|
664
|
+
const stale = [];
|
|
570
665
|
for (const [filename, count] of pendingDocCache) {
|
|
666
|
+
// Validate file still exists on disk (prunes ghost entries after server restart)
|
|
667
|
+
const filePath = isExternalDoc(filename) ? filename : join(DATA_DIR, filename);
|
|
668
|
+
if (!existsSync(filePath)) {
|
|
669
|
+
stale.push(filename);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
571
672
|
filenames.push(filename);
|
|
572
673
|
counts[filename] = count;
|
|
573
674
|
}
|
|
675
|
+
// Clean up stale entries
|
|
676
|
+
for (const f of stale)
|
|
677
|
+
pendingDocCache.delete(f);
|
|
574
678
|
return { filenames, counts };
|
|
575
679
|
}
|
|
576
680
|
// ============================================================================
|
|
@@ -601,7 +705,7 @@ function writeToDisk() {
|
|
|
601
705
|
catch { /* stat failed, proceed with save */ }
|
|
602
706
|
}
|
|
603
707
|
}
|
|
604
|
-
|
|
708
|
+
atomicWriteFileSync(state.filePath, markdown);
|
|
605
709
|
// Best-effort version snapshot — never blocks saves
|
|
606
710
|
try {
|
|
607
711
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
@@ -661,7 +765,7 @@ export function load() {
|
|
|
661
765
|
state.docId = ensureDocId(state.metadata);
|
|
662
766
|
if (!hadDocId) {
|
|
663
767
|
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
664
|
-
|
|
768
|
+
atomicWriteFileSync(state.filePath, md);
|
|
665
769
|
}
|
|
666
770
|
break;
|
|
667
771
|
}
|
|
@@ -770,7 +874,11 @@ function cleanupEmptyTempFiles() {
|
|
|
770
874
|
try {
|
|
771
875
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
772
876
|
const parsed = markdownToTiptap(raw);
|
|
773
|
-
|
|
877
|
+
// Keep temp files that have meaningful metadata (templates, pending changes, tags)
|
|
878
|
+
const meta = parsed.metadata || {};
|
|
879
|
+
const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
|
|
880
|
+
|| (Array.isArray(meta.tags) && meta.tags.length > 0);
|
|
881
|
+
if (isDocEmpty(parsed.document) && !hasMetadata) {
|
|
774
882
|
unlinkSync(fullPath);
|
|
775
883
|
}
|
|
776
884
|
}
|
|
@@ -841,7 +949,7 @@ export function addDocTag(filename, tag) {
|
|
|
841
949
|
tags.push(tag);
|
|
842
950
|
parsed.metadata.tags = tags;
|
|
843
951
|
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
844
|
-
|
|
952
|
+
atomicWriteFileSync(targetPath, markdown);
|
|
845
953
|
}
|
|
846
954
|
}
|
|
847
955
|
catch { /* best-effort */ }
|
|
@@ -874,7 +982,7 @@ export function removeDocTag(filename, tag) {
|
|
|
874
982
|
tags.splice(idx, 1);
|
|
875
983
|
parsed.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
876
984
|
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
877
|
-
|
|
985
|
+
atomicWriteFileSync(targetPath, markdown);
|
|
878
986
|
}
|
|
879
987
|
}
|
|
880
988
|
catch { /* best-effort */ }
|
|
@@ -899,7 +1007,7 @@ export function saveDocToFile(filename, doc) {
|
|
|
899
1007
|
transferPendingAttrs(parsed.document, doc);
|
|
900
1008
|
}
|
|
901
1009
|
const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
902
|
-
|
|
1010
|
+
atomicWriteFileSync(targetPath, markdown);
|
|
903
1011
|
}
|
|
904
1012
|
catch { /* best-effort */ }
|
|
905
1013
|
}
|
|
@@ -933,8 +1041,144 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
933
1041
|
delete parsed.metadata.agentCreated;
|
|
934
1042
|
}
|
|
935
1043
|
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
936
|
-
|
|
1044
|
+
atomicWriteFileSync(targetPath, markdown);
|
|
937
1045
|
removePendingCacheEntry(filename);
|
|
938
1046
|
}
|
|
939
1047
|
catch { /* best-effort */ }
|
|
940
1048
|
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Populate a non-active document file with content.
|
|
1051
|
+
* Writes directly to disk without touching the active singleton.
|
|
1052
|
+
* Returns { title, wordCount, pendingCount } for the response message.
|
|
1053
|
+
*/
|
|
1054
|
+
/** Count pending nodes in a document tree. */
|
|
1055
|
+
function countPending(nodes) {
|
|
1056
|
+
let count = 0;
|
|
1057
|
+
if (!nodes)
|
|
1058
|
+
return 0;
|
|
1059
|
+
for (const node of nodes) {
|
|
1060
|
+
if (node.attrs?.pendingStatus)
|
|
1061
|
+
count++;
|
|
1062
|
+
if (node.content)
|
|
1063
|
+
count += countPending(node.content);
|
|
1064
|
+
}
|
|
1065
|
+
return count;
|
|
1066
|
+
}
|
|
1067
|
+
/** Write a mutated doc back to disk and update the pending cache. */
|
|
1068
|
+
function flushDocToFile(filename, doc, title, metadata) {
|
|
1069
|
+
const targetPath = resolveDocPath(filename);
|
|
1070
|
+
const markdown = tiptapToMarkdown(doc, title, metadata);
|
|
1071
|
+
atomicWriteFileSync(targetPath, markdown);
|
|
1072
|
+
setPendingCacheEntry(filename, countPending(doc.content));
|
|
1073
|
+
}
|
|
1074
|
+
export function populateDocumentFile(filename, doc) {
|
|
1075
|
+
const targetPath = resolveDocPath(filename);
|
|
1076
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
1077
|
+
const parsed = markdownToTiptap(raw);
|
|
1078
|
+
markAllNodesAsPending(doc, 'insert');
|
|
1079
|
+
flushDocToFile(filename, doc, parsed.title, parsed.metadata);
|
|
1080
|
+
const pendingCount = countPending(doc.content);
|
|
1081
|
+
const text = extractText(doc.content);
|
|
1082
|
+
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
1083
|
+
return { title: parsed.title, wordCount, pendingCount };
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Apply node changes to a non-active document file on disk.
|
|
1087
|
+
* Same logic as applyChanges but without touching the active singleton or broadcasting to browser.
|
|
1088
|
+
*/
|
|
1089
|
+
export function applyChangesToFile(filename, changes) {
|
|
1090
|
+
const targetPath = resolveDocPath(filename);
|
|
1091
|
+
// Try cache first — preserves stable node IDs
|
|
1092
|
+
const cached = getCachedDocument(targetPath);
|
|
1093
|
+
let doc;
|
|
1094
|
+
let title;
|
|
1095
|
+
let metadata;
|
|
1096
|
+
let docId;
|
|
1097
|
+
let isTemp;
|
|
1098
|
+
if (cached) {
|
|
1099
|
+
doc = structuredClone(cached.document);
|
|
1100
|
+
title = cached.title;
|
|
1101
|
+
metadata = cached.metadata;
|
|
1102
|
+
docId = cached.docId;
|
|
1103
|
+
isTemp = cached.isTemp;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
1107
|
+
const parsed = markdownToTiptap(raw);
|
|
1108
|
+
doc = parsed.document;
|
|
1109
|
+
title = parsed.title;
|
|
1110
|
+
metadata = parsed.metadata;
|
|
1111
|
+
docId = metadata.docId || '';
|
|
1112
|
+
isTemp = false;
|
|
1113
|
+
}
|
|
1114
|
+
const processed = applyChangesToDoc(doc, changes);
|
|
1115
|
+
if (processed.length > 0) {
|
|
1116
|
+
flushDocToFile(filename, doc, title, metadata);
|
|
1117
|
+
updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
|
|
1118
|
+
}
|
|
1119
|
+
// Find the last created node ID for chaining inserts
|
|
1120
|
+
let lastNodeId = null;
|
|
1121
|
+
for (let i = processed.length - 1; i >= 0; i--) {
|
|
1122
|
+
const change = processed[i];
|
|
1123
|
+
if (change.content) {
|
|
1124
|
+
const contentArr = Array.isArray(change.content) ? change.content : [change.content];
|
|
1125
|
+
const lastNode = contentArr[contentArr.length - 1];
|
|
1126
|
+
if (lastNode?.attrs?.id) {
|
|
1127
|
+
lastNodeId = lastNode.attrs.id;
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return { count: processed.length, lastNodeId };
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Apply fine-grained text edits to a node in a non-active document file on disk.
|
|
1136
|
+
*/
|
|
1137
|
+
export function applyTextEditsToFile(filename, nodeId, edits) {
|
|
1138
|
+
const targetPath = resolveDocPath(filename);
|
|
1139
|
+
// Try cache first — preserves stable node IDs
|
|
1140
|
+
const cached = getCachedDocument(targetPath);
|
|
1141
|
+
let doc;
|
|
1142
|
+
let title;
|
|
1143
|
+
let metadata;
|
|
1144
|
+
let docId;
|
|
1145
|
+
let isTemp;
|
|
1146
|
+
if (cached) {
|
|
1147
|
+
doc = structuredClone(cached.document);
|
|
1148
|
+
title = cached.title;
|
|
1149
|
+
metadata = cached.metadata;
|
|
1150
|
+
docId = cached.docId;
|
|
1151
|
+
isTemp = cached.isTemp;
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
1155
|
+
const parsed = markdownToTiptap(raw);
|
|
1156
|
+
doc = parsed.document;
|
|
1157
|
+
title = parsed.title;
|
|
1158
|
+
metadata = parsed.metadata;
|
|
1159
|
+
docId = metadata.docId || '';
|
|
1160
|
+
isTemp = false;
|
|
1161
|
+
}
|
|
1162
|
+
const found = findNode(doc.content, nodeId, doc.content);
|
|
1163
|
+
if (!found)
|
|
1164
|
+
return { success: false, error: `Node ${nodeId} not found` };
|
|
1165
|
+
const originalNode = found.parent[found.index];
|
|
1166
|
+
const result = applyTextEditsToNode(originalNode, edits);
|
|
1167
|
+
if (!result)
|
|
1168
|
+
return { success: false, error: 'No edits matched' };
|
|
1169
|
+
result.node.attrs = {
|
|
1170
|
+
...result.node.attrs,
|
|
1171
|
+
pendingTextEdits: result.textEdits,
|
|
1172
|
+
};
|
|
1173
|
+
// Apply as a rewrite to the doc
|
|
1174
|
+
const processed = applyChangesToDoc(doc, [{
|
|
1175
|
+
operation: 'rewrite',
|
|
1176
|
+
nodeId,
|
|
1177
|
+
content: result.node,
|
|
1178
|
+
}]);
|
|
1179
|
+
if (processed.length > 0) {
|
|
1180
|
+
flushDocToFile(filename, doc, title, metadata);
|
|
1181
|
+
updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
|
|
1182
|
+
}
|
|
1183
|
+
return { success: true };
|
|
1184
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Mounted in index.ts to keep the main file lean.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
-
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, reorderContainer, } from './workspaces.js';
|
|
6
|
+
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, } from './workspaces.js';
|
|
7
7
|
export function createWorkspaceRouter(b) {
|
|
8
8
|
const router = Router();
|
|
9
9
|
router.get('/api/workspaces', (_req, res) => {
|
|
@@ -30,6 +30,16 @@ export function createWorkspaceRouter(b) {
|
|
|
30
30
|
res.status(404).json({ error: err.message });
|
|
31
31
|
}
|
|
32
32
|
});
|
|
33
|
+
router.put('/api/workspaces/:filename', (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const ws = renameWorkspace(req.params.filename, req.body.title);
|
|
36
|
+
b.broadcastWorkspacesChanged();
|
|
37
|
+
res.json(ws);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
res.status(400).json({ error: err.message });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
33
43
|
router.delete('/api/workspaces/:filename', async (req, res) => {
|
|
34
44
|
try {
|
|
35
45
|
await deleteWorkspace(req.params.filename);
|
|
@@ -234,6 +234,12 @@ export function renameContainer(wsFile, containerId, name) {
|
|
|
234
234
|
writeWorkspace(wsFile, ws);
|
|
235
235
|
return ws;
|
|
236
236
|
}
|
|
237
|
+
export function renameWorkspace(wsFile, newTitle) {
|
|
238
|
+
const ws = getWorkspace(wsFile);
|
|
239
|
+
ws.title = newTitle;
|
|
240
|
+
writeWorkspace(wsFile, ws);
|
|
241
|
+
return ws;
|
|
242
|
+
}
|
|
237
243
|
export function reorderContainer(wsFile, containerId, afterIdentifier) {
|
|
238
244
|
const ws = getWorkspace(wsFile);
|
|
239
245
|
reorderNode(ws.root, containerId, afterIdentifier);
|
package/dist/server/ws.js
CHANGED
|
@@ -27,7 +27,23 @@ function debouncedBroadcastDocumentsChanged() {
|
|
|
27
27
|
}, 2100);
|
|
28
28
|
}
|
|
29
29
|
export function setupWebSocket(server) {
|
|
30
|
-
const wss = new WebSocketServer({
|
|
30
|
+
const wss = new WebSocketServer({
|
|
31
|
+
server,
|
|
32
|
+
verifyClient: ({ req }) => {
|
|
33
|
+
const origin = req.headers.origin;
|
|
34
|
+
// Allow connections with no origin (non-browser clients like MCP)
|
|
35
|
+
// and localhost origins only (blocks cross-site WebSocket hijacking)
|
|
36
|
+
if (!origin)
|
|
37
|
+
return true;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(origin);
|
|
40
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
31
47
|
// Push agent changes to all browser clients
|
|
32
48
|
onChanges((changes) => {
|
|
33
49
|
const msg = JSON.stringify({ type: 'node-changes', changes });
|
|
@@ -126,20 +142,29 @@ export function setupWebSocket(server) {
|
|
|
126
142
|
try {
|
|
127
143
|
const tmpl = msg.template;
|
|
128
144
|
const url = msg.url;
|
|
129
|
-
// Create
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
// Create named document (dedup handles collisions)
|
|
146
|
+
let title = 'Untitled';
|
|
147
|
+
if (tmpl === 'tweet')
|
|
148
|
+
title = 'Tweet';
|
|
149
|
+
else if (tmpl === 'reply')
|
|
150
|
+
title = 'Reply';
|
|
151
|
+
else if (tmpl === 'quote')
|
|
152
|
+
title = 'Quote Tweet';
|
|
153
|
+
else if (tmpl === 'article')
|
|
154
|
+
title = 'Article';
|
|
155
|
+
const result = createDocument(title);
|
|
156
|
+
// Set template-specific metadata
|
|
132
157
|
if (tmpl === 'tweet') {
|
|
133
|
-
setMetadata({ tweetContext: { mode: 'tweet' }
|
|
158
|
+
setMetadata({ tweetContext: { mode: 'tweet' } });
|
|
134
159
|
}
|
|
135
160
|
else if (tmpl === 'reply') {
|
|
136
|
-
setMetadata({ tweetContext: { url, mode: 'reply' }
|
|
161
|
+
setMetadata({ tweetContext: { url, mode: 'reply' } });
|
|
137
162
|
}
|
|
138
163
|
else if (tmpl === 'quote') {
|
|
139
|
-
setMetadata({ tweetContext: { url, mode: 'quote' }
|
|
164
|
+
setMetadata({ tweetContext: { url, mode: 'quote' } });
|
|
140
165
|
}
|
|
141
166
|
else if (tmpl === 'article') {
|
|
142
|
-
setMetadata({ articleContext: { active: true }
|
|
167
|
+
setMetadata({ articleContext: { active: true } });
|
|
143
168
|
}
|
|
144
169
|
save();
|
|
145
170
|
broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
|
|
@@ -186,6 +211,7 @@ export function setupWebSocket(server) {
|
|
|
186
211
|
}
|
|
187
212
|
stripPendingAttrs();
|
|
188
213
|
save();
|
|
214
|
+
updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
|
|
189
215
|
}
|
|
190
216
|
else {
|
|
191
217
|
// Race path: resolved doc is NOT the active one (server switched away).
|
|
@@ -206,7 +232,8 @@ export function setupWebSocket(server) {
|
|
|
206
232
|
});
|
|
207
233
|
}
|
|
208
234
|
export function broadcastDocumentSwitched(document, title, filename, metadata) {
|
|
209
|
-
const
|
|
235
|
+
const resolvedMeta = metadata ?? getMetadata();
|
|
236
|
+
const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: resolvedMeta });
|
|
210
237
|
for (const ws of clients) {
|
|
211
238
|
if (ws.readyState === WebSocket.OPEN) {
|
|
212
239
|
ws.send(msg);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skill/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: openwriter
|
|
|
3
3
|
description: |
|
|
4
4
|
OpenWriter — the writing surface for AI agents. A markdown-native rich text
|
|
5
5
|
editor where agents write via MCP tools and users accept or reject changes
|
|
6
|
-
in-browser.
|
|
6
|
+
in-browser. 31 MCP tools for document editing, multi-doc workspaces, and
|
|
7
7
|
organization. Tweet compose mode for drafting replies/QTs with pixel-accurate
|
|
8
8
|
X/Twitter UI. Plain .md files on disk — no database, no lock-in.
|
|
9
9
|
|
|
@@ -14,7 +14,7 @@ description: |
|
|
|
14
14
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
15
15
|
metadata:
|
|
16
16
|
author: travsteward
|
|
17
|
-
version: "0.
|
|
17
|
+
version: "0.5.0"
|
|
18
18
|
repository: https://github.com/travsteward/openwriter
|
|
19
19
|
license: MIT
|
|
20
20
|
---
|
|
@@ -38,7 +38,7 @@ Skip to [Writing Strategy](#writing-strategy) below.
|
|
|
38
38
|
|
|
39
39
|
### MCP tools are NOT available (skill-first install)
|
|
40
40
|
|
|
41
|
-
The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the
|
|
41
|
+
The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 31 editing tools.
|
|
42
42
|
|
|
43
43
|
**Step 1:** Tell the user to install globally and add the MCP server:
|
|
44
44
|
|
|
@@ -123,6 +123,7 @@ After editing, tell the user:
|
|
|
123
123
|
| `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
|
|
124
124
|
| `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
|
|
125
125
|
| `move_doc` | Move a document to a different container or root level |
|
|
126
|
+
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
126
127
|
|
|
127
128
|
### Text Operations
|
|
128
129
|
|
|
@@ -134,7 +135,7 @@ After editing, tell the user:
|
|
|
134
135
|
|
|
135
136
|
| Tool | Description |
|
|
136
137
|
|------|-------------|
|
|
137
|
-
| `generate_image` | Generate an image via Gemini
|
|
138
|
+
| `generate_image` | Generate an image via Gemini Nano Banana 2 — optionally set as article cover (requires GEMINI_API_KEY) |
|
|
138
139
|
|
|
139
140
|
### Version Management
|
|
140
141
|
|
|
@@ -153,7 +154,7 @@ OpenWriter has two distinct modes: **editing** existing documents and **creating
|
|
|
153
154
|
|
|
154
155
|
For making changes to existing documents — rewrites, insertions, deletions:
|
|
155
156
|
|
|
156
|
-
- Use `write_to_pad` for all edits
|
|
157
|
+
- Use `write_to_pad` for all edits — **`filename` is required**
|
|
157
158
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
158
159
|
- Always `read_pad` before editing to get fresh node IDs
|
|
159
160
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
@@ -202,7 +203,7 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
|
|
|
202
203
|
```
|
|
203
204
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
204
205
|
2. read_pad → get full document with node IDs
|
|
205
|
-
3. write_to_pad
|
|
206
|
+
3. write_to_pad({ filename: "Doc.md", changes: [...] })
|
|
206
207
|
4. Wait → user accepts/rejects in browser
|
|
207
208
|
```
|
|
208
209
|
|
|
@@ -210,9 +211,9 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
|
|
|
210
211
|
|
|
211
212
|
```
|
|
212
213
|
1. list_documents → see all docs, find target
|
|
213
|
-
2.
|
|
214
|
-
3.
|
|
215
|
-
|
|
214
|
+
2. read_pad → read active doc (or switch_document first)
|
|
215
|
+
3. write_to_pad({ filename: "Target.md", changes: [...] })
|
|
216
|
+
→ edits go to the named file, no view switch needed
|
|
216
217
|
```
|
|
217
218
|
|
|
218
219
|
### Creating new content (two-step)
|