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.
@@ -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);
@@ -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 the active 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.',
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
- handler: async ({ nodeId, edits }) => {
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
  },
@@ -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 the document tree.
304
- * Returns the parent array and index for in-place mutation.
303
+ * Find a node by ID in any document tree.
304
+ * topLevel is used to resolve the "end" sentinel.
305
305
  */
306
- function findNodeInDoc(nodes, id) {
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 = findNodeInDoc(nodes[i].content, id);
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
- * Apply changes to server-side document and return processed changes
329
- * with server-assigned IDs for broadcast to browsers.
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 applyChangesToDocument(changes) {
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 = findNodeInDoc(state.document.content, change.nodeId);
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 = findNodeInDoc(state.document.content, change.nodeId);
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 = findNodeInDoc(state.document.content, change.afterNodeId);
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 = findNodeInDoc(state.document.content, change.nodeId);
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.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. 30 MCP tools for document editing, multi-doc workspaces, and
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.4.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 30 editing tools.
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 send changes (3-8 per call)
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. switch_document save current, load target (returns content)
214
- 3. read_pad → read full content with node IDs
215
- 4. write_to_pad apply edits
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)