openwriter 0.5.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/server/documents.js +24 -1
- package/dist/server/mcp.js +87 -10
- package/dist/server/state.js +247 -16
- package/dist/server/workspace-routes.js +11 -1
- package/dist/server/workspaces.js +6 -0
- package/package.json +1 -1
- package/skill/SKILL.md +9 -8
package/dist/server/documents.js
CHANGED
|
@@ -9,7 +9,7 @@ import matter from 'gray-matter';
|
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
|
-
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, } from './state.js';
|
|
12
|
+
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, } from './state.js';
|
|
13
13
|
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
15
|
const DOC_ORDER_FILE = join(DATA_DIR, '_doc-order.json');
|
|
@@ -110,6 +110,8 @@ export function switchDocument(filename) {
|
|
|
110
110
|
// Cancel any pending debounced save, then save current doc immediately.
|
|
111
111
|
cancelDebouncedSave();
|
|
112
112
|
save();
|
|
113
|
+
// Cache current doc before switching (preserves node IDs)
|
|
114
|
+
cacheActiveDocument();
|
|
113
115
|
// Read target from disk — markdownToTiptap rehydrates pending state
|
|
114
116
|
const targetPath = resolveDocPath(filename);
|
|
115
117
|
if (!existsSync(targetPath)) {
|
|
@@ -119,6 +121,12 @@ export function switchDocument(filename) {
|
|
|
119
121
|
if (isExternalDoc(filename)) {
|
|
120
122
|
registerExternalDoc(targetPath);
|
|
121
123
|
}
|
|
124
|
+
// Check cache first — preserves stable node IDs across switches
|
|
125
|
+
const cached = getCachedDocument(targetPath);
|
|
126
|
+
if (cached) {
|
|
127
|
+
setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
128
|
+
return { document: getDocument(), title: getTitle(), filename };
|
|
129
|
+
}
|
|
122
130
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
123
131
|
const parsed = markdownToTiptap(raw);
|
|
124
132
|
const mtime = new Date(statSync(targetPath).mtimeMs);
|
|
@@ -132,6 +140,8 @@ export function createDocument(title, content, path) {
|
|
|
132
140
|
// Cancel any pending debounced save, then save current doc immediately
|
|
133
141
|
cancelDebouncedSave();
|
|
134
142
|
save();
|
|
143
|
+
// Cache current doc before switching to new one
|
|
144
|
+
cacheActiveDocument();
|
|
135
145
|
const docTitle = title || 'Untitled';
|
|
136
146
|
let filePath;
|
|
137
147
|
let isTemp;
|
|
@@ -190,6 +200,8 @@ export function createDocument(title, content, path) {
|
|
|
190
200
|
export async function deleteDocument(filename) {
|
|
191
201
|
ensureDataDir();
|
|
192
202
|
const targetPath = resolveDocPath(filename);
|
|
203
|
+
// Invalidate cache for deleted doc
|
|
204
|
+
invalidateDocCache(targetPath);
|
|
193
205
|
// Unregister if external
|
|
194
206
|
if (isExternalDoc(filename)) {
|
|
195
207
|
unregisterExternalDoc(targetPath);
|
|
@@ -222,6 +234,8 @@ export function reloadDocument() {
|
|
|
222
234
|
if (!existsSync(filePath)) {
|
|
223
235
|
throw new Error('Active document file not found on disk');
|
|
224
236
|
}
|
|
237
|
+
// Force fresh parse — invalidate any cached version
|
|
238
|
+
invalidateDocCache(filePath);
|
|
225
239
|
const filename = filePath.split(/[/\\]/).pop();
|
|
226
240
|
const raw = readFileSync(filePath, 'utf-8');
|
|
227
241
|
const parsed = markdownToTiptap(raw);
|
|
@@ -254,10 +268,19 @@ export function openFile(fullPath) {
|
|
|
254
268
|
// Cancel any pending debounced save, then save current doc immediately
|
|
255
269
|
cancelDebouncedSave();
|
|
256
270
|
save();
|
|
271
|
+
// Cache current doc before switching
|
|
272
|
+
cacheActiveDocument();
|
|
257
273
|
// Register as external if not in DATA_DIR
|
|
258
274
|
if (isExternalDoc(fullPath)) {
|
|
259
275
|
registerExternalDoc(fullPath);
|
|
260
276
|
}
|
|
277
|
+
// Check cache first — preserves stable node IDs
|
|
278
|
+
const cached = getCachedDocument(fullPath);
|
|
279
|
+
if (cached) {
|
|
280
|
+
setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
|
|
281
|
+
const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
|
|
282
|
+
return { document: getDocument(), title: getTitle(), filename };
|
|
283
|
+
}
|
|
261
284
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
262
285
|
const parsed = markdownToTiptap(raw);
|
|
263
286
|
const mtime = new Date(statSync(fullPath).mtimeMs);
|
package/dist/server/mcp.js
CHANGED
|
@@ -10,10 +10,10 @@ 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
12
|
import { DATA_DIR, ensureDataDir } from './helpers.js';
|
|
13
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, getDocId, getFilePath, } from './state.js';
|
|
14
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
|
|
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
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename, updateDocumentTitle } from './documents.js';
|
|
15
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';
|
|
16
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, renameWorkspace, renameContainer } from './workspaces.js';
|
|
17
17
|
import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
|
|
18
18
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
19
19
|
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
@@ -33,7 +33,7 @@ export const TOOL_REGISTRY = [
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
name: 'write_to_pad',
|
|
36
|
-
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.',
|
|
36
|
+
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. Always specify filename — edits target that file directly without switching the user\'s view.',
|
|
37
37
|
schema: {
|
|
38
38
|
changes: z.array(z.object({
|
|
39
39
|
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
@@ -41,8 +41,9 @@ export const TOOL_REGISTRY = [
|
|
|
41
41
|
afterNodeId: z.string().optional(),
|
|
42
42
|
content: z.any().optional(),
|
|
43
43
|
})).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
|
|
44
|
+
filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required — identifies which document to edit.'),
|
|
44
45
|
},
|
|
45
|
-
handler: async ({ changes }) => {
|
|
46
|
+
handler: async ({ changes, filename }) => {
|
|
46
47
|
const processed = changes.map((change) => {
|
|
47
48
|
const resolved = { ...change };
|
|
48
49
|
if (typeof resolved.content === 'string') {
|
|
@@ -50,6 +51,22 @@ export const TOOL_REGISTRY = [
|
|
|
50
51
|
}
|
|
51
52
|
return resolved;
|
|
52
53
|
});
|
|
54
|
+
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
55
|
+
if (targetIsNonActive) {
|
|
56
|
+
const { count: appliedCount, lastNodeId } = applyChangesToFile(filename, processed);
|
|
57
|
+
broadcastPendingDocsChanged();
|
|
58
|
+
return {
|
|
59
|
+
content: [{
|
|
60
|
+
type: 'text',
|
|
61
|
+
text: JSON.stringify({
|
|
62
|
+
success: appliedCount > 0,
|
|
63
|
+
appliedCount,
|
|
64
|
+
...(lastNodeId ? { lastNodeId } : {}),
|
|
65
|
+
...(appliedCount < processed.length ? { skipped: processed.length - appliedCount } : {}),
|
|
66
|
+
}),
|
|
67
|
+
}],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
53
70
|
const { count: appliedCount, lastNodeId } = applyChanges(processed);
|
|
54
71
|
// broadcastPendingDocsChanged() already fires via onChanges listener in ws.ts
|
|
55
72
|
return {
|
|
@@ -187,11 +204,12 @@ export const TOOL_REGISTRY = [
|
|
|
187
204
|
},
|
|
188
205
|
{
|
|
189
206
|
name: 'populate_document',
|
|
190
|
-
description: 'Populate
|
|
207
|
+
description: 'Populate a 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. Pass the filename from create_document\'s response to ensure content goes to the right doc even if the user switched away.',
|
|
191
208
|
schema: {
|
|
192
209
|
content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
|
|
210
|
+
filename: z.string().optional().describe('Target filename (e.g. "My Essay.md"). If provided and differs from the active doc, writes directly to disk without switching the user\'s view. Recommended — prevents race conditions when the user navigates during content generation.'),
|
|
193
211
|
},
|
|
194
|
-
handler: async ({ content }) => {
|
|
212
|
+
handler: async ({ content, filename }) => {
|
|
195
213
|
try {
|
|
196
214
|
let doc;
|
|
197
215
|
if (typeof content === 'string') {
|
|
@@ -206,6 +224,22 @@ export const TOOL_REGISTRY = [
|
|
|
206
224
|
content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
|
|
207
225
|
};
|
|
208
226
|
}
|
|
227
|
+
// Non-active target: write directly to disk without disrupting the user's view
|
|
228
|
+
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
229
|
+
if (targetIsNonActive) {
|
|
230
|
+
const result = populateDocumentFile(filename, doc);
|
|
231
|
+
broadcastDocumentsChanged();
|
|
232
|
+
broadcastWorkspacesChanged();
|
|
233
|
+
broadcastPendingDocsChanged();
|
|
234
|
+
broadcastWritingFinished();
|
|
235
|
+
return {
|
|
236
|
+
content: [{
|
|
237
|
+
type: 'text',
|
|
238
|
+
text: `Populated "${result.title}" — ${result.wordCount.toLocaleString()} words`,
|
|
239
|
+
}],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// Active target (or no filename): existing flow
|
|
209
243
|
setAgentLock(); // Block browser doc-updates during population
|
|
210
244
|
markAllNodesAsPending(doc, 'insert');
|
|
211
245
|
updateDocument(doc);
|
|
@@ -484,9 +518,44 @@ export const TOOL_REGISTRY = [
|
|
|
484
518
|
return { content: [{ type: 'text', text: `Moved "${docFile}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
485
519
|
},
|
|
486
520
|
},
|
|
521
|
+
{
|
|
522
|
+
name: 'rename_item',
|
|
523
|
+
description: 'Rename a workspace, container, or document. For workspaces: updates the manifest title. For containers: updates the container name in the workspace tree. For documents: updates the title in frontmatter.',
|
|
524
|
+
schema: {
|
|
525
|
+
type: z.enum(['workspace', 'container', 'document']).describe('What to rename'),
|
|
526
|
+
filename: z.string().describe('Workspace manifest filename (for workspace/container) or document filename (for document)'),
|
|
527
|
+
newName: z.string().describe('The new name/title'),
|
|
528
|
+
containerId: z.string().optional().describe('Container ID (required for container renames)'),
|
|
529
|
+
workspaceFile: z.string().optional().describe('Parent workspace filename (required for container renames)'),
|
|
530
|
+
},
|
|
531
|
+
handler: async ({ type, filename, newName, containerId, workspaceFile }) => {
|
|
532
|
+
if (type === 'workspace') {
|
|
533
|
+
renameWorkspace(filename, newName);
|
|
534
|
+
broadcastWorkspacesChanged();
|
|
535
|
+
return { content: [{ type: 'text', text: `Renamed workspace to "${newName}"` }] };
|
|
536
|
+
}
|
|
537
|
+
if (type === 'container') {
|
|
538
|
+
const wsFile = workspaceFile || filename;
|
|
539
|
+
if (!containerId)
|
|
540
|
+
return { content: [{ type: 'text', text: 'Error: containerId is required for container renames' }] };
|
|
541
|
+
renameContainer(wsFile, containerId, newName);
|
|
542
|
+
broadcastWorkspacesChanged();
|
|
543
|
+
return { content: [{ type: 'text', text: `Renamed container ${containerId} to "${newName}"` }] };
|
|
544
|
+
}
|
|
545
|
+
if (type === 'document') {
|
|
546
|
+
updateDocumentTitle(filename, newName);
|
|
547
|
+
broadcastDocumentsChanged();
|
|
548
|
+
if (filename === getActiveFilename()) {
|
|
549
|
+
broadcastTitleChanged(newName);
|
|
550
|
+
}
|
|
551
|
+
return { content: [{ type: 'text', text: `Renamed document "${filename}" to "${newName}"` }] };
|
|
552
|
+
}
|
|
553
|
+
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
554
|
+
},
|
|
555
|
+
},
|
|
487
556
|
{
|
|
488
557
|
name: 'edit_text',
|
|
489
|
-
description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node.',
|
|
558
|
+
description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node. Always specify filename — edits target that file directly without switching the user\'s view.',
|
|
490
559
|
schema: {
|
|
491
560
|
nodeId: z.string().describe('ID of the node to edit'),
|
|
492
561
|
edits: z.array(z.object({
|
|
@@ -498,8 +567,16 @@ export const TOOL_REGISTRY = [
|
|
|
498
567
|
}).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
|
|
499
568
|
removeMark: z.string().optional().describe('Mark type to remove from matched text'),
|
|
500
569
|
})).describe('Array of text edits to apply'),
|
|
501
|
-
|
|
502
|
-
|
|
570
|
+
filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required — identifies which document to edit.'),
|
|
571
|
+
},
|
|
572
|
+
handler: async ({ nodeId, edits, filename }) => {
|
|
573
|
+
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
574
|
+
if (targetIsNonActive) {
|
|
575
|
+
const result = applyTextEditsToFile(filename, nodeId, edits);
|
|
576
|
+
if (result.success)
|
|
577
|
+
broadcastPendingDocsChanged();
|
|
578
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
579
|
+
}
|
|
503
580
|
return { content: [{ type: 'text', text: JSON.stringify(applyTextEdits(nodeId, edits)) }] };
|
|
504
581
|
},
|
|
505
582
|
},
|
package/dist/server/state.js
CHANGED
|
@@ -300,13 +300,11 @@ export function onChanges(listener) {
|
|
|
300
300
|
// ============================================================================
|
|
301
301
|
// generateNodeId imported from helpers.ts
|
|
302
302
|
/**
|
|
303
|
-
* Find a node by ID in
|
|
304
|
-
*
|
|
303
|
+
* Find a node by ID in any document tree.
|
|
304
|
+
* topLevel is used to resolve the "end" sentinel.
|
|
305
305
|
*/
|
|
306
|
-
function
|
|
307
|
-
// Special sentinel: "end" resolves to the last top-level node in the document
|
|
306
|
+
function findNode(nodes, id, topLevel) {
|
|
308
307
|
if (id === 'end') {
|
|
309
|
-
const topLevel = state.document.content;
|
|
310
308
|
if (topLevel && topLevel.length > 0) {
|
|
311
309
|
return { parent: topLevel, index: topLevel.length - 1 };
|
|
312
310
|
}
|
|
@@ -317,36 +315,43 @@ function findNodeInDoc(nodes, id) {
|
|
|
317
315
|
return { parent: nodes, index: i };
|
|
318
316
|
}
|
|
319
317
|
if (nodes[i].content && Array.isArray(nodes[i].content)) {
|
|
320
|
-
const result =
|
|
318
|
+
const result = findNode(nodes[i].content, id, topLevel);
|
|
321
319
|
if (result)
|
|
322
320
|
return result;
|
|
323
321
|
}
|
|
324
322
|
}
|
|
325
323
|
return null;
|
|
326
324
|
}
|
|
325
|
+
/** Find a node in the active document. */
|
|
326
|
+
function findNodeInDoc(nodes, id) {
|
|
327
|
+
return findNode(nodes, id, state.document.content);
|
|
328
|
+
}
|
|
327
329
|
/**
|
|
328
|
-
*
|
|
329
|
-
* 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.
|
|
330
332
|
*/
|
|
331
|
-
function
|
|
333
|
+
function applyChangesToDoc(doc, changes) {
|
|
332
334
|
const processed = [];
|
|
333
335
|
for (const change of changes) {
|
|
334
336
|
if (change.operation === 'rewrite' && change.nodeId && change.content) {
|
|
335
|
-
const found =
|
|
337
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
336
338
|
if (!found)
|
|
337
339
|
continue;
|
|
338
340
|
const contentArray = Array.isArray(change.content) ? change.content : [change.content];
|
|
339
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();
|
|
340
345
|
// Only store original on first rewrite (preserve baseline for reject)
|
|
341
346
|
const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
|
|
342
|
-
// First node replaces the target (rewrite)
|
|
347
|
+
// First node replaces the target (rewrite or insert if empty)
|
|
343
348
|
const firstNode = {
|
|
344
349
|
...contentArray[0],
|
|
345
350
|
attrs: {
|
|
346
351
|
...contentArray[0].attrs,
|
|
347
352
|
id: change.nodeId,
|
|
348
|
-
pendingStatus: 'rewrite',
|
|
349
|
-
pendingOriginalContent: existingOriginal || originalNode,
|
|
353
|
+
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
354
|
+
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
350
355
|
},
|
|
351
356
|
};
|
|
352
357
|
// Additional nodes get inserted after as pending inserts
|
|
@@ -376,17 +381,20 @@ function applyChangesToDocument(changes) {
|
|
|
376
381
|
}));
|
|
377
382
|
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
378
383
|
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
384
|
+
let resolvedAfterId;
|
|
379
385
|
if (change.nodeId && !change.afterNodeId) {
|
|
380
386
|
// Replace empty node
|
|
381
|
-
const found =
|
|
387
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
382
388
|
if (!found)
|
|
383
389
|
continue;
|
|
384
390
|
found.parent.splice(found.index, 1, ...contentWithIds);
|
|
385
391
|
}
|
|
386
392
|
else if (change.afterNodeId) {
|
|
387
|
-
const found =
|
|
393
|
+
const found = findNode(doc.content, change.afterNodeId, doc.content);
|
|
388
394
|
if (!found)
|
|
389
395
|
continue;
|
|
396
|
+
// Resolve "end" sentinel to actual node ID so browser can find it
|
|
397
|
+
resolvedAfterId = found.parent[found.index]?.attrs?.id;
|
|
390
398
|
found.parent.splice(found.index + 1, 0, ...contentWithIds);
|
|
391
399
|
}
|
|
392
400
|
else {
|
|
@@ -395,11 +403,13 @@ function applyChangesToDocument(changes) {
|
|
|
395
403
|
// Broadcast with server-assigned IDs so browser uses the same IDs
|
|
396
404
|
processed.push({
|
|
397
405
|
...change,
|
|
406
|
+
// Replace "end" with the resolved node ID so browser can look it up
|
|
407
|
+
...(resolvedAfterId && change.afterNodeId === 'end' ? { afterNodeId: resolvedAfterId } : {}),
|
|
398
408
|
content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
|
|
399
409
|
});
|
|
400
410
|
}
|
|
401
411
|
else if (change.operation === 'delete' && change.nodeId) {
|
|
402
|
-
const found =
|
|
412
|
+
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
403
413
|
if (!found)
|
|
404
414
|
continue;
|
|
405
415
|
found.parent[found.index] = {
|
|
@@ -412,6 +422,11 @@ function applyChangesToDocument(changes) {
|
|
|
412
422
|
processed.push(change);
|
|
413
423
|
}
|
|
414
424
|
}
|
|
425
|
+
return processed;
|
|
426
|
+
}
|
|
427
|
+
/** Apply changes to the active document singleton. */
|
|
428
|
+
function applyChangesToDocument(changes) {
|
|
429
|
+
const processed = applyChangesToDoc(state.document, changes);
|
|
415
430
|
if (processed.length > 0) {
|
|
416
431
|
state.lastModified = new Date();
|
|
417
432
|
}
|
|
@@ -480,6 +495,15 @@ export function updatePendingCacheForActiveDoc() {
|
|
|
480
495
|
export function removePendingCacheEntry(filename) {
|
|
481
496
|
pendingDocCache.delete(filename);
|
|
482
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
|
+
}
|
|
483
507
|
/** Populate the pending cache from a full disk scan. Called once on startup. */
|
|
484
508
|
function populatePendingCache() {
|
|
485
509
|
pendingDocCache.clear();
|
|
@@ -511,6 +535,67 @@ function populatePendingCache() {
|
|
|
511
535
|
catch { /* skip unreadable files */ }
|
|
512
536
|
}
|
|
513
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
|
+
}
|
|
514
599
|
// ============================================================================
|
|
515
600
|
// PENDING DOCUMENT STORE OPERATIONS
|
|
516
601
|
// ============================================================================
|
|
@@ -576,10 +661,20 @@ export function markAllNodesAsPending(doc, status) {
|
|
|
576
661
|
export function getPendingDocInfo() {
|
|
577
662
|
const filenames = [];
|
|
578
663
|
const counts = {};
|
|
664
|
+
const stale = [];
|
|
579
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
|
+
}
|
|
580
672
|
filenames.push(filename);
|
|
581
673
|
counts[filename] = count;
|
|
582
674
|
}
|
|
675
|
+
// Clean up stale entries
|
|
676
|
+
for (const f of stale)
|
|
677
|
+
pendingDocCache.delete(f);
|
|
583
678
|
return { filenames, counts };
|
|
584
679
|
}
|
|
585
680
|
// ============================================================================
|
|
@@ -951,3 +1046,139 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
951
1046
|
}
|
|
952
1047
|
catch { /* best-effort */ }
|
|
953
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
|
@@ -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)
|