openwriter 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  },
@@ -524,7 +601,7 @@ export const TOOL_REGISTRY = [
524
601
  },
525
602
  {
526
603
  name: 'generate_image',
527
- description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
604
+ description: 'Generate an image using Gemini Nano Banana 2. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
528
605
  schema: {
529
606
  prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
530
607
  aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
@@ -537,16 +614,16 @@ export const TOOL_REGISTRY = [
537
614
  }
538
615
  const { GoogleGenAI } = await import('@google/genai');
539
616
  const ai = new GoogleGenAI({ apiKey });
540
- const response = await ai.models.generateImages({
541
- model: 'imagen-4.0-generate-001',
542
- prompt,
617
+ const response = await ai.models.generateContent({
618
+ model: 'gemini-3.1-flash-image-preview',
619
+ contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
543
620
  config: {
544
- numberOfImages: 1,
545
- aspectRatio: (aspect_ratio || '16:9'),
621
+ responseModalities: ['IMAGE'],
546
622
  },
547
623
  });
548
- const image = response.generatedImages?.[0];
549
- if (!image?.image?.imageBytes) {
624
+ const parts = response.candidates?.[0]?.content?.parts;
625
+ const imagePart = parts?.find((p) => p.inlineData);
626
+ if (!imagePart?.inlineData?.data) {
550
627
  return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
551
628
  }
552
629
  // Save to ~/.openwriter/_images/
@@ -556,7 +633,7 @@ export const TOOL_REGISTRY = [
556
633
  mkdirSync(imagesDir, { recursive: true });
557
634
  const filename = `${randomUUID().slice(0, 8)}.png`;
558
635
  const filePath = join(imagesDir, filename);
559
- writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
636
+ writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
560
637
  const src = `/_images/${filename}`;
561
638
  // Optionally set as article cover + append to carousel history
562
639
  if (set_cover) {
@@ -127,6 +127,7 @@ export class PluginManager {
127
127
  continue;
128
128
  results.push({
129
129
  name: managed.plugin.name,
130
+ displayName: managed.discovered.displayName,
130
131
  contextMenuItems: managed.plugin.contextMenuItems?.() || [],
131
132
  sidebarMenuItems: managed.plugin.sidebarMenuItems?.() || [],
132
133
  });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * File: prompt-debug.ts
3
+ * Purpose: Write AV prompt debug data to timestamped .md files for inspection.
4
+ * Each enhance creates a new file in DATA_DIR, visible in the sidebar.
5
+ */
6
+ import { DATA_DIR, ensureDataDir, atomicWriteFileSync } from './helpers.js';
7
+ import { join } from 'path';
8
+ /**
9
+ * Write prompt debug info to a timestamped markdown file.
10
+ * Returns the filename created.
11
+ */
12
+ export function writePromptDebug(action, debug, metadata) {
13
+ ensureDataDir();
14
+ const now = new Date();
15
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
16
+ const filename = `_prompt-${action || 'debug'}-${ts}.md`;
17
+ const filePath = join(DATA_DIR, filename);
18
+ const timeStr = now.toLocaleTimeString('en-US', { hour12: true, hour: '2-digit', minute: '2-digit', second: '2-digit' });
19
+ let md = `---\ntitle: "Prompt Debug: ${action} @ ${timeStr}"\n---\n\n`;
20
+ // Metadata summary
21
+ if (metadata) {
22
+ md += `## Metadata\n\n`;
23
+ md += `| Key | Value |\n|-----|-------|\n`;
24
+ if (metadata.action)
25
+ md += `| Action | ${metadata.action} |\n`;
26
+ if (metadata.profileUsed)
27
+ md += `| Profile | ${metadata.profileUsed} |\n`;
28
+ if (metadata.nodesIn != null)
29
+ md += `| Nodes In | ${metadata.nodesIn} |\n`;
30
+ if (metadata.nodesOut != null)
31
+ md += `| Nodes Out | ${metadata.nodesOut} |\n`;
32
+ if (metadata.ragExamples != null)
33
+ md += `| RAG Examples | ${metadata.ragExamples} |\n`;
34
+ if (metadata.ragTotalWords != null)
35
+ md += `| RAG Total Words | ${metadata.ragTotalWords} |\n`;
36
+ if (metadata.processingTimeMs != null)
37
+ md += `| Processing Time | ${metadata.processingTimeMs}ms |\n`;
38
+ if (metadata.estimatedCost != null)
39
+ md += `| Estimated Cost | $${metadata.estimatedCost.toFixed(4)} |\n`;
40
+ md += `\n`;
41
+ }
42
+ // System prompt
43
+ if (debug.systemPrompt) {
44
+ md += `## System Prompt\n\n`;
45
+ md += debug.systemPrompt + '\n\n';
46
+ }
47
+ // User prompt
48
+ if (debug.userPrompt) {
49
+ md += `---\n\n## User Prompt\n\n`;
50
+ md += debug.userPrompt + '\n\n';
51
+ }
52
+ // Raw LLM response (when available)
53
+ if (debug.rawResponse) {
54
+ md += `---\n\n## Raw LLM Output\n\n\`\`\`json\n${debug.rawResponse}\n\`\`\`\n\n`;
55
+ }
56
+ atomicWriteFileSync(filePath, md);
57
+ return filename;
58
+ }