openwriter 0.2.2 → 0.3.0
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/README.md +2 -2
- package/dist/bin/pad.js +35 -3
- package/dist/client/assets/index-BLVKwyNi.js +209 -0
- package/dist/client/assets/index-NIq_FmFc.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +45 -34
- package/dist/server/mcp-client.js +29 -28
- package/dist/server/mcp.js +93 -15
- package/dist/server/state.js +69 -25
- package/dist/server/tweet-routes.js +98 -0
- package/dist/server/update-check.js +96 -0
- package/dist/server/ws.js +38 -2
- package/package.json +2 -1
- package/skill/SKILL.md +13 -10
- package/dist/client/assets/index-De-jpZgc.css +0 -1
- package/dist/client/assets/index-FOERHzGc.js +0 -205
package/dist/server/mcp.js
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
* Uses compact wire format for token efficiency.
|
|
4
4
|
* Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
|
|
5
5
|
*/
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
6
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
11
|
import { z } from 'zod';
|
|
12
|
+
import { DATA_DIR, ensureDataDir } from './helpers.js';
|
|
9
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
|
|
10
14
|
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
|
|
11
|
-
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
15
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
12
16
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc } from './workspaces.js';
|
|
13
17
|
import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
|
|
14
18
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
15
19
|
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
20
|
+
import { getUpdateInfo } from './update-check.js';
|
|
16
21
|
export const TOOL_REGISTRY = [
|
|
17
22
|
{
|
|
18
23
|
name: 'read_pad',
|
|
@@ -63,7 +68,10 @@ export const TOOL_REGISTRY = [
|
|
|
63
68
|
description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
|
|
64
69
|
schema: {},
|
|
65
70
|
handler: async () => {
|
|
66
|
-
|
|
71
|
+
const status = getStatus();
|
|
72
|
+
const latestVersion = getUpdateInfo();
|
|
73
|
+
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
74
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
67
75
|
},
|
|
68
76
|
},
|
|
69
77
|
{
|
|
@@ -106,14 +114,15 @@ export const TOOL_REGISTRY = [
|
|
|
106
114
|
},
|
|
107
115
|
{
|
|
108
116
|
name: 'create_document',
|
|
109
|
-
description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first.
|
|
117
|
+
description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). 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).',
|
|
110
118
|
schema: {
|
|
111
119
|
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
112
120
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
113
121
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
114
122
|
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.'),
|
|
123
|
+
empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
|
|
115
124
|
},
|
|
116
|
-
handler: async ({ title, path, workspace, container }) => {
|
|
125
|
+
handler: async ({ title, path, workspace, container, empty }) => {
|
|
117
126
|
// Resolve workspace/container up front so spinner renders in the right place
|
|
118
127
|
let wsTarget;
|
|
119
128
|
if (workspace) {
|
|
@@ -126,24 +135,38 @@ export const TOOL_REGISTRY = [
|
|
|
126
135
|
wsTarget = { wsFilename: ws.filename, containerId };
|
|
127
136
|
broadcastWorkspacesChanged(); // Browser sees container structure before spinner
|
|
128
137
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
if (!empty) {
|
|
139
|
+
broadcastWritingStarted(title || 'Untitled', wsTarget);
|
|
140
|
+
// Yield so the browser receives and renders the placeholder before heavy work
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
142
|
+
}
|
|
132
143
|
try {
|
|
133
144
|
// Lock browser doc-updates: prevents race where browser sends a doc-update
|
|
134
145
|
// for the previous document but server has already switched active doc.
|
|
135
146
|
setAgentLock();
|
|
136
147
|
const result = createDocument(title, undefined, path);
|
|
137
|
-
|
|
138
|
-
save(); // Persist agentCreated flag to frontmatter
|
|
139
|
-
// Auto-add to workspace if specified (defer sidebar broadcasts to populate_document
|
|
140
|
-
// so the real doc entry doesn't appear alongside the spinner placeholder)
|
|
148
|
+
// Auto-add to workspace if specified
|
|
141
149
|
let wsInfo = '';
|
|
142
150
|
if (wsTarget) {
|
|
143
151
|
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
144
152
|
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
145
153
|
}
|
|
146
|
-
|
|
154
|
+
if (empty) {
|
|
155
|
+
// Immediate switch — no spinner, no populate_document needed
|
|
156
|
+
save();
|
|
157
|
+
broadcastDocumentsChanged();
|
|
158
|
+
broadcastWorkspacesChanged();
|
|
159
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
|
|
160
|
+
return {
|
|
161
|
+
content: [{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
|
|
164
|
+
}],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Two-step flow: spinner persists until populate_document is called
|
|
168
|
+
setMetadata({ agentCreated: true });
|
|
169
|
+
save(); // Persist agentCreated flag to frontmatter
|
|
147
170
|
return {
|
|
148
171
|
content: [{
|
|
149
172
|
type: 'text',
|
|
@@ -152,7 +175,8 @@ export const TOOL_REGISTRY = [
|
|
|
152
175
|
};
|
|
153
176
|
}
|
|
154
177
|
catch (err) {
|
|
155
|
-
|
|
178
|
+
if (!empty)
|
|
179
|
+
broadcastWritingFinished();
|
|
156
180
|
throw err;
|
|
157
181
|
}
|
|
158
182
|
},
|
|
@@ -270,6 +294,7 @@ export const TOOL_REGISTRY = [
|
|
|
270
294
|
for (const key of removed)
|
|
271
295
|
delete meta[key];
|
|
272
296
|
save();
|
|
297
|
+
broadcastMetadataChanged(getMetadata());
|
|
273
298
|
if (cleaned.title) {
|
|
274
299
|
broadcastTitleChanged(cleaned.title);
|
|
275
300
|
broadcastDocumentsChanged();
|
|
@@ -492,8 +517,61 @@ export const TOOL_REGISTRY = [
|
|
|
492
517
|
return { content: [{ type: 'text', text }] };
|
|
493
518
|
},
|
|
494
519
|
},
|
|
520
|
+
{
|
|
521
|
+
name: 'generate_image',
|
|
522
|
+
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.',
|
|
523
|
+
schema: {
|
|
524
|
+
prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
|
|
525
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Use "5:2" for article covers.'),
|
|
526
|
+
set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
527
|
+
},
|
|
528
|
+
handler: async ({ prompt, aspect_ratio, set_cover }) => {
|
|
529
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
530
|
+
if (!apiKey) {
|
|
531
|
+
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
532
|
+
}
|
|
533
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
534
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
535
|
+
const response = await ai.models.generateImages({
|
|
536
|
+
model: 'imagen-4.0-generate-001',
|
|
537
|
+
prompt,
|
|
538
|
+
config: {
|
|
539
|
+
numberOfImages: 1,
|
|
540
|
+
aspectRatio: (aspect_ratio || '16:9'),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const image = response.generatedImages?.[0];
|
|
544
|
+
if (!image?.image?.imageBytes) {
|
|
545
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
546
|
+
}
|
|
547
|
+
// Save to ~/.openwriter/_images/
|
|
548
|
+
ensureDataDir();
|
|
549
|
+
const imagesDir = join(DATA_DIR, '_images');
|
|
550
|
+
if (!existsSync(imagesDir))
|
|
551
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
552
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
553
|
+
const filePath = join(imagesDir, filename);
|
|
554
|
+
writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
|
|
555
|
+
const src = `/_images/${filename}`;
|
|
556
|
+
// Optionally set as article cover
|
|
557
|
+
if (set_cover) {
|
|
558
|
+
const meta = getMetadata();
|
|
559
|
+
const articleContext = meta.articleContext || {};
|
|
560
|
+
articleContext.coverImage = src;
|
|
561
|
+
setMetadata({ articleContext });
|
|
562
|
+
save();
|
|
563
|
+
broadcastMetadataChanged(getMetadata());
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
content: [{
|
|
567
|
+
type: 'text',
|
|
568
|
+
text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
|
|
569
|
+
}],
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
},
|
|
495
573
|
];
|
|
496
|
-
/** Register MCP tools from plugins.
|
|
574
|
+
/** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
|
|
497
575
|
export function registerPluginTools(tools) {
|
|
498
576
|
for (const tool of tools) {
|
|
499
577
|
TOOL_REGISTRY.push({
|
|
@@ -518,7 +596,7 @@ export function removePluginTools(names) {
|
|
|
518
596
|
}
|
|
519
597
|
export async function startMcpServer() {
|
|
520
598
|
const server = new McpServer({
|
|
521
|
-
name: '
|
|
599
|
+
name: 'openwriter',
|
|
522
600
|
version: '0.2.0',
|
|
523
601
|
});
|
|
524
602
|
for (const tool of TOOL_REGISTRY) {
|
package/dist/server/state.js
CHANGED
|
@@ -10,6 +10,7 @@ import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
|
+
import trash from 'trash';
|
|
13
14
|
const DEFAULT_DOC = {
|
|
14
15
|
type: 'doc',
|
|
15
16
|
content: [{ type: 'paragraph', content: [] }],
|
|
@@ -143,6 +144,22 @@ export function setMetadata(updates) {
|
|
|
143
144
|
state.metadata = { ...state.metadata, ...updates };
|
|
144
145
|
if (updates.title)
|
|
145
146
|
state.title = updates.title;
|
|
147
|
+
// Auto-tag: tweetContext / articleContext ↔ "x" tag
|
|
148
|
+
for (const key of ['tweetContext', 'articleContext']) {
|
|
149
|
+
if (key in updates) {
|
|
150
|
+
const filename = state.filePath
|
|
151
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
152
|
+
: '';
|
|
153
|
+
if (filename) {
|
|
154
|
+
if (updates[key]) {
|
|
155
|
+
addDocTag(filename, 'x');
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
removeDocTag(filename, 'x');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
146
163
|
}
|
|
147
164
|
export function getStatus() {
|
|
148
165
|
return {
|
|
@@ -621,6 +638,8 @@ export function load() {
|
|
|
621
638
|
migrateSwJsonFiles();
|
|
622
639
|
// Clean up empty temp files from previous sessions
|
|
623
640
|
cleanupEmptyTempFiles();
|
|
641
|
+
// Trash docs marked as ephemeral from previous sessions
|
|
642
|
+
cleanupEphemeralDocs();
|
|
624
643
|
// Find most recently modified .md file
|
|
625
644
|
const files = readdirSync(DATA_DIR)
|
|
626
645
|
.filter((f) => f.endsWith('.md'))
|
|
@@ -630,33 +649,38 @@ export function load() {
|
|
|
630
649
|
return { name: f, path: fullPath, mtime: stat.mtimeMs };
|
|
631
650
|
})
|
|
632
651
|
.sort((a, b) => b.mtime - a.mtime);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
652
|
+
// Walk sorted files until we find a real document with content.
|
|
653
|
+
// Skip empty temp files so we don't open a blank scratch pad when real docs exist.
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
try {
|
|
656
|
+
const raw = readFileSync(file.path, 'utf-8');
|
|
657
|
+
const parsed = markdownToTiptap(raw);
|
|
658
|
+
const isTemp = file.name.startsWith(TEMP_PREFIX);
|
|
659
|
+
// Skip empty temp files — prefer a real document
|
|
660
|
+
if (isTemp && isDocEmpty(parsed.document))
|
|
661
|
+
continue;
|
|
662
|
+
state.document = parsed.document;
|
|
663
|
+
state.title = parsed.title;
|
|
664
|
+
state.metadata = parsed.metadata;
|
|
665
|
+
state.lastModified = new Date(statSync(file.path).mtimeMs);
|
|
666
|
+
state.filePath = file.path;
|
|
667
|
+
state.isTemp = isTemp;
|
|
668
|
+
// Lazy docId migration: assign if missing, save to persist
|
|
669
|
+
const hadDocId = !!state.metadata.docId;
|
|
670
|
+
state.docId = ensureDocId(state.metadata);
|
|
671
|
+
if (!hadDocId) {
|
|
672
|
+
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
673
|
+
writeFileSync(state.filePath, md, 'utf-8');
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// Corrupt file — try next one
|
|
679
|
+
continue;
|
|
656
680
|
}
|
|
657
681
|
}
|
|
658
|
-
|
|
659
|
-
|
|
682
|
+
// If nothing loaded (all files were empty temps or corrupt), start fresh
|
|
683
|
+
if (!state.filePath) {
|
|
660
684
|
state.filePath = tempFilePath();
|
|
661
685
|
state.isTemp = true;
|
|
662
686
|
}
|
|
@@ -766,6 +790,26 @@ function cleanupEmptyTempFiles() {
|
|
|
766
790
|
}
|
|
767
791
|
catch { /* ignore errors during cleanup */ }
|
|
768
792
|
}
|
|
793
|
+
/** Delete docs marked as ephemeral from previous sessions */
|
|
794
|
+
function cleanupEphemeralDocs() {
|
|
795
|
+
try {
|
|
796
|
+
const wsRefs = getWorkspaceReferencedFiles();
|
|
797
|
+
const files = readdirSync(DATA_DIR).filter(f => f.endsWith('.md'));
|
|
798
|
+
for (const f of files) {
|
|
799
|
+
if (wsRefs.has(f))
|
|
800
|
+
continue; // protect workspace-referenced docs
|
|
801
|
+
try {
|
|
802
|
+
const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
|
|
803
|
+
const { data } = matter(raw);
|
|
804
|
+
if (data.ephemeral) {
|
|
805
|
+
trash(join(DATA_DIR, f)).catch(() => { }); // move to OS trash, fire-and-forget
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch { /* skip unreadable */ }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
catch { /* ignore */ }
|
|
812
|
+
}
|
|
769
813
|
// ============================================================================
|
|
770
814
|
// DOCUMENT-LEVEL TAG OPERATIONS
|
|
771
815
|
// ============================================================================
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tweet embed proxy: fetches tweet data from fxtwitter API.
|
|
3
|
+
* GET /api/tweet-embed?url=... → normalized TweetEmbedData JSON.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
// In-memory cache: URL → { data, expires }
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
function parseTweetUrl(url) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
if (!['twitter.com', 'x.com', 'www.twitter.com', 'www.x.com'].includes(parsed.hostname)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Path: /{username}/status/{id}
|
|
16
|
+
const match = parsed.pathname.match(/^\/([^/]+)\/status\/(\d+)/);
|
|
17
|
+
if (!match)
|
|
18
|
+
return null;
|
|
19
|
+
return { username: match[1], statusId: match[2] };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function normalizeTweet(tweet) {
|
|
26
|
+
const data = {
|
|
27
|
+
author: {
|
|
28
|
+
name: tweet.author?.name || '',
|
|
29
|
+
username: tweet.author?.screen_name || '',
|
|
30
|
+
avatarUrl: tweet.author?.avatar_url || '',
|
|
31
|
+
},
|
|
32
|
+
text: tweet.text || '',
|
|
33
|
+
createdAt: tweet.created_at || '',
|
|
34
|
+
metrics: {
|
|
35
|
+
likes: tweet.likes ?? 0,
|
|
36
|
+
retweets: tweet.retweets ?? 0,
|
|
37
|
+
replies: tweet.replies ?? 0,
|
|
38
|
+
views: tweet.views ?? 0,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
if (tweet.media?.all?.length) {
|
|
42
|
+
data.media = tweet.media.all.map((m) => ({
|
|
43
|
+
type: m.type || 'photo',
|
|
44
|
+
url: m.url || m.thumbnail_url || '',
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
if (tweet.quote) {
|
|
48
|
+
data.quoteTweet = normalizeTweet(tweet.quote);
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
export function createTweetRouter() {
|
|
53
|
+
const router = Router();
|
|
54
|
+
router.get('/api/tweet-embed', async (req, res) => {
|
|
55
|
+
const url = req.query.url;
|
|
56
|
+
if (!url) {
|
|
57
|
+
res.status(400).json({ error: 'url query parameter is required' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const parsed = parseTweetUrl(url);
|
|
61
|
+
if (!parsed) {
|
|
62
|
+
res.status(400).json({ error: 'Invalid tweet URL. Supports x.com and twitter.com URLs.' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Check cache
|
|
66
|
+
const cacheKey = `${parsed.username}/${parsed.statusId}`;
|
|
67
|
+
const cached = cache.get(cacheKey);
|
|
68
|
+
if (cached && cached.expires > Date.now()) {
|
|
69
|
+
res.json(cached.data);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const apiUrl = `https://api.fxtwitter.com/${parsed.username}/status/${parsed.statusId}`;
|
|
74
|
+
const response = await fetch(apiUrl);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
if (response.status === 404) {
|
|
77
|
+
res.status(404).json({ error: 'Tweet not found' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.status(502).json({ error: `fxtwitter API returned ${response.status}` });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const json = await response.json();
|
|
84
|
+
if (!json.tweet) {
|
|
85
|
+
res.status(404).json({ error: 'Tweet not found in API response' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const data = normalizeTweet(json.tweet);
|
|
89
|
+
// Cache it
|
|
90
|
+
cache.set(cacheKey, { data, expires: Date.now() + CACHE_TTL_MS });
|
|
91
|
+
res.json(data);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
res.status(502).json({ error: `Failed to fetch tweet: ${err.message}` });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return router;
|
|
98
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in update check — zero dependencies.
|
|
3
|
+
* Uses Node's built-in fetch + existing config system.
|
|
4
|
+
* Fire-and-forget: never blocks startup, never throws to caller.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { readConfig, saveConfig } from './helpers.js';
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
13
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
14
|
+
let cachedLatestVersion = null;
|
|
15
|
+
/** Compare two semver strings numerically. Returns -1, 0, or 1. */
|
|
16
|
+
export function compareVersions(a, b) {
|
|
17
|
+
const partsA = a.split('.').map((s) => parseInt(s, 10));
|
|
18
|
+
const partsB = b.split('.').map((s) => parseInt(s, 10));
|
|
19
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
20
|
+
for (let i = 0; i < len; i++) {
|
|
21
|
+
const numA = partsA[i] || 0;
|
|
22
|
+
const numB = partsB[i] || 0;
|
|
23
|
+
if (numA < numB)
|
|
24
|
+
return -1;
|
|
25
|
+
if (numA > numB)
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
/** Read current package version from package.json on disk. */
|
|
31
|
+
function getCurrentVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const pkgPath = join(__dirname, '../../package.json');
|
|
34
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
35
|
+
return pkg.version || '0.0.0';
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return '0.0.0';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check npm registry for a newer version. Fire-and-forget.
|
|
43
|
+
* - Respects NO_UPDATE_NOTIFIER env var
|
|
44
|
+
* - Caches result for 24h in config
|
|
45
|
+
* - Logs to stderr if update available
|
|
46
|
+
*/
|
|
47
|
+
export async function checkForUpdate() {
|
|
48
|
+
if (process.env.NO_UPDATE_NOTIFIER)
|
|
49
|
+
return;
|
|
50
|
+
const config = readConfig();
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const currentVersion = getCurrentVersion();
|
|
53
|
+
// Use cached result if checked within 24h
|
|
54
|
+
if (config.lastUpdateCheck && config.latestVersion) {
|
|
55
|
+
const lastCheck = new Date(config.lastUpdateCheck).getTime();
|
|
56
|
+
if (now - lastCheck < CHECK_INTERVAL_MS) {
|
|
57
|
+
if (compareVersions(currentVersion, config.latestVersion) < 0) {
|
|
58
|
+
cachedLatestVersion = config.latestVersion;
|
|
59
|
+
console.error(`[OpenWriter] Update available: ${currentVersion} → ${config.latestVersion} — run: npm update -g openwriter`);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Fetch latest version from npm registry
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch('https://registry.npmjs.org/openwriter/latest', {
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return;
|
|
74
|
+
const data = (await res.json());
|
|
75
|
+
const latestVersion = data.version;
|
|
76
|
+
if (!latestVersion)
|
|
77
|
+
return;
|
|
78
|
+
// Save to config for 24h cache
|
|
79
|
+
saveConfig({
|
|
80
|
+
lastUpdateCheck: new Date().toISOString(),
|
|
81
|
+
latestVersion,
|
|
82
|
+
});
|
|
83
|
+
if (compareVersions(currentVersion, latestVersion) < 0) {
|
|
84
|
+
cachedLatestVersion = latestVersion;
|
|
85
|
+
console.error(`[OpenWriter] Update available: ${currentVersion} → ${latestVersion} — run: npm update -g openwriter`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Network error, timeout, abort — silently ignore
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Sync getter: returns latest version string if update available, null otherwise. */
|
|
94
|
+
export function getUpdateInfo() {
|
|
95
|
+
return cachedLatestVersion;
|
|
96
|
+
}
|
package/dist/server/ws.js
CHANGED
|
@@ -49,6 +49,7 @@ export function setupWebSocket(server) {
|
|
|
49
49
|
title: getTitle(),
|
|
50
50
|
filename,
|
|
51
51
|
docId: getDocId(),
|
|
52
|
+
metadata: getMetadata(),
|
|
52
53
|
}));
|
|
53
54
|
// Send pending docs info on connect
|
|
54
55
|
ws.send(JSON.stringify({
|
|
@@ -85,6 +86,7 @@ export function setupWebSocket(server) {
|
|
|
85
86
|
title: getTitle(),
|
|
86
87
|
filename,
|
|
87
88
|
docId: getDocId(),
|
|
89
|
+
metadata: getMetadata(),
|
|
88
90
|
}));
|
|
89
91
|
}
|
|
90
92
|
if (msg.type === 'title-update' && msg.title) {
|
|
@@ -112,6 +114,33 @@ export function setupWebSocket(server) {
|
|
|
112
114
|
console.error('[WS] Create document failed:', err.message);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
117
|
+
if (msg.type === 'create-template' && msg.template) {
|
|
118
|
+
try {
|
|
119
|
+
const tmpl = msg.template;
|
|
120
|
+
const url = msg.url;
|
|
121
|
+
// Create with no title → temp file path (avoids naming conflicts)
|
|
122
|
+
const result = createDocument();
|
|
123
|
+
// Set template-appropriate metadata
|
|
124
|
+
if (tmpl === 'tweet') {
|
|
125
|
+
setMetadata({ tweetContext: { mode: 'tweet' }, title: 'Tweet' });
|
|
126
|
+
}
|
|
127
|
+
else if (tmpl === 'reply') {
|
|
128
|
+
setMetadata({ tweetContext: { url, mode: 'reply' }, title: 'Reply' });
|
|
129
|
+
}
|
|
130
|
+
else if (tmpl === 'quote') {
|
|
131
|
+
setMetadata({ tweetContext: { url, mode: 'quote' }, title: 'Quote Tweet' });
|
|
132
|
+
}
|
|
133
|
+
else if (tmpl === 'article') {
|
|
134
|
+
setMetadata({ articleContext: { active: true }, title: 'Article' });
|
|
135
|
+
}
|
|
136
|
+
save();
|
|
137
|
+
broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
|
|
138
|
+
broadcastDocumentsChanged();
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error('[WS] Create template failed:', err.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
if (msg.type === 'pending-resolved' && msg.filename) {
|
|
116
145
|
const action = msg.action; // 'accept' or 'reject'
|
|
117
146
|
const resolvedFilename = msg.filename;
|
|
@@ -168,14 +197,21 @@ export function setupWebSocket(server) {
|
|
|
168
197
|
});
|
|
169
198
|
});
|
|
170
199
|
}
|
|
171
|
-
export function broadcastDocumentSwitched(document, title, filename) {
|
|
172
|
-
const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId() });
|
|
200
|
+
export function broadcastDocumentSwitched(document, title, filename, metadata) {
|
|
201
|
+
const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: metadata ?? getMetadata() });
|
|
173
202
|
for (const ws of clients) {
|
|
174
203
|
if (ws.readyState === WebSocket.OPEN) {
|
|
175
204
|
ws.send(msg);
|
|
176
205
|
}
|
|
177
206
|
}
|
|
178
207
|
}
|
|
208
|
+
export function broadcastMetadataChanged(metadata) {
|
|
209
|
+
const msg = JSON.stringify({ type: 'metadata-changed', metadata });
|
|
210
|
+
for (const ws of clients) {
|
|
211
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
212
|
+
ws.send(msg);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
179
215
|
export function broadcastDocumentsChanged() {
|
|
180
216
|
const msg = JSON.stringify({ type: 'documents-changed' });
|
|
181
217
|
for (const ws of clients) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"lint": "eslint src server bin --ext .ts,.tsx"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@google/genai": "^1.42.0",
|
|
36
37
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
37
38
|
"@tiptap/core": "^3.0.0",
|
|
38
39
|
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
package/skill/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
23
23
|
|
|
24
24
|
## Setup — Which Path?
|
|
25
25
|
|
|
26
|
-
Check whether the `
|
|
26
|
+
Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
|
|
27
27
|
|
|
28
28
|
### MCP tools ARE available (ready to use)
|
|
29
29
|
|
|
@@ -38,11 +38,14 @@ Skip to [Writing Strategy](#writing-strategy) below.
|
|
|
38
38
|
|
|
39
39
|
The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 24 editing tools.
|
|
40
40
|
|
|
41
|
-
**Step 1:** Tell the user to install
|
|
41
|
+
**Step 1:** Tell the user to install globally and add the MCP server:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
+
# Install globally for instant startup (no npx resolution delay)
|
|
45
|
+
npm install -g openwriter
|
|
46
|
+
|
|
44
47
|
# Add the OpenWriter MCP server to Claude Code
|
|
45
|
-
claude mcp add -s user
|
|
48
|
+
claude mcp add -s user openwriter -- openwriter --no-open
|
|
46
49
|
```
|
|
47
50
|
|
|
48
51
|
Then restart the Claude Code session. The MCP tools become available on next launch.
|
|
@@ -50,9 +53,9 @@ Then restart the Claude Code session. The MCP tools become available on next lau
|
|
|
50
53
|
**Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add to the `mcpServers` object:
|
|
51
54
|
|
|
52
55
|
```json
|
|
53
|
-
"
|
|
54
|
-
"command": "
|
|
55
|
-
"args": ["
|
|
56
|
+
"openwriter": {
|
|
57
|
+
"command": "openwriter",
|
|
58
|
+
"args": ["--no-open"]
|
|
56
59
|
}
|
|
57
60
|
```
|
|
58
61
|
|
|
@@ -61,9 +64,9 @@ The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exis
|
|
|
61
64
|
```json
|
|
62
65
|
{
|
|
63
66
|
"mcpServers": {
|
|
64
|
-
"
|
|
65
|
-
"command": "
|
|
66
|
-
"args": ["
|
|
67
|
+
"openwriter": {
|
|
68
|
+
"command": "openwriter",
|
|
69
|
+
"args": ["--no-open"]
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
}
|
|
@@ -254,4 +257,4 @@ When importing or organizing book-length projects, read the source material firs
|
|
|
254
257
|
|
|
255
258
|
**"pendingChanges" never clears** — User needs to accept/reject changes in the browser at http://localhost:5050.
|
|
256
259
|
|
|
257
|
-
**Server not starting** — Ensure `
|
|
260
|
+
**Server not starting** — Ensure `openwriter` works from your terminal (`npm install -g openwriter` first). If on Windows and the global command isn't found, the MCP config may need `"command": "cmd"` with `"args": ["/c", "openwriter", "--no-open"]`.
|