openwriter 0.6.11 → 0.8.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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Post History Sync — pulls scheduler_history from platform,
3
+ * updates local doc frontmatter with lastPost metadata.
4
+ *
5
+ * The platform is the canonical record for posted items.
6
+ * Local frontmatter is a cached view for sidebar display.
7
+ */
8
+ import { readFileSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import matter from 'gray-matter';
11
+ import { filenameByDocId } from './documents.js';
12
+ import { getDataDir } from './helpers.js';
13
+ import { isAuthenticated, platformFetch } from './connections.js';
14
+ import { isExternalDoc } from './helpers.js';
15
+ /**
16
+ * Sync posted history from platform → local doc frontmatter.
17
+ * For each history item with a doc_id:
18
+ * - Find the local doc by docId
19
+ * - Check if frontmatter already has a lastPost >= posted_at
20
+ * - If not, write the appropriate context metadata
21
+ */
22
+ export async function syncPostHistory() {
23
+ if (!isAuthenticated())
24
+ return { synced: 0, skipped: 0, errors: 0 };
25
+ let items;
26
+ try {
27
+ const res = await platformFetch('/scheduler/history');
28
+ if (!res.ok)
29
+ return { synced: 0, skipped: 0, errors: 0 };
30
+ const data = await res.json();
31
+ items = data.items || [];
32
+ }
33
+ catch {
34
+ return { synced: 0, skipped: 0, errors: 0 };
35
+ }
36
+ let synced = 0, skipped = 0, errors = 0;
37
+ for (const item of items) {
38
+ if (!item.doc_id || !item.result?.success || !item.posted_at) {
39
+ skipped++;
40
+ continue;
41
+ }
42
+ try {
43
+ const filename = filenameByDocId(item.doc_id);
44
+ if (!filename) {
45
+ skipped++;
46
+ continue;
47
+ }
48
+ const filePath = isExternalDoc(filename)
49
+ ? filename
50
+ : join(getDataDir(), filename);
51
+ const raw = readFileSync(filePath, 'utf-8');
52
+ const { data, content } = matter(raw);
53
+ // Determine the context key based on content_type/provider
54
+ const provider = item.provider || item.content_type;
55
+ let contextKey;
56
+ let lastPostField;
57
+ let urlField;
58
+ if (provider === 'x' || item.content_type === 'tweet' || item.content_type === 'thread') {
59
+ contextKey = 'tweetContext';
60
+ lastPostField = 'lastPost';
61
+ urlField = 'tweetUrl';
62
+ }
63
+ else if (provider === 'linkedin' || item.content_type === 'linkedin') {
64
+ contextKey = 'linkedinContext';
65
+ lastPostField = 'lastPost';
66
+ urlField = 'postUrl';
67
+ }
68
+ else if (item.content_type === 'blog' || provider === 'github') {
69
+ contextKey = 'blogContext';
70
+ lastPostField = 'lastPublish';
71
+ urlField = 'publishUrl';
72
+ }
73
+ else if (item.content_type === 'newsletter') {
74
+ // Newsletter uses its own sync path
75
+ skipped++;
76
+ continue;
77
+ }
78
+ else {
79
+ contextKey = 'tweetContext';
80
+ lastPostField = 'lastPost';
81
+ urlField = 'tweetUrl';
82
+ }
83
+ // Check if already synced (existing lastPost >= this posted_at)
84
+ const existingContext = data[contextKey] || {};
85
+ const existingPost = existingContext[lastPostField];
86
+ if (existingPost?.postedAt) {
87
+ const existingTime = new Date(existingPost.postedAt).getTime();
88
+ const newTime = new Date(item.posted_at).getTime();
89
+ if (existingTime >= newTime) {
90
+ skipped++;
91
+ continue;
92
+ }
93
+ }
94
+ // Write updated metadata
95
+ data[contextKey] = {
96
+ ...existingContext,
97
+ [lastPostField]: {
98
+ postedAt: item.posted_at,
99
+ ...(item.result.url ? { [urlField]: item.result.url } : {}),
100
+ },
101
+ };
102
+ const updated = matter.stringify(content, data);
103
+ writeFileSync(filePath, updated, 'utf-8');
104
+ synced++;
105
+ }
106
+ catch {
107
+ errors++;
108
+ }
109
+ }
110
+ if (synced > 0) {
111
+ console.log(`[PostSync] Synced ${synced} posted items to local docs`);
112
+ }
113
+ return { synced, skipped, errors };
114
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Router } from 'express';
5
5
  import { platformFetch, isAuthenticated } from './connections.js';
6
+ import { syncPostHistory } from './post-sync.js';
6
7
  export function createSchedulerRouter() {
7
8
  const router = Router();
8
9
  function proxy(path, method = 'GET') {
@@ -115,8 +116,40 @@ export function createSchedulerRouter() {
115
116
  });
116
117
  // History
117
118
  router.get('/api/scheduler/history', proxy('/scheduler/history'));
119
+ // Sync post history from platform → local doc frontmatter
120
+ router.post('/api/scheduler/sync', async (_req, res) => {
121
+ try {
122
+ const result = await syncPostHistory();
123
+ res.json(result);
124
+ }
125
+ catch (err) {
126
+ res.status(500).json({ error: err.message });
127
+ }
128
+ });
118
129
  // Available connections for scheduler
119
130
  router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
131
+ // Upload media via connection (proxies to platform)
132
+ router.post('/api/connections/:id/upload-media', async (req, res) => {
133
+ try {
134
+ if (!isAuthenticated()) {
135
+ res.json({ error: 'Not authenticated' });
136
+ return;
137
+ }
138
+ const upstream = await platformFetch(`/connections/${req.params.id}/upload-media`, {
139
+ method: 'POST',
140
+ body: JSON.stringify(req.body),
141
+ });
142
+ const data = await upstream.json();
143
+ if (!upstream.ok) {
144
+ res.status(upstream.status).json(data);
145
+ return;
146
+ }
147
+ res.json(data);
148
+ }
149
+ catch (err) {
150
+ res.status(500).json({ error: err.message });
151
+ }
152
+ });
120
153
  // --- Autoplugs ---
121
154
  // Goals
122
155
  router.get('/api/scheduler/autoplugs/goals', proxy('/scheduler/autoplugs/goals'));
@@ -92,7 +92,7 @@ export function getDocId() {
92
92
  export function getPlainText() {
93
93
  return extractText(state.document.content);
94
94
  }
95
- function extractText(nodes) {
95
+ export function extractText(nodes) {
96
96
  if (!nodes)
97
97
  return '';
98
98
  return nodes
@@ -170,21 +170,21 @@ function computePartialRange(origContent, newContent) {
170
170
  let origTo = origEnd >= firstDiff && origEnd < origWords.length ? origWords[origEnd].end : origFrom;
171
171
  let newFrom = firstDiff < newWords.length ? newWords[firstDiff].start : newText.length;
172
172
  let newTo = newEnd >= firstDiff && newEnd < newWords.length ? newWords[newEnd].end : newFrom;
173
- // Snap start back to previous sentence boundary (after ". ")
173
+ // Snap start back to previous sentence boundary (after ". " or ".\n")
174
174
  const snapBack = (text, pos) => {
175
175
  let i = pos - 1;
176
176
  while (i > 0) {
177
- if (text[i] === '.' && i + 1 < text.length && text[i + 1] === ' ')
177
+ if (text[i] === '.' && i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\n'))
178
178
  return i + 2;
179
179
  i--;
180
180
  }
181
181
  return 0; // No period found → start of text
182
182
  };
183
- // Snap end forward to next sentence boundary (the ". " or end of text)
183
+ // Snap end forward to next sentence boundary (". " or ".\n" or end of text)
184
184
  const snapForward = (text, pos) => {
185
185
  let i = pos;
186
186
  while (i < text.length) {
187
- if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' '))
187
+ if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' ' || text[i + 1] === '\n'))
188
188
  return i + 1;
189
189
  i++;
190
190
  }
@@ -249,31 +249,67 @@ export function getNodesByIds(ids) {
249
249
  }
250
250
  return result;
251
251
  }
252
+ /** Pure version of getNodesByIds — takes content array instead of reading state. */
253
+ export function findNodesByIds(docContent, ids) {
254
+ const result = [];
255
+ const idSet = new Set(ids);
256
+ function scan(nodes) {
257
+ if (!nodes)
258
+ return;
259
+ for (let i = 0; i < nodes.length; i++) {
260
+ const node = nodes[i];
261
+ if (node.attrs?.id && idSet.has(node.attrs.id)) {
262
+ result.push(node);
263
+ if (i + 1 < nodes.length && nodes[i + 1].type === 'horizontalRule') {
264
+ result.push(nodes[i + 1]);
265
+ }
266
+ }
267
+ if (node.content)
268
+ scan(node.content);
269
+ }
270
+ }
271
+ scan(docContent);
272
+ if (result.length > 0 && result[result.length - 1].type === 'horizontalRule') {
273
+ result.pop();
274
+ }
275
+ return result;
276
+ }
252
277
  export function getMetadata() {
253
278
  return state.metadata;
254
279
  }
255
- export function setMetadata(updates) {
256
- // Prevent blogContext contamination: only allow blogContext writes if
257
- // the incoming update has active:true OR the doc already has active blogContext
258
- if (updates.blogContext && !updates.blogContext.active && !state.metadata?.blogContext?.active) {
280
+ /**
281
+ * Apply contamination guards + deep-merge context keys. Pure function.
282
+ * Returns the merged metadata object, or null if all updates were filtered out.
283
+ */
284
+ export function mergeMetadataUpdates(existing, updates) {
285
+ // Clone so we don't mutate the caller's object
286
+ updates = { ...updates };
287
+ // Prevent blogContext contamination
288
+ if (updates.blogContext && !updates.blogContext.active && !existing?.blogContext?.active) {
259
289
  delete updates.blogContext;
260
290
  if (Object.keys(updates).length === 0)
261
- return;
291
+ return null;
262
292
  }
263
293
  // Same guard for newsletterContext
264
- if (updates.newsletterContext && !updates.newsletterContext.active && !state.metadata?.newsletterContext?.active) {
294
+ if (updates.newsletterContext && !updates.newsletterContext.active && !existing?.newsletterContext?.active) {
265
295
  delete updates.newsletterContext;
266
296
  if (Object.keys(updates).length === 0)
267
- return;
297
+ return null;
268
298
  }
269
- // Deep-merge known context objects so partial updates preserve essential fields (active, format, etc.)
299
+ // Deep-merge known context objects
270
300
  const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
271
301
  for (const key of CONTEXT_KEYS) {
272
- if (updates[key] && typeof updates[key] === 'object' && state.metadata?.[key] && typeof state.metadata[key] === 'object') {
273
- updates[key] = { ...state.metadata[key], ...updates[key] };
302
+ if (updates[key] && typeof updates[key] === 'object' && existing?.[key] && typeof existing[key] === 'object') {
303
+ updates[key] = { ...existing[key], ...updates[key] };
274
304
  }
275
305
  }
276
- state.metadata = { ...state.metadata, ...updates };
306
+ return { ...existing, ...updates };
307
+ }
308
+ export function setMetadata(updates) {
309
+ const merged = mergeMetadataUpdates(state.metadata, updates);
310
+ if (!merged)
311
+ return;
312
+ state.metadata = merged;
277
313
  if (updates.title)
278
314
  state.title = updates.title;
279
315
  // Auto-tag based on context metadata
@@ -599,6 +635,26 @@ function applyChangesToDoc(doc, changes) {
599
635
  const found = findNode(doc.content, change.nodeId, doc.content);
600
636
  if (!found)
601
637
  continue;
638
+ // Tweet thread: hard-delete paragraphs + adjacent HR immediately.
639
+ // Tweet compose view can't handle pending deletes near HRs — hard-delete and resync.
640
+ const delNode = found.parent[found.index];
641
+ if (delNode.type === 'paragraph' && state.metadata?.tweetContext) {
642
+ const idx = found.index;
643
+ if (idx > 0 && found.parent[idx - 1].type === 'horizontalRule') {
644
+ found.parent.splice(idx, 1);
645
+ found.parent.splice(idx - 1, 1);
646
+ }
647
+ else if (idx + 1 < found.parent.length && found.parent[idx + 1].type === 'horizontalRule') {
648
+ found.parent.splice(idx + 1, 1);
649
+ found.parent.splice(idx, 1);
650
+ }
651
+ else {
652
+ found.parent.splice(idx, 1);
653
+ }
654
+ // Push a synthetic HR change so ws.ts detects it and sends document-switched
655
+ processed.push({ operation: 'delete', nodeId: change.nodeId, content: [{ type: 'horizontalRule' }] });
656
+ continue;
657
+ }
602
658
  found.parent[found.index] = {
603
659
  ...found.parent[found.index],
604
660
  attrs: {
@@ -769,7 +825,7 @@ export function invalidateDocCache(filePath) {
769
825
  docCache.delete(filePath);
770
826
  }
771
827
  /** Update the cache entry for a file after writing changes (without cloning the active state). */
772
- function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
828
+ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
773
829
  let fileMtime = 0;
774
830
  try {
775
831
  fileMtime = statSync(filePath).mtimeMs;
@@ -1320,7 +1376,7 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1320
1376
  * Returns { title, wordCount, pendingCount } for the response message.
1321
1377
  */
1322
1378
  /** Count pending nodes in a document tree. */
1323
- function countPending(nodes) {
1379
+ export function countPending(nodes) {
1324
1380
  let count = 0;
1325
1381
  if (!nodes)
1326
1382
  return 0;
@@ -246,6 +246,41 @@ export function reorderContainer(wsFile, containerId, afterIdentifier) {
246
246
  writeWorkspace(wsFile, ws);
247
247
  return ws;
248
248
  }
249
+ export function moveContainer(wsFile, containerId, targetContainerId, afterIdentifier) {
250
+ const ws = getWorkspace(wsFile);
251
+ moveNode(ws.root, containerId, targetContainerId, afterIdentifier);
252
+ writeWorkspace(wsFile, ws);
253
+ return ws;
254
+ }
255
+ export function reorderWorkspaceAfter(filename, afterFilename) {
256
+ ensureWorkspacesDir();
257
+ const order = readOrder();
258
+ // Ensure all current workspace files are in the order array
259
+ const files = readdirSync(getWorkspacesDir()).filter(f => f.endsWith('.json') && f !== '_order.json');
260
+ for (const f of files) {
261
+ if (!order.includes(f))
262
+ order.push(f);
263
+ }
264
+ // Remove target
265
+ const idx = order.indexOf(filename);
266
+ if (idx === -1)
267
+ throw new Error(`Workspace "${filename}" not found in order`);
268
+ order.splice(idx, 1);
269
+ // Insert
270
+ if (afterFilename === null) {
271
+ order.unshift(filename);
272
+ }
273
+ else {
274
+ const afterIdx = order.indexOf(afterFilename);
275
+ if (afterIdx === -1) {
276
+ order.push(filename);
277
+ }
278
+ else {
279
+ order.splice(afterIdx + 1, 0, filename);
280
+ }
281
+ }
282
+ writeOrder(order);
283
+ }
249
284
  // ============================================================================
250
285
  // CONTEXT
251
286
  // ============================================================================
package/dist/server/ws.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
- import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
5
+ import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
6
6
  import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
8
  const clients = new Set();
@@ -61,6 +61,10 @@ export function setupWebSocket(server) {
61
61
  if (hasHrChange) {
62
62
  const doc = getDocument();
63
63
  console.log(`[WS] HR detected in tweet thread → sending document-switched (${doc?.content?.length || 0} nodes)`);
64
+ // Re-set agent lock so the 3s window starts NOW, not from the original insert.
65
+ // Tweet thread resyncs recreate all editors which fire onUpdate → stale doc-updates.
66
+ // Without this reset, the lock expires before the browser finishes recreating editors.
67
+ setAgentLock();
64
68
  const filePath = getFilePath();
65
69
  const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
66
70
  const msg = JSON.stringify({
@@ -128,7 +132,13 @@ export function setupWebSocket(server) {
128
132
  saveDocToFile(msg.filename, msg.document);
129
133
  }
130
134
  else {
131
- console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
135
+ // Strip ephemeral imageLoading nodes they're transient placeholders that should
136
+ // never persist. The browser's doc-update can re-add them after a failed rewrite.
137
+ if (msg.document.content) {
138
+ msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
139
+ }
140
+ const cleanedCount = msg.document.content?.length || 0;
141
+ console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
132
142
  updateDocument(msg.document);
133
143
  updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
134
144
  debouncedSave();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.6.11",
3
+ "version": "0.8.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",
@@ -59,6 +59,7 @@
59
59
  "@tiptap/starter-kit": "^3.0.0",
60
60
  "@turbodocx/html-to-docx": "^1.20.1",
61
61
  "@types/multer": "^2.0.0",
62
+ "@xdevplatform/xdk": "^0.4.0",
62
63
  "express": "^4.21.0",
63
64
  "gray-matter": "^4.0.3",
64
65
  "lowlight": "^3.3.0",
@@ -72,8 +73,8 @@
72
73
  "react": "^18.3.1",
73
74
  "react-dom": "^18.3.1",
74
75
  "trash": "^10.1.0",
76
+ "twitter-text": "^3.1.0",
75
77
  "ws": "^8.18.0",
76
- "@xdevplatform/xdk": "^0.4.0",
77
78
  "zod": "^3.25.76"
78
79
  },
79
80
  "devDependencies": {
@@ -81,6 +82,7 @@
81
82
  "@types/markdown-it": "^14.1.2",
82
83
  "@types/react": "^18.3.0",
83
84
  "@types/react-dom": "^18.3.0",
85
+ "@types/twitter-text": "^3.1.10",
84
86
  "@types/ws": "^8.5.0",
85
87
  "@vitejs/plugin-react": "^4.3.0",
86
88
  "typescript": "^5.6.0",
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.2.7"
19
+ version: "0.3.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -96,6 +96,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
96
96
  - Two documents can have the same title — the docId disambiguates
97
97
  - Filenames contain UUIDs unrelated to docIds — the first segment of a filename UUID looks like a docId but is not
98
98
 
99
+ **MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
100
+
99
101
  ## MCP Tools Reference (36 core + 21 publish platform)
100
102
 
101
103
  ### Document Operations
@@ -147,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
147
149
  | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
148
150
  | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
149
151
  | `untag_doc` | Remove a tag from a document by docId |
150
- | `move_doc` | Add a doc to a workspace, or move it within the workspace (by docId) |
152
+ | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
151
153
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
152
154
 
153
155
  ### Agent Marks
@@ -161,14 +163,13 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
161
163
 
162
164
  | Tool | Key Params | Description |
163
165
  |------|-----------|-------------|
164
- | `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks) |
166
+ | `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks). **`edits` must be a JSON array, not a string.** Example: `edits: [{ find: "old text", replace: "new text" }]` |
165
167
 
166
168
  ### Image Generation
167
169
 
168
170
  | Tool | Description |
169
171
  |------|-------------|
170
- | `generate_image` | Generate an image via Gemini Nano Banana 2 optionally set as article cover (requires GEMINI_API_KEY) |
171
- | `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
172
+ | `insert_image` | Generate image via Gemini. Three modes: (1) `docId` + `afterNodeId` → inline insert with pending decoration. (2) `set_cover: true` set as article cover. (3) Neither → generate to disk only. Requires GEMINI_API_KEY. |
172
173
 
173
174
  ### Version Management
174
175
 
@@ -228,7 +229,7 @@ create_document({
228
229
  - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
229
230
  - Both are optional — omit for standalone docs outside any workspace.
230
231
 
231
- This eliminates the need for separate `create_workspace`, `create_container`, and `move_doc` calls when building up a workspace.
232
+ This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
232
233
 
233
234
  ## Workflow
234
235
 
@@ -395,6 +396,17 @@ Threads are single documents with `horizontalRule` nodes separating each tweet.
395
396
  }})
396
397
  ```
397
398
 
399
+ ### Inserting New Tweets into Existing Threads
400
+
401
+ **Insert HR + paragraph as ONE change with a content array.** Two separate calls will fail — the browser resyncs on HR insertion and overwrites the second call.
402
+
403
+ ```
404
+ write_to_pad({ docId: "...", changes: [
405
+ { operation: "insert", afterNodeId: "<last-node-of-previous-tweet>",
406
+ content: [{ type: "horizontalRule" }, { type: "paragraph", content: [{ type: "text", text: "New tweet" }] }] }
407
+ ]})
408
+ ```
409
+
398
410
  ### Paragraph Spacing in Tweets
399
411
 
400
412
  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.
@@ -440,6 +452,16 @@ After creating a thread, use `read_pad` to get node IDs, then `insert_image` to
440
452
 
441
453
  All `insert_image` calls can run **in parallel** — no dependencies between them. Images appear with green pending decorations for user review.
442
454
 
455
+ ### Inserting Existing Images (from disk)
456
+
457
+ Copy to `~/.openwriter/profiles/Default/_images/`, then use TipTap JSON in `write_to_pad`:
458
+
459
+ ```
460
+ content: { "type": "image", "attrs": { "src": "/_images/my-image.png", "alt": "..." } }
461
+ ```
462
+
463
+ **Markdown `![alt](path)` does NOT work** — creates an empty paragraph. Always use TipTap JSON.
464
+
443
465
  ## Review Etiquette
444
466
 
445
467
  1. **Share the URL.** Always tell the user: http://localhost:5050