openwriter 0.8.7 → 0.8.8
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-5Av_FKzU.css +1 -0
- package/dist/client/assets/{index-BP-NVo6E.js → index-BwbbiqD9.js} +42 -42
- package/dist/client/index.html +2 -2
- package/dist/plugins/image-gen/dist/index.js +75 -26
- package/dist/server/connection-routes.js +1 -1
- package/dist/server/mcp.js +77 -38
- package/package.json +1 -1
- package/skill/SKILL.md +14 -31
- package/dist/client/assets/index-Cd7iUO_s.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BwbbiqD9.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-5Av_FKzU.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -4,8 +4,47 @@
|
|
|
4
4
|
* Uses Google Gemini (Nano Banana 2) for generation, saves to /_images/.
|
|
5
5
|
*/
|
|
6
6
|
import { join } from 'path';
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
/** Fallback: generate image via the publish platform API, download and save locally */
|
|
11
|
+
async function generateViaPlatform(prompt, dataDir, aspectRatio = '16:9') {
|
|
12
|
+
const configPath = join(homedir(), '.openwriter', 'config.json');
|
|
13
|
+
if (!existsSync(configPath))
|
|
14
|
+
return null;
|
|
15
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
16
|
+
const publishConfig = config.plugins?.['@openwriter/plugin-publish']?.config || {};
|
|
17
|
+
const platformKey = publishConfig['api-key'];
|
|
18
|
+
const apiUrl = publishConfig['api-url'] || 'https://publish.openwriter.io';
|
|
19
|
+
const profile = config.activeProfile || 'Default';
|
|
20
|
+
if (!platformKey)
|
|
21
|
+
return null;
|
|
22
|
+
console.log(`[ImageGen] Generating image (platform): "${prompt.slice(0, 80)}..."`);
|
|
23
|
+
const res = await fetch(`${apiUrl}/images/generate`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
Authorization: `Bearer ${platformKey}`,
|
|
28
|
+
'X-Profile': profile,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({ prompt, aspect_ratio: aspectRatio }),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
34
|
+
throw new Error(err.error || 'Platform image generation failed');
|
|
35
|
+
}
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
// Download image and save locally
|
|
38
|
+
const imageRes = await fetch(data.url);
|
|
39
|
+
const imageBuffer = Buffer.from(await imageRes.arrayBuffer());
|
|
40
|
+
const imagesDir = join(dataDir, '_images');
|
|
41
|
+
if (!existsSync(imagesDir))
|
|
42
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
43
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
44
|
+
writeFileSync(join(imagesDir, filename), imageBuffer);
|
|
45
|
+
console.log(`[ImageGen] Saved (platform): ${join(imagesDir, filename)}`);
|
|
46
|
+
return { success: true, src: `/_images/${filename}` };
|
|
47
|
+
}
|
|
9
48
|
const plugin = {
|
|
10
49
|
name: '@openwriter/plugin-image-gen',
|
|
11
50
|
version: '0.1.0',
|
|
@@ -15,8 +54,8 @@ const plugin = {
|
|
|
15
54
|
'gemini-api-key': {
|
|
16
55
|
type: 'string',
|
|
17
56
|
env: 'GEMINI_API_KEY',
|
|
18
|
-
required:
|
|
19
|
-
description: 'Google Gemini API key for image generation',
|
|
57
|
+
required: false,
|
|
58
|
+
description: 'Google Gemini API key for image generation (optional — falls back to publish platform)',
|
|
20
59
|
},
|
|
21
60
|
},
|
|
22
61
|
registerRoutes(ctx) {
|
|
@@ -32,30 +71,40 @@ const plugin = {
|
|
|
32
71
|
return;
|
|
33
72
|
}
|
|
34
73
|
const apiKey = ctx.config['gemini-api-key'] || process.env.GEMINI_API_KEY || '';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
let imageBytes;
|
|
75
|
+
if (apiKey) {
|
|
76
|
+
// Local Gemini generation
|
|
77
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
78
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
79
|
+
console.log(`[ImageGen] Generating image (local): "${prompt.slice(0, 80)}..."`);
|
|
80
|
+
const response = await ai.models.generateContent({
|
|
81
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
82
|
+
contents: `Generate a 16:9 aspect ratio image: ${prompt}`,
|
|
83
|
+
config: {
|
|
84
|
+
responseModalities: ['IMAGE'],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
88
|
+
if (!parts || parts.length === 0) {
|
|
89
|
+
res.status(422).json({ success: false, error: 'No image generated — content may have been filtered' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const imagePart = parts.find((p) => p.inlineData);
|
|
93
|
+
imageBytes = imagePart?.inlineData?.data;
|
|
94
|
+
if (!imageBytes) {
|
|
95
|
+
res.status(422).json({ success: false, error: 'No image data in response' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
54
98
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
else {
|
|
100
|
+
// Fallback: generate via publish platform API
|
|
101
|
+
const platformResult = await generateViaPlatform(prompt, ctx.dataDir);
|
|
102
|
+
if (!platformResult) {
|
|
103
|
+
res.status(400).json({ success: false, error: 'No GEMINI_API_KEY and publish platform not configured. Set GEMINI_API_KEY or log in to the publish plugin.' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// platformResult already saved to disk
|
|
107
|
+
res.json(platformResult);
|
|
59
108
|
return;
|
|
60
109
|
}
|
|
61
110
|
// Save to dataDir/_images/
|
|
@@ -10,7 +10,7 @@ export function createConnectionRouter() {
|
|
|
10
10
|
router.get('/api/connections', async (req, res) => {
|
|
11
11
|
try {
|
|
12
12
|
if (!isAuthenticated()) {
|
|
13
|
-
res.json({ connections: [] });
|
|
13
|
+
res.json({ connections: [], authenticated: false });
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
const upstream = await platformFetch('/connections/unified');
|
package/dist/server/mcp.js
CHANGED
|
@@ -24,11 +24,11 @@ import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
|
24
24
|
import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
|
|
25
25
|
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
26
26
|
/** Map a content type string to its frontmatter metadata object. */
|
|
27
|
-
function resolveTypeMeta(type) {
|
|
27
|
+
function resolveTypeMeta(type, url) {
|
|
28
28
|
switch (type) {
|
|
29
29
|
case 'tweet': return { tweetContext: { mode: 'tweet' } };
|
|
30
|
-
case 'reply': return { tweetContext: { mode: 'reply' } };
|
|
31
|
-
case 'quote': return { tweetContext: { mode: 'quote' } };
|
|
30
|
+
case 'reply': return { tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
31
|
+
case 'quote': return { tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
32
32
|
case 'article': return { articleContext: { active: true } };
|
|
33
33
|
case 'linkedin': return { linkedinContext: { active: true } };
|
|
34
34
|
case 'newsletter': return { newsletterContext: { active: true } };
|
|
@@ -271,18 +271,23 @@ export const TOOL_REGISTRY = [
|
|
|
271
271
|
},
|
|
272
272
|
{
|
|
273
273
|
name: 'create_document',
|
|
274
|
-
description: 'Create a new document. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document.
|
|
274
|
+
description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
|
|
275
275
|
schema: {
|
|
276
276
|
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
277
277
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
278
278
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
279
279
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
|
|
280
280
|
empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
|
|
281
|
-
content_type: z.
|
|
281
|
+
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
|
|
282
|
+
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
282
283
|
},
|
|
283
|
-
handler: async ({ title, path, workspace, container, empty, content_type }) => {
|
|
284
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
|
|
285
|
+
// Require url for reply/quote
|
|
286
|
+
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
287
|
+
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
288
|
+
}
|
|
284
289
|
// Default title from content_type if not provided
|
|
285
|
-
if (!title && content_type) {
|
|
290
|
+
if (!title && content_type && content_type !== 'document') {
|
|
286
291
|
const typeDefaults = {
|
|
287
292
|
tweet: 'Tweet', reply: 'Reply', quote: 'Quote Tweet', article: 'Article',
|
|
288
293
|
linkedin: 'LinkedIn Post', newsletter: 'Newsletter', blog: 'Blog Post',
|
|
@@ -313,7 +318,7 @@ export const TOOL_REGISTRY = [
|
|
|
313
318
|
const result = createDocument(title, undefined, path);
|
|
314
319
|
// Apply type-specific metadata
|
|
315
320
|
if (content_type) {
|
|
316
|
-
const typeMeta = resolveTypeMeta(content_type);
|
|
321
|
+
const typeMeta = resolveTypeMeta(content_type, url);
|
|
317
322
|
if (typeMeta) {
|
|
318
323
|
setMetadata(typeMeta);
|
|
319
324
|
}
|
|
@@ -337,7 +342,7 @@ export const TOOL_REGISTRY = [
|
|
|
337
342
|
}
|
|
338
343
|
// Two-step flow: create file on disk WITHOUT switching the user's view.
|
|
339
344
|
// The spinner persists in the sidebar until populate_document is called.
|
|
340
|
-
const typeMeta = content_type ? resolveTypeMeta(content_type) : undefined;
|
|
345
|
+
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
341
346
|
const result = createDocumentFile(title, path, typeMeta);
|
|
342
347
|
let wsInfo = '';
|
|
343
348
|
if (wsTarget) {
|
|
@@ -875,7 +880,7 @@ export const TOOL_REGISTRY = [
|
|
|
875
880
|
},
|
|
876
881
|
{
|
|
877
882
|
name: 'insert_image',
|
|
878
|
-
description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path.
|
|
883
|
+
description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path. Uses local GEMINI_API_KEY if set, otherwise falls back to publish platform API.',
|
|
879
884
|
schema: {
|
|
880
885
|
prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
|
|
881
886
|
docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
|
|
@@ -885,10 +890,6 @@ export const TOOL_REGISTRY = [
|
|
|
885
890
|
set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
886
891
|
},
|
|
887
892
|
handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
|
|
888
|
-
const apiKey = process.env.GEMINI_API_KEY;
|
|
889
|
-
if (!apiKey) {
|
|
890
|
-
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
891
|
-
}
|
|
892
893
|
const inlineMode = docId && afterNodeId;
|
|
893
894
|
const filename = inlineMode ? resolveDocId(docId) : undefined;
|
|
894
895
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
@@ -905,32 +906,70 @@ export const TOOL_REGISTRY = [
|
|
|
905
906
|
applyChanges([loadingChange]);
|
|
906
907
|
}
|
|
907
908
|
try {
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
909
|
+
let imgFilename;
|
|
910
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
911
|
+
if (apiKey) {
|
|
912
|
+
// Generate image via local Gemini
|
|
913
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
914
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
915
|
+
const response = await ai.models.generateContent({
|
|
916
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
917
|
+
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
918
|
+
config: {
|
|
919
|
+
responseModalities: ['IMAGE'],
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
923
|
+
const imagePart = parts?.find((p) => p.inlineData);
|
|
924
|
+
if (!imagePart?.inlineData?.data) {
|
|
925
|
+
if (inlineMode && !targetIsNonActive) {
|
|
926
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
927
|
+
}
|
|
928
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
929
|
+
}
|
|
930
|
+
ensureDataDir();
|
|
931
|
+
const imagesDir = join(getDataDir(), '_images');
|
|
932
|
+
if (!existsSync(imagesDir))
|
|
933
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
934
|
+
imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
935
|
+
writeFileSync(join(imagesDir, imgFilename), Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
// Fallback: generate via publish platform API
|
|
939
|
+
const { platformFetch, isAuthenticated } = await import('./connections.js');
|
|
940
|
+
if (!isAuthenticated()) {
|
|
941
|
+
if (inlineMode && !targetIsNonActive) {
|
|
942
|
+
try {
|
|
943
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
944
|
+
}
|
|
945
|
+
catch { }
|
|
946
|
+
}
|
|
947
|
+
return { content: [{ type: 'text', text: 'Error: No GEMINI_API_KEY and publish platform not configured. Set GEMINI_API_KEY or log in to the publish plugin.' }] };
|
|
948
|
+
}
|
|
949
|
+
const res = await platformFetch('/images/generate', {
|
|
950
|
+
method: 'POST',
|
|
951
|
+
body: JSON.stringify({ prompt, aspect_ratio: aspect_ratio || '16:9' }),
|
|
952
|
+
});
|
|
953
|
+
if (!res.ok) {
|
|
954
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
955
|
+
if (inlineMode && !targetIsNonActive) {
|
|
956
|
+
try {
|
|
957
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
958
|
+
}
|
|
959
|
+
catch { }
|
|
960
|
+
}
|
|
961
|
+
return { content: [{ type: 'text', text: `Error: ${err.error || 'Platform generation failed'}` }] };
|
|
923
962
|
}
|
|
924
|
-
|
|
963
|
+
const data = await res.json();
|
|
964
|
+
const imageRes = await fetch(data.url);
|
|
965
|
+
const imageBuffer = Buffer.from(await imageRes.arrayBuffer());
|
|
966
|
+
ensureDataDir();
|
|
967
|
+
const imagesDir = join(getDataDir(), '_images');
|
|
968
|
+
if (!existsSync(imagesDir))
|
|
969
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
970
|
+
imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
971
|
+
writeFileSync(join(imagesDir, imgFilename), imageBuffer);
|
|
925
972
|
}
|
|
926
|
-
// Save to ~/.openwriter/_images/
|
|
927
|
-
ensureDataDir();
|
|
928
|
-
const imagesDir = join(getDataDir(), '_images');
|
|
929
|
-
if (!existsSync(imagesDir))
|
|
930
|
-
mkdirSync(imagesDir, { recursive: true });
|
|
931
|
-
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
932
|
-
const imgPath = join(imagesDir, imgFilename);
|
|
933
|
-
writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
934
973
|
const src = `/_images/${imgFilename}`;
|
|
935
974
|
// Mode 1: Inline insert
|
|
936
975
|
if (inlineMode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
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
|
@@ -119,7 +119,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
119
119
|
|------|-----------|-------------|
|
|
120
120
|
| `list_documents` | — | List all documents with title, docId, word count, active status |
|
|
121
121
|
| `switch_document` | `docId` | Switch to a different document by docId |
|
|
122
|
-
| `create_document` | `title?`, ... | Create a new
|
|
122
|
+
| `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" |
|
|
123
123
|
| `open_file` | `path` | Open an existing .md file from any location on disk |
|
|
124
124
|
| `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
|
|
125
125
|
| `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
|
|
@@ -213,8 +213,8 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
213
213
|
**Always use the two-step flow** when creating new content:
|
|
214
214
|
|
|
215
215
|
```
|
|
216
|
-
1. create_document({ title: "My Doc" })
|
|
217
|
-
2. populate_document({ content: "..." })
|
|
216
|
+
1. create_document({ title: "My Doc", content_type: "document" }) ← fires instantly, shows spinner
|
|
217
|
+
2. populate_document({ content: "..." }) ← delivers content, clears spinner
|
|
218
218
|
```
|
|
219
219
|
|
|
220
220
|
**Why two steps?** MCP tool calls are atomic — the server doesn't receive the call until ALL parameters are fully generated. For a document with hundreds or thousands of words, the user would wait 30+ seconds with zero feedback while you generate content tokens. The two-step flow shows a sidebar spinner immediately (step 1 has no content to generate), then the spinner persists while you generate and deliver the content (step 2).
|
|
@@ -232,6 +232,7 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
232
232
|
```
|
|
233
233
|
create_document({
|
|
234
234
|
title: "Opening Chapter",
|
|
235
|
+
content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
|
|
235
236
|
workspace: "The Immortal", ← creates workspace if it doesn't exist
|
|
236
237
|
container: "Chapters" ← creates container if it doesn't exist
|
|
237
238
|
})
|
|
@@ -269,7 +270,7 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
|
|
|
269
270
|
### Creating new content (two-step)
|
|
270
271
|
|
|
271
272
|
```
|
|
272
|
-
1. create_document({ title: "My Doc", workspace: "Project", container: "Chapters" })
|
|
273
|
+
1. create_document({ title: "My Doc", content_type: "document", workspace: "Project", container: "Chapters" })
|
|
273
274
|
→ returns docId "a1b2c3d4", spinner appears
|
|
274
275
|
2. populate_document({ docId: "a1b2c3d4", content: "# ..." })
|
|
275
276
|
→ content delivered, spinner clears
|
|
@@ -280,13 +281,13 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
|
|
|
280
281
|
### Building a workspace (multiple docs)
|
|
281
282
|
|
|
282
283
|
```
|
|
283
|
-
1. create_document({ title: "Ch 1", workspace: "My Book", container: "Chapters" })
|
|
284
|
+
1. create_document({ title: "Ch 1", content_type: "document", workspace: "My Book", container: "Chapters" })
|
|
284
285
|
→ returns docId "ch1docid"
|
|
285
286
|
2. populate_document({ docId: "ch1docid", content: "..." })
|
|
286
|
-
3. create_document({ title: "Ch 2", workspace: "My Book", container: "Chapters" })
|
|
287
|
+
3. create_document({ title: "Ch 2", content_type: "document", workspace: "My Book", container: "Chapters" })
|
|
287
288
|
→ returns docId "ch2docid"
|
|
288
289
|
4. populate_document({ docId: "ch2docid", content: "..." })
|
|
289
|
-
5. create_document({ title: "Character Bible", workspace: "My Book", container: "References" })
|
|
290
|
+
5. create_document({ title: "Character Bible", content_type: "document", workspace: "My Book", container: "References" })
|
|
290
291
|
6. populate_document({ docId: "<from step 5>", content: "..." })
|
|
291
292
|
7. tag_doc + update_workspace_context → organize and add context
|
|
292
293
|
```
|
|
@@ -325,9 +326,7 @@ OpenWriter doubles as a tweet compose surface. When `tweetContext` is set in a d
|
|
|
325
326
|
### Setting up a tweet document
|
|
326
327
|
|
|
327
328
|
```
|
|
328
|
-
1. create_document({ title: "Reply to @username" })
|
|
329
|
-
2. populate_document({ content: " " }) ← empty content, compose area
|
|
330
|
-
3. set_metadata({ tweetContext: { url: "https://x.com/user/status/123", mode: "reply" } })
|
|
329
|
+
1. create_document({ title: "Reply to @username", content_type: "reply", url: "https://x.com/user/status/123", empty: true })
|
|
331
330
|
```
|
|
332
331
|
|
|
333
332
|
- **`url`** — the tweet URL to reply to or quote
|
|
@@ -370,31 +369,15 @@ The compose view fetches and renders the parent tweet (text, author, avatar, med
|
|
|
370
369
|
|
|
371
370
|
### Template Documents
|
|
372
371
|
|
|
373
|
-
Users can also create tweet and article templates directly from the browser UI using the **Templates** dropdown in the titlebar. For agent-initiated
|
|
372
|
+
Users can also create tweet and article templates directly from the browser UI using the **Templates** dropdown in the titlebar. For agent-initiated creation, `content_type` handles all metadata automatically:
|
|
374
373
|
|
|
375
|
-
**Tweet
|
|
376
|
-
```
|
|
377
|
-
1. create_document({ empty: true })
|
|
378
|
-
2. set_metadata({ tweetContext: { mode: "tweet" }, title: "Tweet" })
|
|
379
|
-
```
|
|
374
|
+
**Tweet:** `create_document({ title: "Tweet", content_type: "tweet", empty: true })`
|
|
380
375
|
|
|
381
|
-
**Reply
|
|
382
|
-
```
|
|
383
|
-
1. create_document({ empty: true })
|
|
384
|
-
2. set_metadata({ tweetContext: { url: "https://x.com/user/status/123", mode: "reply" }, title: "Reply" })
|
|
385
|
-
```
|
|
376
|
+
**Reply:** `create_document({ title: "Reply", content_type: "reply", url: "https://x.com/user/status/123", empty: true })`
|
|
386
377
|
|
|
387
|
-
**Quote tweet
|
|
388
|
-
```
|
|
389
|
-
1. create_document({ empty: true })
|
|
390
|
-
2. set_metadata({ tweetContext: { url: "https://x.com/user/status/123", mode: "quote" }, title: "Quote Tweet" })
|
|
391
|
-
```
|
|
378
|
+
**Quote tweet:** `create_document({ title: "Quote Tweet", content_type: "quote", url: "https://x.com/user/status/123", empty: true })`
|
|
392
379
|
|
|
393
|
-
**Article
|
|
394
|
-
```
|
|
395
|
-
1. create_document({ empty: true })
|
|
396
|
-
2. set_metadata({ articleContext: { active: true }, title: "Article" })
|
|
397
|
-
```
|
|
380
|
+
**Article:** `create_document({ title: "Article", content_type: "article", empty: true })`
|
|
398
381
|
|
|
399
382
|
### Removing tweet mode
|
|
400
383
|
|