openwriter 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-Be3gaGeo.css +1 -0
- package/dist/client/assets/index-BwT1KW6a.js +207 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +105 -8
- package/dist/server/git-sync.js +3 -2
- package/dist/server/helpers.js +17 -4
- package/dist/server/index.js +115 -10
- package/dist/server/markdown-parse.js +12 -0
- package/dist/server/markdown-serialize.js +12 -0
- package/dist/server/mcp.js +96 -19
- package/dist/server/plugin-manager.js +1 -0
- package/dist/server/prompt-debug.js +58 -0
- package/dist/server/state.js +269 -25
- package/dist/server/workspace-routes.js +11 -1
- package/dist/server/workspaces.js +6 -0
- package/dist/server/ws.js +36 -9
- package/package.json +1 -1
- package/skill/SKILL.md +10 -9
- package/dist/client/assets/index-CqeJ7cMy.css +0 -1
- package/dist/client/assets/index-DiDoklNt.js +0 -209
package/dist/server/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
|
},
|
|
@@ -524,7 +601,7 @@ export const TOOL_REGISTRY = [
|
|
|
524
601
|
},
|
|
525
602
|
{
|
|
526
603
|
name: 'generate_image',
|
|
527
|
-
description: 'Generate an image using Gemini
|
|
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.
|
|
541
|
-
model: '
|
|
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
|
-
|
|
545
|
-
aspectRatio: (aspect_ratio || '16:9'),
|
|
621
|
+
responseModalities: ['IMAGE'],
|
|
546
622
|
},
|
|
547
623
|
});
|
|
548
|
-
const
|
|
549
|
-
|
|
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(
|
|
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
|
+
}
|