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.
@@ -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 || []).filter((m) => SERIALIZED_MARKS.includes(m.type));
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) {
@@ -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
- const msg = JSON.stringify({ type: 'node-changes', changes });
50
- for (const ws of clients) {
51
- if (ws.readyState === WebSocket.OPEN) {
52
- ws.send(msg);
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
- // Agent write in progress ignore browser doc-updates
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.1",
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": "cp ../../skills/openwriter/SKILL.md skill/SKILL.md",
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.0"
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.