openwriter 0.5.2 → 0.5.4

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.
@@ -12,8 +12,7 @@ import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
14
  import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
15
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs } from './documents.js';
16
- import { writePromptDebug } from './prompt-debug.js';
15
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
17
16
  import { createWorkspaceRouter } from './workspace-routes.js';
18
17
  import { createLinkRouter } from './link-routes.js';
19
18
  import { createTweetRouter } from './tweet-routes.js';
@@ -56,7 +55,14 @@ export async function startHttpServer(options = {}) {
56
55
  res.status(404).json({ error: `Unknown tool: ${toolName}` });
57
56
  return;
58
57
  }
59
- const result = await tool.handler(args || {});
58
+ // Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
59
+ const schema = z.object(tool.schema);
60
+ const parsed = schema.safeParse(args || {});
61
+ if (!parsed.success) {
62
+ res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
63
+ return;
64
+ }
65
+ const result = await tool.handler(parsed.data);
60
66
  res.json(result);
61
67
  }
62
68
  catch (err) {
@@ -223,6 +229,39 @@ export async function startHttpServer(options = {}) {
223
229
  res.status(500).json({ error: err.message });
224
230
  }
225
231
  });
232
+ app.get('/api/documents/archived', (_req, res) => {
233
+ res.json(listArchivedDocuments());
234
+ });
235
+ app.get('/api/documents/search', (req, res) => {
236
+ const q = req.query.q || '';
237
+ const includeArchived = req.query.archived === 'true';
238
+ res.json(searchDocuments(q, includeArchived));
239
+ });
240
+ app.post('/api/documents/:filename/archive', (req, res) => {
241
+ try {
242
+ removeDocFromAllWorkspaces(req.params.filename);
243
+ const result = archiveDocument(req.params.filename);
244
+ if (result.switched && result.newDoc) {
245
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
246
+ }
247
+ broadcastDocumentsChanged();
248
+ broadcastWorkspacesChanged();
249
+ res.json(result);
250
+ }
251
+ catch (err) {
252
+ res.status(400).json({ error: err.message });
253
+ }
254
+ });
255
+ app.post('/api/documents/:filename/unarchive', (req, res) => {
256
+ try {
257
+ const result = unarchiveDocument(req.params.filename);
258
+ broadcastDocumentsChanged();
259
+ res.json(result);
260
+ }
261
+ catch (err) {
262
+ res.status(400).json({ error: err.message });
263
+ }
264
+ });
226
265
  app.get('/api/documents/:filename/content', (req, res) => {
227
266
  try {
228
267
  const targetPath = resolveDocPath(req.params.filename);
@@ -361,22 +400,6 @@ export async function startHttpServer(options = {}) {
361
400
  res.status(500).json({ error: err.message });
362
401
  }
363
402
  });
364
- // Prompt debug: write full prompt to a timestamped .md file for inspection
365
- app.post('/api/prompt-debug', (req, res) => {
366
- try {
367
- const { action, debug, metadata } = req.body;
368
- if (!debug) {
369
- res.status(400).json({ error: 'debug payload is required' });
370
- return;
371
- }
372
- const filename = writePromptDebug(action, debug, metadata);
373
- broadcastDocumentsChanged();
374
- res.json({ success: true, filename });
375
- }
376
- catch (err) {
377
- res.status(500).json({ error: err.message });
378
- }
379
- });
380
403
  // Google Doc import
381
404
  app.post('/api/import/gdoc', (req, res) => {
382
405
  try {
@@ -523,8 +546,26 @@ export async function startHttpServer(options = {}) {
523
546
  setupWebSocket(server);
524
547
  // Broadcast agent status now that WS is ready
525
548
  broadcastAgentStatus(true);
526
- server.listen(port, '127.0.0.1', () => {
527
- console.log(`OpenWriter running at http://localhost:${port}`);
549
+ await new Promise((resolve, reject) => {
550
+ server.on('error', (err) => {
551
+ if (err.code === 'EADDRINUSE') {
552
+ console.error(`[HTTP] Port ${port} in use — retrying in 2s...`);
553
+ setTimeout(() => {
554
+ server.listen(port, '127.0.0.1', () => {
555
+ console.log(`OpenWriter running at http://localhost:${port}`);
556
+ resolve();
557
+ });
558
+ }, 2000);
559
+ }
560
+ else {
561
+ console.error(`[HTTP] Server error:`, err);
562
+ reject(err);
563
+ }
564
+ });
565
+ server.listen(port, '127.0.0.1', () => {
566
+ console.log(`OpenWriter running at http://localhost:${port}`);
567
+ resolve();
568
+ });
528
569
  });
529
570
  // Open browser unless --no-open or running as MCP stdio pipe
530
571
  const isMcpStdio = !process.stdout.isTTY;
@@ -9,19 +9,38 @@ import { randomUUID } from 'crypto';
9
9
  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
- import { DATA_DIR, ensureDataDir } from './helpers.js';
12
+ import { DATA_DIR, ensureDataDir, resolveDocPath } from './helpers.js';
13
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, promoteTempFile } from './documents.js';
14
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
15
15
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
16
16
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, renameWorkspace, renameContainer } from './workspaces.js';
17
- import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
17
+ import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
18
18
  import { importGoogleDoc } from './gdoc-import.js';
19
- import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
19
+ import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
20
+ import matter from 'gray-matter';
20
21
  import { getUpdateInfo } from './update-check.js';
21
22
  import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
22
23
  import { markdownToTiptap } from './markdown.js';
23
24
  import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
24
25
  import { broadcastMarksChanged } from './ws.js';
26
+ /** Check if a document is in tweet compose mode (has tweetContext metadata). */
27
+ function isTweetDoc(filename) {
28
+ if (!filename || filename === getActiveFilename()) {
29
+ return !!getMetadata()?.tweetContext;
30
+ }
31
+ const targetPath = resolveDocPath(filename);
32
+ const cached = getCachedDocument(targetPath);
33
+ if (cached)
34
+ return !!cached.metadata?.tweetContext;
35
+ try {
36
+ const raw = readFileSync(targetPath, 'utf-8');
37
+ const { data } = matter(raw);
38
+ return !!data?.tweetContext;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
25
44
  export const TOOL_REGISTRY = [
26
45
  {
27
46
  name: 'read_pad',
@@ -29,7 +48,7 @@ export const TOOL_REGISTRY = [
29
48
  schema: {},
30
49
  handler: async () => {
31
50
  const doc = getDocument();
32
- const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount());
51
+ const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount(), getDocId());
33
52
  const activeFile = getActiveFilename();
34
53
  const localCount = getMarkCount(activeFile);
35
54
  const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(activeFile);
@@ -45,7 +64,7 @@ export const TOOL_REGISTRY = [
45
64
  },
46
65
  {
47
66
  name: 'write_to_pad',
48
- 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.',
67
+ 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. Target document by docId (8-char hex from list_documents or read_pad).',
49
68
  schema: {
50
69
  changes: z.array(z.object({
51
70
  operation: z.enum(['rewrite', 'insert', 'delete']),
@@ -53,13 +72,18 @@ export const TOOL_REGISTRY = [
53
72
  afterNodeId: z.string().optional(),
54
73
  content: z.any().optional(),
55
74
  })).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
56
- filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required identifies which document to edit.'),
75
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
57
76
  },
58
- handler: async ({ changes, filename }) => {
77
+ handler: async ({ changes, docId }) => {
78
+ const filename = resolveDocId(docId);
79
+ const tweetMode = isTweetDoc(filename);
59
80
  const processed = changes.map((change) => {
60
81
  const resolved = { ...change };
61
82
  if (typeof resolved.content === 'string') {
62
- resolved.content = parseMarkdownContent(resolved.content);
83
+ let nodes = parseMarkdownContent(resolved.content);
84
+ if (tweetMode)
85
+ nodes = mergeParagraphsToHardBreaks(nodes);
86
+ resolved.content = nodes;
63
87
  }
64
88
  return resolved;
65
89
  });
@@ -117,30 +141,32 @@ export const TOOL_REGISTRY = [
117
141
  },
118
142
  {
119
143
  name: 'list_documents',
120
- description: 'List all documents in the workspace. Shows filename, word count, last modified date, and which document is active.',
144
+ description: 'List all documents. Shows title, docId (8-char hex), word count, last modified date, and which document is active. Use the docId to target documents in other tools.',
121
145
  schema: {},
122
146
  handler: async () => {
123
147
  const docs = listDocuments();
124
148
  const lines = docs.map((d) => {
125
149
  const active = d.isActive ? ' (active)' : '';
150
+ const id = d.docId ? ` [${d.docId}]` : '';
126
151
  const date = d.lastModified.split('T')[0];
127
- return ` ${d.filename}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
152
+ return ` "${d.title}"${id}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
128
153
  });
129
154
  return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}` }] };
130
155
  },
131
156
  },
132
157
  {
133
158
  name: 'switch_document',
134
- description: 'Switch to a different document by filename. Saves the current document first. Returns a compact read of the newly active document.',
159
+ description: 'Switch to a different document. Saves the current document first. Returns a compact read of the newly active document. Target document by docId (8-char hex from list_documents or read_pad).',
135
160
  schema: {
136
- filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
161
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
137
162
  },
138
- handler: async ({ filename }) => {
163
+ handler: async ({ docId }) => {
164
+ const filename = resolveDocId(docId);
139
165
  broadcastWritingFinished(); // Clear any in-progress creation spinner
140
166
  const result = switchDocument(filename);
141
167
  broadcastDocumentSwitched(result.document, result.title, result.filename);
142
- const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
143
- return { content: [{ type: 'text', text: `Switched to "${result.title}"\n\n${compact}` }] };
168
+ const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), getDocId());
169
+ return { content: [{ type: 'text', text: `Switched to "${result.title}" [${docId}]\n\n${compact}` }] };
144
170
  },
145
171
  },
146
172
  {
@@ -172,18 +198,16 @@ export const TOOL_REGISTRY = [
172
198
  await new Promise((resolve) => setTimeout(resolve, 200));
173
199
  }
174
200
  try {
175
- // Lock browser doc-updates: prevents race where browser sends a doc-update
176
- // for the previous document but server has already switched active doc.
177
- setAgentLock();
178
- const result = createDocument(title, undefined, path);
179
- // Auto-add to workspace if specified
180
- let wsInfo = '';
181
- if (wsTarget) {
182
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
183
- wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
184
- }
185
201
  if (empty) {
186
202
  // Immediate switch — no spinner, no populate_document needed
203
+ setAgentLock();
204
+ const result = createDocument(title, undefined, path);
205
+ let wsInfo = '';
206
+ if (wsTarget) {
207
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
208
+ wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
209
+ }
210
+ const newDocId = getDocId();
187
211
  save();
188
212
  broadcastDocumentsChanged();
189
213
  broadcastWorkspacesChanged();
@@ -191,19 +215,23 @@ export const TOOL_REGISTRY = [
191
215
  return {
192
216
  content: [{
193
217
  type: 'text',
194
- text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
218
+ text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
195
219
  }],
196
220
  };
197
221
  }
198
- // Two-step flow: spinner persists until populate_document is called
199
- setMetadata({ agentCreated: true });
200
- save(); // Persist agentCreated flag to frontmatter
222
+ // Two-step flow: create file on disk WITHOUT switching the user's view.
223
+ // The spinner persists in the sidebar until populate_document is called.
224
+ const result = createDocumentFile(title, path);
225
+ let wsInfo = '';
226
+ if (wsTarget) {
227
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
228
+ wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
229
+ }
201
230
  broadcastDocumentsChanged();
202
- broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
203
231
  return {
204
232
  content: [{
205
233
  type: 'text',
206
- text: `Created "${result.title}" (${result.filename})${wsInfo} — empty. Call populate_document to add content.`,
234
+ text: `Created "${result.title}" [${result.docId}]${wsInfo} — empty. Call populate_document with docId "${result.docId}" to add content.`,
207
235
  }],
208
236
  };
209
237
  }
@@ -216,16 +244,20 @@ export const TOOL_REGISTRY = [
216
244
  },
217
245
  {
218
246
  name: 'populate_document',
219
- 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.',
247
+ 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 docId from create_document\'s response to ensure content goes to the right doc even if the user switched away.',
220
248
  schema: {
221
249
  content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
222
- 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.'),
250
+ docId: z.string().optional().describe('Target document by docId (8-char hex from create_document or list_documents). 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.'),
223
251
  },
224
- handler: async ({ content, filename }) => {
252
+ handler: async ({ content, docId }) => {
253
+ const filename = docId ? resolveDocId(docId) : undefined;
225
254
  try {
226
255
  let doc;
227
256
  if (typeof content === 'string') {
228
- doc = { type: 'doc', content: parseMarkdownContent(content) };
257
+ let nodes = parseMarkdownContent(content);
258
+ if (isTweetDoc(filename))
259
+ nodes = mergeParagraphsToHardBreaks(nodes);
260
+ doc = { type: 'doc', content: nodes };
229
261
  }
230
262
  else if (content?.type === 'doc' && Array.isArray(content.content)) {
231
263
  doc = content;
@@ -287,17 +319,19 @@ export const TOOL_REGISTRY = [
287
319
  handler: async ({ path }) => {
288
320
  const result = openFile(path);
289
321
  broadcastDocumentSwitched(result.document, result.title, result.filename);
290
- const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
291
- return { content: [{ type: 'text', text: `Opened "${result.title}" from ${path}\n\n${compact}` }] };
322
+ const openedDocId = getDocId();
323
+ const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), openedDocId);
324
+ return { content: [{ type: 'text', text: `Opened "${result.title}" [${openedDocId}] from ${path}\n\n${compact}` }] };
292
325
  },
293
326
  },
294
327
  {
295
328
  name: 'delete_document',
296
- description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool.',
329
+ description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool. Target document by docId (8-char hex from list_documents or read_pad).',
297
330
  schema: {
298
- filename: z.string().describe('Filename of the document to delete (e.g. "My Essay.md")'),
331
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
299
332
  },
300
- handler: async ({ filename }) => {
333
+ handler: async ({ docId }) => {
334
+ const filename = resolveDocId(docId);
301
335
  const result = await deleteDocument(filename);
302
336
  if (result.switched && result.newDoc) {
303
337
  broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
@@ -305,11 +339,44 @@ export const TOOL_REGISTRY = [
305
339
  broadcastDocumentsChanged();
306
340
  let text = `Deleted "${filename}" (moved to trash)`;
307
341
  if (result.switched && result.newDoc) {
308
- text += `. Switched to "${result.newDoc.filename}"`;
342
+ text += `. Switched to "${result.newDoc.title}"`;
309
343
  }
310
344
  return { content: [{ type: 'text', text }] };
311
345
  },
312
346
  },
347
+ {
348
+ name: 'archive_document',
349
+ description: 'Archive a document. Removes it from the active document list without deleting the file. Archived docs can be restored later with unarchive_document. If archiving the active document, automatically switches to the most recent remaining doc. Target document by docId (8-char hex from list_documents).',
350
+ schema: {
351
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
352
+ },
353
+ handler: async ({ docId }) => {
354
+ const filename = resolveDocId(docId);
355
+ const result = archiveDocument(filename);
356
+ if (result.switched && result.newDoc) {
357
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
358
+ }
359
+ broadcastDocumentsChanged();
360
+ let text = `Archived "${filename}"`;
361
+ if (result.switched && result.newDoc) {
362
+ text += `. Switched to "${result.newDoc.title}"`;
363
+ }
364
+ return { content: [{ type: 'text', text }] };
365
+ },
366
+ },
367
+ {
368
+ name: 'unarchive_document',
369
+ description: 'Restore an archived document back to the active document list. Target document by docId (8-char hex from list_documents with includeArchived).',
370
+ schema: {
371
+ docId: z.string().describe('Target document by docId (8-char hex).'),
372
+ },
373
+ handler: async ({ docId }) => {
374
+ const filename = resolveDocId(docId);
375
+ const result = unarchiveDocument(filename);
376
+ broadcastDocumentsChanged();
377
+ return { content: [{ type: 'text', text: `Restored "${result.title}" [${docId}] from archive` }] };
378
+ },
379
+ },
313
380
  {
314
381
  name: 'get_metadata',
315
382
  description: 'Get the JSON frontmatter metadata for the active document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
@@ -537,22 +604,27 @@ export const TOOL_REGISTRY = [
537
604
  },
538
605
  {
539
606
  name: 'rename_item',
540
- 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.',
607
+ 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 — use docId to identify the document.',
541
608
  schema: {
542
609
  type: z.enum(['workspace', 'container', 'document']).describe('What to rename'),
543
- filename: z.string().describe('Workspace manifest filename (for workspace/container) or document filename (for document)'),
610
+ filename: z.string().optional().describe('Workspace manifest filename (required for workspace/container renames). Not used for document renames.'),
611
+ docId: z.string().optional().describe('Document docId (required for document renames, 8-char hex from list_documents).'),
544
612
  newName: z.string().describe('The new name/title'),
545
613
  containerId: z.string().optional().describe('Container ID (required for container renames)'),
546
614
  workspaceFile: z.string().optional().describe('Parent workspace filename (required for container renames)'),
547
615
  },
548
- handler: async ({ type, filename, newName, containerId, workspaceFile }) => {
616
+ handler: async ({ type, filename, docId, newName, containerId, workspaceFile }) => {
549
617
  if (type === 'workspace') {
618
+ if (!filename)
619
+ return { content: [{ type: 'text', text: 'Error: filename is required for workspace renames' }] };
550
620
  renameWorkspace(filename, newName);
551
621
  broadcastWorkspacesChanged();
552
622
  return { content: [{ type: 'text', text: `Renamed workspace to "${newName}"` }] };
553
623
  }
554
624
  if (type === 'container') {
555
625
  const wsFile = workspaceFile || filename;
626
+ if (!wsFile)
627
+ return { content: [{ type: 'text', text: 'Error: workspaceFile or filename is required for container renames' }] };
556
628
  if (!containerId)
557
629
  return { content: [{ type: 'text', text: 'Error: containerId is required for container renames' }] };
558
630
  renameContainer(wsFile, containerId, newName);
@@ -560,19 +632,22 @@ export const TOOL_REGISTRY = [
560
632
  return { content: [{ type: 'text', text: `Renamed container ${containerId} to "${newName}"` }] };
561
633
  }
562
634
  if (type === 'document') {
563
- updateDocumentTitle(filename, newName);
635
+ if (!docId)
636
+ return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
637
+ const resolvedFilename = resolveDocId(docId);
638
+ updateDocumentTitle(resolvedFilename, newName);
564
639
  broadcastDocumentsChanged();
565
- if (filename === getActiveFilename()) {
640
+ if (resolvedFilename === getActiveFilename()) {
566
641
  broadcastTitleChanged(newName);
567
642
  }
568
- return { content: [{ type: 'text', text: `Renamed document "${filename}" to "${newName}"` }] };
643
+ return { content: [{ type: 'text', text: `Renamed document [${docId}] to "${newName}"` }] };
569
644
  }
570
645
  return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
571
646
  },
572
647
  },
573
648
  {
574
649
  name: 'edit_text',
575
- 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.',
650
+ 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. Target document by docId (8-char hex from list_documents or read_pad).',
576
651
  schema: {
577
652
  nodeId: z.string().describe('ID of the node to edit'),
578
653
  edits: z.array(z.object({
@@ -584,9 +659,10 @@ export const TOOL_REGISTRY = [
584
659
  }).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
585
660
  removeMark: z.string().optional().describe('Mark type to remove from matched text'),
586
661
  })).describe('Array of text edits to apply'),
587
- filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required identifies which document to edit.'),
662
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
588
663
  },
589
- handler: async ({ nodeId, edits, filename }) => {
664
+ handler: async ({ nodeId, edits, docId }) => {
665
+ const filename = resolveDocId(docId);
590
666
  const targetIsNonActive = filename && filename !== getActiveFilename();
591
667
  if (targetIsNonActive) {
592
668
  const result = applyTextEditsToFile(filename, nodeId, edits);
@@ -629,6 +705,11 @@ export const TOOL_REGISTRY = [
629
705
  if (!apiKey) {
630
706
  return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
631
707
  }
708
+ // Capture document context BEFORE the async image generation.
709
+ // The active document can change during the await (user switches docs),
710
+ // so we snapshot the metadata and filePath now to stay scoped.
711
+ const preAwaitFilePath = getFilePath();
712
+ const preAwaitMeta = structuredClone(getMetadata());
632
713
  const { GoogleGenAI } = await import('@google/genai');
633
714
  const ai = new GoogleGenAI({ apiKey });
634
715
  const response = await ai.models.generateContent({
@@ -654,9 +735,20 @@ export const TOOL_REGISTRY = [
654
735
  const src = `/_images/${filename}`;
655
736
  // Optionally set as article cover + append to carousel history
656
737
  if (set_cover) {
657
- const meta = getMetadata();
658
- const articleContext = meta.articleContext || {};
659
- let existing = Array.isArray(articleContext.coverImages) ? articleContext.coverImages : [];
738
+ const docChanged = getFilePath() !== preAwaitFilePath;
739
+ if (docChanged) {
740
+ // Active document changed during image generation — skip set_cover
741
+ // to avoid leaking cover images across documents.
742
+ return {
743
+ content: [{
744
+ type: 'text',
745
+ text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
746
+ }],
747
+ };
748
+ }
749
+ // Use pre-await metadata snapshot to build the update (not live state)
750
+ const articleContext = preAwaitMeta.articleContext || {};
751
+ let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
660
752
  // Seed with current coverImage if array is empty (first carousel entry)
661
753
  if (existing.length === 0 && articleContext.coverImage) {
662
754
  existing = [articleContext.coverImage];
@@ -676,6 +768,69 @@ export const TOOL_REGISTRY = [
676
768
  };
677
769
  },
678
770
  },
771
+ {
772
+ name: 'insert_image',
773
+ description: 'Generate an image via Gemini and insert it inline into a document. The image appears with a green pending decoration for user review. Uses the same change pipeline as write_to_pad.',
774
+ schema: {
775
+ docId: z.string().describe('Target document by docId (8-char hex).'),
776
+ prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
777
+ afterNodeId: z.string().describe('Insert after this node ID, or "end" to append at the bottom.'),
778
+ aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
779
+ alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
780
+ },
781
+ handler: async ({ docId, prompt, afterNodeId, aspect_ratio, alt }) => {
782
+ const apiKey = process.env.GEMINI_API_KEY;
783
+ if (!apiKey) {
784
+ return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
785
+ }
786
+ const filename = resolveDocId(docId);
787
+ // Generate image via Gemini
788
+ const { GoogleGenAI } = await import('@google/genai');
789
+ const ai = new GoogleGenAI({ apiKey });
790
+ const response = await ai.models.generateContent({
791
+ model: 'gemini-3.1-flash-image-preview',
792
+ contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
793
+ config: {
794
+ responseModalities: ['IMAGE'],
795
+ },
796
+ });
797
+ const parts = response.candidates?.[0]?.content?.parts;
798
+ const imagePart = parts?.find((p) => p.inlineData);
799
+ if (!imagePart?.inlineData?.data) {
800
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
801
+ }
802
+ // Save to ~/.openwriter/_images/
803
+ ensureDataDir();
804
+ const imagesDir = join(DATA_DIR, '_images');
805
+ if (!existsSync(imagesDir))
806
+ mkdirSync(imagesDir, { recursive: true });
807
+ const imgFilename = `${randomUUID().slice(0, 8)}.png`;
808
+ const filePath = join(imagesDir, imgFilename);
809
+ writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
810
+ const src = `/_images/${imgFilename}`;
811
+ // Build image node and insert change
812
+ const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
813
+ const change = { operation: 'insert', afterNodeId, content: [imageNode] };
814
+ const targetIsNonActive = filename && filename !== getActiveFilename();
815
+ if (targetIsNonActive) {
816
+ const { lastNodeId } = applyChangesToFile(filename, [change]);
817
+ broadcastPendingDocsChanged();
818
+ return {
819
+ content: [{
820
+ type: 'text',
821
+ text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
822
+ }],
823
+ };
824
+ }
825
+ const { lastNodeId } = applyChanges([change]);
826
+ return {
827
+ content: [{
828
+ type: 'text',
829
+ text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
830
+ }],
831
+ };
832
+ },
833
+ },
679
834
  {
680
835
  name: 'list_versions',
681
836
  description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
@@ -751,11 +906,12 @@ export const TOOL_REGISTRY = [
751
906
  },
752
907
  {
753
908
  name: 'get_agent_marks',
754
- description: 'Get inline feedback marks left by the user. Users select text in the editor, right-click → Agent Mark, and leave notes for the agent. Returns marks grouped by filename with text, note, and nodeId. Call resolve_agent_marks after addressing each mark.',
909
+ description: 'Get inline feedback marks left by the user. Users select text in the editor, right-click → Agent Mark, and leave notes for the agent. Returns marks grouped by document with text, note, and nodeId. Call resolve_agent_marks after addressing each mark.',
755
910
  schema: {
756
- filename: z.string().optional().describe('Document filename to get marks for. Omit to get marks across all documents.'),
911
+ docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get marks across all documents.'),
757
912
  },
758
- handler: async ({ filename }) => {
913
+ handler: async ({ docId }) => {
914
+ const filename = docId ? resolveDocId(docId) : undefined;
759
915
  const marks = getMarks(filename);
760
916
  const entries = Object.entries(marks);
761
917
  if (entries.length === 0) {
@@ -146,7 +146,7 @@ export function setMetadata(updates) {
146
146
  state.metadata = { ...state.metadata, ...updates };
147
147
  if (updates.title)
148
148
  state.title = updates.title;
149
- // Auto-tag: tweetContext / articleContext ↔ "x" tag
149
+ // Auto-tag: tweetContext / articleContext ↔ "x" + mode tag
150
150
  for (const key of ['tweetContext', 'articleContext']) {
151
151
  if (key in updates) {
152
152
  const filename = state.filePath
@@ -155,6 +155,9 @@ export function setMetadata(updates) {
155
155
  if (filename) {
156
156
  if (updates[key]) {
157
157
  addDocTag(filename, 'x');
158
+ const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
159
+ if (mode)
160
+ addDocTag(filename, mode);
158
161
  }
159
162
  else {
160
163
  removeDocTag(filename, 'x');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "vite build && tsc -p tsconfig.server.json",
35
+ "prepublishOnly": "cp ../../skills/openwriter/SKILL.md skill/SKILL.md",
35
36
  "preview": "node dist/bin/pad.js",
36
37
  "lint": "eslint src server bin --ext .ts,.tsx"
37
38
  },