openwriter 0.6.1 → 0.6.3
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-cxT2LD1Q.js +209 -0
- package/dist/client/assets/index-rHyhyRQQ.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/blog-routes.js +160 -0
- package/dist/server/connection-routes.js +46 -0
- package/dist/server/documents.js +4 -2
- package/dist/server/index.js +3 -0
- package/dist/server/install-skill.js +10 -0
- package/dist/server/markdown-serialize.js +3 -1
- package/dist/server/state.js +32 -1
- package/dist/server/ws.js +40 -5
- package/package.json +2 -2
- package/skill/SKILL.md +77 -1
- package/skill/docs/welcome.md +21 -0
- package/dist/client/assets/index-CCMCrgTu.js +0 -209
- package/dist/client/assets/index-DN1_4Au6.css +0 -1
|
@@ -112,6 +112,16 @@ export function installSkill() {
|
|
|
112
112
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
113
113
|
fs.copyFileSync(source, target);
|
|
114
114
|
log(` ✓ Skill installed to ${target}`);
|
|
115
|
+
// Copy docs/ directory (welcome doc, etc.)
|
|
116
|
+
const docsSource = path.join(__dirname, '../../skill/docs');
|
|
117
|
+
const docsTarget = path.join(targetDir, 'docs');
|
|
118
|
+
if (fs.existsSync(docsSource)) {
|
|
119
|
+
fs.mkdirSync(docsTarget, { recursive: true });
|
|
120
|
+
for (const file of fs.readdirSync(docsSource)) {
|
|
121
|
+
fs.copyFileSync(path.join(docsSource, file), path.join(docsTarget, file));
|
|
122
|
+
}
|
|
123
|
+
log(` ✓ Skill docs copied to ${docsTarget}`);
|
|
124
|
+
}
|
|
115
125
|
// Step 2: Global install (skip if already installed)
|
|
116
126
|
const alreadyInstalled = isGloballyInstalled();
|
|
117
127
|
if (alreadyInstalled) {
|
|
@@ -215,7 +215,9 @@ export function inlineToMarkdown(nodes) {
|
|
|
215
215
|
}
|
|
216
216
|
if (node.type !== 'text')
|
|
217
217
|
continue;
|
|
218
|
-
const targetMarks = (node.marks || [])
|
|
218
|
+
const targetMarks = (node.marks || [])
|
|
219
|
+
.filter((m) => SERIALIZED_MARKS.includes(m.type))
|
|
220
|
+
.sort((a, b) => SERIALIZED_MARKS.indexOf(a.type) - SERIALIZED_MARKS.indexOf(b.type));
|
|
219
221
|
// Find common prefix of marks between open and target
|
|
220
222
|
let commonLen = 0;
|
|
221
223
|
while (commonLen < openMarks.length && commonLen < targetMarks.length) {
|
package/dist/server/state.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
|
|
4
4
|
* Title lives in frontmatter metadata. Filenames are stable identifiers.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
@@ -984,6 +984,23 @@ function cleanupEmptyTempFiles() {
|
|
|
984
984
|
catch { /* ignore errors during cleanup */ }
|
|
985
985
|
}
|
|
986
986
|
// ============================================================================
|
|
987
|
+
// MTIME HELPERS (preserve file modification time for metadata-only writes)
|
|
988
|
+
// ============================================================================
|
|
989
|
+
function safeGetMtime(filePath) {
|
|
990
|
+
try {
|
|
991
|
+
return statSync(filePath).mtime;
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function safeRestoreMtime(filePath, mtime) {
|
|
998
|
+
try {
|
|
999
|
+
utimesSync(filePath, new Date(), mtime);
|
|
1000
|
+
}
|
|
1001
|
+
catch { /* best-effort */ }
|
|
1002
|
+
}
|
|
1003
|
+
// ============================================================================
|
|
987
1004
|
// DOCUMENT-LEVEL TAG OPERATIONS
|
|
988
1005
|
// ============================================================================
|
|
989
1006
|
/** Get tags for the active document from its metadata. */
|
|
@@ -1023,7 +1040,11 @@ export function addDocTag(filename, tag) {
|
|
|
1023
1040
|
if (!tags.includes(tag)) {
|
|
1024
1041
|
tags.push(tag);
|
|
1025
1042
|
state.metadata.tags = tags;
|
|
1043
|
+
// Preserve mtime — tag changes shouldn't affect sidebar sort order
|
|
1044
|
+
const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
|
|
1026
1045
|
save();
|
|
1046
|
+
if (mtime && state.filePath)
|
|
1047
|
+
safeRestoreMtime(state.filePath, mtime);
|
|
1027
1048
|
}
|
|
1028
1049
|
}
|
|
1029
1050
|
else {
|
|
@@ -1038,8 +1059,11 @@ export function addDocTag(filename, tag) {
|
|
|
1038
1059
|
if (!tags.includes(tag)) {
|
|
1039
1060
|
tags.push(tag);
|
|
1040
1061
|
parsed.metadata.tags = tags;
|
|
1062
|
+
const mtime = safeGetMtime(targetPath);
|
|
1041
1063
|
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1042
1064
|
atomicWriteFileSync(targetPath, markdown);
|
|
1065
|
+
if (mtime)
|
|
1066
|
+
safeRestoreMtime(targetPath, mtime);
|
|
1043
1067
|
}
|
|
1044
1068
|
}
|
|
1045
1069
|
catch { /* best-effort */ }
|
|
@@ -1056,7 +1080,11 @@ export function removeDocTag(filename, tag) {
|
|
|
1056
1080
|
if (idx >= 0) {
|
|
1057
1081
|
tags.splice(idx, 1);
|
|
1058
1082
|
state.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
1083
|
+
// Preserve mtime — tag changes shouldn't affect sidebar sort order
|
|
1084
|
+
const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
|
|
1059
1085
|
save();
|
|
1086
|
+
if (mtime && state.filePath)
|
|
1087
|
+
safeRestoreMtime(state.filePath, mtime);
|
|
1060
1088
|
}
|
|
1061
1089
|
}
|
|
1062
1090
|
else {
|
|
@@ -1071,8 +1099,11 @@ export function removeDocTag(filename, tag) {
|
|
|
1071
1099
|
if (idx >= 0) {
|
|
1072
1100
|
tags.splice(idx, 1);
|
|
1073
1101
|
parsed.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
1102
|
+
const mtime = safeGetMtime(targetPath);
|
|
1074
1103
|
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1075
1104
|
atomicWriteFileSync(targetPath, markdown);
|
|
1105
|
+
if (mtime)
|
|
1106
|
+
safeRestoreMtime(targetPath, mtime);
|
|
1076
1107
|
}
|
|
1077
1108
|
}
|
|
1078
1109
|
catch { /* best-effort */ }
|
package/dist/server/ws.js
CHANGED
|
@@ -46,10 +46,41 @@ export function setupWebSocket(server) {
|
|
|
46
46
|
});
|
|
47
47
|
// Push agent changes to all browser clients
|
|
48
48
|
onChanges((changes) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
// Check if changes include HR nodes in a tweet thread document.
|
|
50
|
+
// Tweet editors don't support horizontalRule in their schema, so individual
|
|
51
|
+
// node-changes with HRs silently fail. Send a full document resync instead,
|
|
52
|
+
// which triggers splitContentAtHr in TweetComposeView to create new editors.
|
|
53
|
+
const metadata = getMetadata();
|
|
54
|
+
const isTweetThread = metadata?.tweetContext != null;
|
|
55
|
+
const hasHrChange = isTweetThread && changes.some((c) => {
|
|
56
|
+
if (!c.content)
|
|
57
|
+
return false;
|
|
58
|
+
const contentArr = Array.isArray(c.content) ? c.content : [c.content];
|
|
59
|
+
return contentArr.some((n) => n.type === 'horizontalRule');
|
|
60
|
+
});
|
|
61
|
+
if (hasHrChange) {
|
|
62
|
+
const doc = getDocument();
|
|
63
|
+
console.log(`[WS] HR detected in tweet thread → sending document-switched (${doc?.content?.length || 0} nodes)`);
|
|
64
|
+
const filePath = getFilePath();
|
|
65
|
+
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
66
|
+
const msg = JSON.stringify({
|
|
67
|
+
type: 'document-switched',
|
|
68
|
+
document: getDocument(),
|
|
69
|
+
title: getTitle(),
|
|
70
|
+
filename,
|
|
71
|
+
docId: getDocId(),
|
|
72
|
+
metadata,
|
|
73
|
+
});
|
|
74
|
+
for (const ws of clients) {
|
|
75
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
76
|
+
ws.send(msg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const msg = JSON.stringify({ type: 'node-changes', changes });
|
|
81
|
+
for (const ws of clients) {
|
|
82
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
83
|
+
ws.send(msg);
|
|
53
84
|
}
|
|
54
85
|
}
|
|
55
86
|
// Notify browser of updated pending docs list (debounced)
|
|
@@ -85,8 +116,11 @@ export function setupWebSocket(server) {
|
|
|
85
116
|
try {
|
|
86
117
|
const msg = JSON.parse(data.toString());
|
|
87
118
|
if (msg.type === 'doc-update' && msg.document) {
|
|
119
|
+
const docContent = msg.document?.content || [];
|
|
120
|
+
const nodeCount = docContent.length;
|
|
121
|
+
const currentNodeCount = getDocument()?.content?.length || 0;
|
|
88
122
|
if (isAgentLocked()) {
|
|
89
|
-
|
|
123
|
+
console.log(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
|
|
90
124
|
}
|
|
91
125
|
else if (msg.filename && msg.filename !== getActiveFilename()) {
|
|
92
126
|
// Browser sent a doc-update for a different document (race: server switched away).
|
|
@@ -94,6 +128,7 @@ export function setupWebSocket(server) {
|
|
|
94
128
|
saveDocToFile(msg.filename, msg.document);
|
|
95
129
|
}
|
|
96
130
|
else {
|
|
131
|
+
console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
|
|
97
132
|
updateDocument(msg.document);
|
|
98
133
|
updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
|
|
99
134
|
debouncedSave();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "vite build && tsc -p tsconfig.server.json",
|
|
35
|
-
"prepublishOnly": "
|
|
35
|
+
"prepublishOnly": "node -e \"const fs=require('fs'),p=require('path');fs.copyFileSync(p.resolve('../../skills/openwriter/SKILL.md'),'skill/SKILL.md');fs.mkdirSync('skill/docs',{recursive:true});fs.copyFileSync(p.resolve('../../skills/openwriter/docs/welcome.md'),'skill/docs/welcome.md')\"",
|
|
36
36
|
"preview": "node dist/bin/pad.js",
|
|
37
37
|
"lint": "eslint src server bin --ext .ts,.tsx"
|
|
38
38
|
},
|
package/skill/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ description: |
|
|
|
15
15
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
16
16
|
metadata:
|
|
17
17
|
author: travsteward
|
|
18
|
-
version: "0.2.
|
|
18
|
+
version: "0.2.3"
|
|
19
19
|
repository: https://github.com/travsteward/openwriter
|
|
20
20
|
license: MIT
|
|
21
21
|
---
|
|
@@ -41,6 +41,15 @@ The user already has OpenWriter configured. You're good to go.
|
|
|
41
41
|
**First action:** Share the browser URL:
|
|
42
42
|
> OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
|
|
43
43
|
|
|
44
|
+
**Onboarding (first use only):** Call `list_documents`. If the workspace is empty (zero documents), create a welcome doc to orient the user:
|
|
45
|
+
|
|
46
|
+
1. Read the welcome template from this skill's `docs/welcome.md`
|
|
47
|
+
2. `create_document` with title "Welcome to OpenWriter"
|
|
48
|
+
3. `populate_document` with the template content (arrives as pending changes — green highlights)
|
|
49
|
+
4. Tell the user: "I've created a welcome doc in your browser. Check it out — the green highlights are my changes. Use the review panel to accept or reject them."
|
|
50
|
+
|
|
51
|
+
This teaches the user the core workflow (pending changes, review panel) by experiencing it. After the first run, docs exist and this step is skipped forever.
|
|
52
|
+
|
|
44
53
|
Skip to [Writing Strategy](#writing-strategy) below.
|
|
45
54
|
|
|
46
55
|
### MCP tools are NOT available (needs setup)
|
|
@@ -182,6 +191,7 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
182
191
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
183
192
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
184
193
|
- Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
|
|
194
|
+
- **Never re-populate a document to fix it.** `populate_document` re-sends the entire document body — extremely token-expensive. To remove nodes, use `write_to_pad` with `{ operation: "delete", nodeId: "..." }`. To fix content, use `rewrite`. Only use `populate_document` once during initial creation, or as a last resort if the document is severely broken.
|
|
185
195
|
|
|
186
196
|
### Creating New Documents (two-step flow)
|
|
187
197
|
|
|
@@ -362,6 +372,72 @@ This restores the normal editor view and removes the "x" tag.
|
|
|
362
372
|
|
|
363
373
|
Users set their X handle by clicking the avatar circle in the compose area. The handle is saved to localStorage and the pfp loads from `unavatar.io/twitter/{handle}`.
|
|
364
374
|
|
|
375
|
+
### Creating Tweet Threads
|
|
376
|
+
|
|
377
|
+
Threads are single documents with `horizontalRule` nodes separating each tweet. The compose view splits at HRs into separate tweet editors.
|
|
378
|
+
|
|
379
|
+
**Critical: threads MUST use TipTap JSON, not markdown.** Markdown `---` does NOT create proper `horizontalRule` nodes — the thread will render as a single tweet.
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
1. create_document({ title: "Thread title" })
|
|
383
|
+
2. set_metadata({ tweetContext: { mode: "tweet" } })
|
|
384
|
+
3. populate_document({ tpiTapJson: {
|
|
385
|
+
type: "doc",
|
|
386
|
+
content: [
|
|
387
|
+
{ type: "paragraph", content: [{ type: "text", text: "Tweet 1 text" }] },
|
|
388
|
+
{ type: "horizontalRule" },
|
|
389
|
+
{ type: "paragraph", content: [{ type: "text", text: "Tweet 2 text" }] },
|
|
390
|
+
{ type: "horizontalRule" },
|
|
391
|
+
{ type: "paragraph", content: [{ type: "text", text: "Tweet 3 text" }] }
|
|
392
|
+
]
|
|
393
|
+
}})
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Paragraph Spacing in Tweets
|
|
397
|
+
|
|
398
|
+
Tweet compose uses `<br>` (hardBreak) for line breaks within a paragraph. Double Enter in the browser creates a new `<p>` node (paragraph split) with visual spacing.
|
|
399
|
+
|
|
400
|
+
**For agents writing via `write_to_pad`:** use separate paragraph nodes for paragraph spacing. Each paragraph gets its own node ID, enabling independent editing.
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
// Correct: separate paragraph nodes for paragraph spacing
|
|
404
|
+
write_to_pad({ docId: "...", changes: [
|
|
405
|
+
{ operation: "insert", afterNodeId: "end", content: "First paragraph of tweet." },
|
|
406
|
+
{ operation: "insert", afterNodeId: "end", content: "Second paragraph — separate node, visual gap." }
|
|
407
|
+
]})
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
For line breaks WITHIN a single paragraph (no gap), use TipTap JSON with hardBreak:
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
{
|
|
414
|
+
type: "paragraph",
|
|
415
|
+
content: [
|
|
416
|
+
{ type: "text", text: "Line one" },
|
|
417
|
+
{ type: "hardBreak" },
|
|
418
|
+
{ type: "text", text: "Line two (same node, no gap)" }
|
|
419
|
+
]
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
This applies to all tweet modes — single tweets, replies, quotes, and individual tweets within threads.
|
|
424
|
+
|
|
425
|
+
### Inserting Images into Thread Tweets
|
|
426
|
+
|
|
427
|
+
After creating a thread, use `read_pad` to get node IDs, then `insert_image` to add images after specific tweets:
|
|
428
|
+
|
|
429
|
+
```
|
|
430
|
+
1. read_pad() → shows [p:abc123] for each tweet paragraph
|
|
431
|
+
2. insert_image({
|
|
432
|
+
docId: "...",
|
|
433
|
+
afterNodeId: "abc123", ← paragraph node ID from read_pad
|
|
434
|
+
prompt: "...",
|
|
435
|
+
aspect_ratio: "16:9"
|
|
436
|
+
})
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
All `insert_image` calls can run **in parallel** — no dependencies between them. Images appear with green pending decorations for user review.
|
|
440
|
+
|
|
365
441
|
## Review Etiquette
|
|
366
442
|
|
|
367
443
|
1. **Share the URL.** Always tell the user: http://localhost:5050
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Welcome to OpenWriter
|
|
2
|
+
|
|
3
|
+
What you're looking at right now is how we collaborate. These green highlights are my proposed changes — I write, you review. Use the review panel on the right to navigate (j/k), accept (a), or reject (r) each change. Try it now.
|
|
4
|
+
|
|
5
|
+
## Create Documents
|
|
6
|
+
|
|
7
|
+
Hit the **+** button in the sidebar to create different document types: blog posts, newsletters, tweets, LinkedIn posts, and articles. Each type has its own compose view tailored to that format. Or just ask me to write something and I'll create the right doc type for you.
|
|
8
|
+
|
|
9
|
+
## Workspaces
|
|
10
|
+
|
|
11
|
+
Organize your documents into workspaces with containers and tags. This is especially powerful for book writing — each chapter is a document, grouped into sections as containers. I can see your full workspace structure and work across multiple docs.
|
|
12
|
+
|
|
13
|
+
## Plugins
|
|
14
|
+
|
|
15
|
+
**Author's Voice** — Feed me your writing samples and I'll write in your voice. Not an approximation — I pull your actual patterns, cadence, and word choices from your corpus.
|
|
16
|
+
|
|
17
|
+
**Publish** — Send newsletters to your subscriber list, post to X/Twitter, publish blog posts to GitHub, and schedule content. All from inside the editor, no copy-pasting to other platforms.
|
|
18
|
+
|
|
19
|
+
## What's Next
|
|
20
|
+
|
|
21
|
+
Ask me to write something. A blog post, a tweet thread, a newsletter — whatever you're working on. I'll create a doc for it and we'll iterate together.
|