openwriter 0.7.0 → 0.8.1

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.
@@ -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;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Express routes for task CRUD.
3
+ * Mounted in index.ts to keep the main file lean.
4
+ */
5
+ import { Router } from 'express';
6
+ import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
7
+ export function createTaskRouter() {
8
+ const router = Router();
9
+ router.get('/api/tasks', (_req, res) => {
10
+ res.json({ tasks: readTasks() });
11
+ });
12
+ router.post('/api/tasks', (req, res) => {
13
+ const { text } = req.body;
14
+ if (!text?.trim()) {
15
+ res.status(400).json({ error: 'text is required' });
16
+ return;
17
+ }
18
+ res.json({ task: addTask(text.trim()) });
19
+ });
20
+ router.put('/api/tasks/:id', (req, res) => {
21
+ const { text, completed } = req.body;
22
+ const task = updateTask(req.params.id, { text, completed });
23
+ if (!task) {
24
+ res.status(404).json({ error: 'Task not found' });
25
+ return;
26
+ }
27
+ res.json({ task });
28
+ });
29
+ router.delete('/api/tasks/:id', (req, res) => {
30
+ const ok = removeTask(req.params.id);
31
+ if (!ok) {
32
+ res.status(404).json({ error: 'Task not found' });
33
+ return;
34
+ }
35
+ res.json({ success: true });
36
+ });
37
+ return router;
38
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-profile task persistence. Stores tasks.json in the profile data dir.
3
+ */
4
+ import { join } from 'path';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { getDataDir, atomicWriteFileSync, generateNodeId } from './helpers.js';
7
+ function tasksPath() {
8
+ return join(getDataDir(), 'tasks.json');
9
+ }
10
+ export function readTasks() {
11
+ const p = tasksPath();
12
+ if (!existsSync(p))
13
+ return [];
14
+ try {
15
+ return JSON.parse(readFileSync(p, 'utf-8'));
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ }
21
+ export function writeTasks(tasks) {
22
+ atomicWriteFileSync(tasksPath(), JSON.stringify(tasks, null, 2));
23
+ }
24
+ export function addTask(text) {
25
+ const tasks = readTasks();
26
+ const maxPos = tasks.reduce((m, t) => Math.max(m, t.position), -1);
27
+ const task = { id: generateNodeId(), text, completed: false, position: maxPos + 1 };
28
+ tasks.push(task);
29
+ writeTasks(tasks);
30
+ return task;
31
+ }
32
+ export function updateTask(id, patch) {
33
+ const tasks = readTasks();
34
+ const task = tasks.find((t) => t.id === id);
35
+ if (!task)
36
+ return null;
37
+ if (patch.text !== undefined)
38
+ task.text = patch.text;
39
+ if (patch.completed !== undefined)
40
+ task.completed = patch.completed;
41
+ writeTasks(tasks);
42
+ return task;
43
+ }
44
+ export function removeTask(id) {
45
+ const tasks = readTasks();
46
+ const idx = tasks.findIndex((t) => t.id === id);
47
+ if (idx === -1)
48
+ return false;
49
+ tasks.splice(idx, 1);
50
+ writeTasks(tasks);
51
+ return true;
52
+ }
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.7.0",
3
+ "version": "0.8.1",
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
@@ -3,7 +3,7 @@ name: openwriter
3
3
  description: |
4
4
  OpenWriter — the writing surface for AI agents. A markdown-native rich text
5
5
  editor where agents write via MCP tools and users accept or reject changes
6
- in-browser. 36 core MCP tools for document editing, multi-doc workspaces,
6
+ in-browser. 40 core MCP tools for document editing, multi-doc workspaces,
7
7
  and organization, plus 21 publish platform tools for newsletter, social
8
8
  posting, and scheduling. Tweet compose mode for drafting replies/QTs with
9
9
  pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
@@ -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.8"
19
+ version: "0.4.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -98,7 +98,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
98
98
 
99
99
  **MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
100
100
 
101
- ## MCP Tools Reference (36 core + 21 publish platform)
101
+ ## MCP Tools Reference (40 core + 21 publish platform)
102
102
 
103
103
  ### Document Operations
104
104
 
@@ -159,18 +159,28 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
159
159
  | `get_agent_marks` | `docId?` | Get inline feedback marks left by the user (optional docId — omit for all docs) |
160
160
  | `resolve_agent_marks` | `mark_ids` | Remove marks after addressing feedback (pass mark IDs) |
161
161
 
162
+ ### Task Management
163
+
164
+ | Tool | Key Params | Description |
165
+ |------|-----------|-------------|
166
+ | `list_tasks` | — | List all tasks for the current profile |
167
+ | `add_task` | `text` | Add a new task to the checklist |
168
+ | `update_task` | `id`, `text?`, `completed?` | Update a task (text or completion status) |
169
+ | `remove_task` | `id` | Remove a task from the checklist |
170
+
171
+ Call `list_tasks` at session start to check for pending work from previous sessions.
172
+
162
173
  ### Text Operations
163
174
 
164
175
  | Tool | Key Params | Description |
165
176
  |------|-----------|-------------|
166
- | `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks) |
177
+ | `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" }]` |
167
178
 
168
179
  ### Image Generation
169
180
 
170
181
  | Tool | Description |
171
182
  |------|-------------|
172
- | `generate_image` | Generate an image via Gemini Nano Banana 2 optionally set as article cover (requires GEMINI_API_KEY) |
173
- | `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
183
+ | `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. |
174
184
 
175
185
  ### Version Management
176
186
 
@@ -397,6 +407,17 @@ Threads are single documents with `horizontalRule` nodes separating each tweet.
397
407
  }})
398
408
  ```
399
409
 
410
+ ### Inserting New Tweets into Existing Threads
411
+
412
+ **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.
413
+
414
+ ```
415
+ write_to_pad({ docId: "...", changes: [
416
+ { operation: "insert", afterNodeId: "<last-node-of-previous-tweet>",
417
+ content: [{ type: "horizontalRule" }, { type: "paragraph", content: [{ type: "text", text: "New tweet" }] }] }
418
+ ]})
419
+ ```
420
+
400
421
  ### Paragraph Spacing in Tweets
401
422
 
402
423
  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.