openwriter 0.26.0 → 0.27.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/dist/server/ws.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
6
- import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments } from './documents.js';
6
+ import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
8
  import { canonicalizeIdentifier } from './helpers.js';
9
9
  import { nodeTextPreview, diagLog } from './pending-overlay.js';
@@ -48,6 +48,29 @@ function debouncedBroadcastDocumentsChanged() {
48
48
  broadcastDocumentsChanged();
49
49
  }, 2100);
50
50
  }
51
+ /**
52
+ * Build a document-switched payload object that includes pendingMetadata
53
+ * (currently a staged title rename, if any) for the active doc. Every
54
+ * call site that emits a document-switched message — initial connect,
55
+ * request-document, tweet-thread HR resync, switchDocument result —
56
+ * must use this so the title-bar inline diff renders consistently
57
+ * regardless of which path delivered the doc state.
58
+ * adr: adr/pending-overlay-model.md
59
+ */
60
+ function buildDocumentSwitchedPayload(document, title, filename, metadata) {
61
+ const docId = getDocId();
62
+ const pendingTitle = docId ? getPendingTitle(docId) : null;
63
+ const pendingMetadata = pendingTitle ? { title: { from: pendingTitle.from, to: pendingTitle.to } } : null;
64
+ return {
65
+ type: 'document-switched',
66
+ document,
67
+ title,
68
+ filename,
69
+ docId,
70
+ metadata,
71
+ pendingMetadata,
72
+ };
73
+ }
51
74
  export function setupWebSocket(server) {
52
75
  const wss = new WebSocketServer({
53
76
  server,
@@ -89,14 +112,7 @@ export function setupWebSocket(server) {
89
112
  setAgentLockActive();
90
113
  const filePath = getFilePath();
91
114
  const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
92
- const msg = JSON.stringify({
93
- type: 'document-switched',
94
- document: getDocument(),
95
- title: getTitle(),
96
- filename,
97
- docId: getDocId(),
98
- metadata,
99
- });
115
+ const msg = JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, metadata));
100
116
  for (const ws of clients) {
101
117
  if (ws.readyState === WebSocket.OPEN)
102
118
  ws.send(msg);
@@ -210,14 +226,7 @@ export function setupWebSocket(server) {
210
226
  const docOnConnect = getDocument();
211
227
  const pendingOnConnect = pendingSummary(docOnConnect);
212
228
  diagLog(`[WS] document-switched SEND on-connect docId=${getDocId()} v=${getDocVersion()} pending=[${pendingOnConnect}]`);
213
- ws.send(JSON.stringify({
214
- type: 'document-switched',
215
- document: docOnConnect,
216
- title: getTitle(),
217
- filename,
218
- docId: getDocId(),
219
- metadata: getMetadata(),
220
- }));
229
+ ws.send(JSON.stringify(buildDocumentSwitchedPayload(docOnConnect, getTitle(), filename, getMetadata())));
221
230
  // Send pending docs info on connect
222
231
  ws.send(JSON.stringify({
223
232
  type: 'pending-docs-changed',
@@ -319,16 +328,21 @@ export function setupWebSocket(server) {
319
328
  if (msg.type === 'request-document') {
320
329
  const filePath = getFilePath();
321
330
  const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
322
- ws.send(JSON.stringify({
323
- type: 'document-switched',
324
- document: getDocument(),
325
- title: getTitle(),
326
- filename,
327
- docId: getDocId(),
328
- metadata: getMetadata(),
329
- }));
331
+ ws.send(JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, getMetadata())));
330
332
  }
331
333
  if (msg.type === 'title-update' && msg.title) {
334
+ // User typed directly in the title bar — this is "the user disposing."
335
+ // It always lands hot. If an agent had a pending title proposal, the
336
+ // user's direct edit supersedes it; clear the pending entry so the
337
+ // diff UI dismisses. adr: adr/pending-overlay-model.md
338
+ const activeDocId = getDocId();
339
+ if (activeDocId) {
340
+ const existing = getPendingTitle(activeDocId);
341
+ if (existing) {
342
+ rejectPendingTitle(activeDocId);
343
+ broadcastPendingMetadataChanged(activeDocId, null);
344
+ }
345
+ }
332
346
  setMetadata({ title: msg.title });
333
347
  const promoted = promoteTempFile(msg.title);
334
348
  if (promoted) {
@@ -341,6 +355,36 @@ export function setupWebSocket(server) {
341
355
  debouncedBroadcastDocumentsChanged();
342
356
  }
343
357
  }
358
+ if (msg.type === 'accept-pending-title' && msg.docId) {
359
+ try {
360
+ const result = acceptPendingTitle(msg.docId);
361
+ broadcastPendingMetadataChanged(msg.docId, null);
362
+ if (result) {
363
+ // updateDocumentTitle (inside acceptPendingTitle) re-runs
364
+ // setActiveDocument when the doc is active, which clears state
365
+ // and rebroadcasts. We also explicitly fire title-changed +
366
+ // documents-changed to update sidebar + title bar.
367
+ if (result.filename === getActiveFilename()) {
368
+ broadcastTitleChanged(result.to);
369
+ }
370
+ broadcastDocumentsChanged();
371
+ broadcastPendingDocsChanged();
372
+ }
373
+ }
374
+ catch (err) {
375
+ console.error('[WS] accept-pending-title failed:', err.message);
376
+ }
377
+ }
378
+ if (msg.type === 'reject-pending-title' && msg.docId) {
379
+ try {
380
+ rejectPendingTitle(msg.docId);
381
+ broadcastPendingMetadataChanged(msg.docId, null);
382
+ broadcastPendingDocsChanged();
383
+ }
384
+ catch (err) {
385
+ console.error('[WS] reject-pending-title failed:', err.message);
386
+ }
387
+ }
344
388
  if (msg.type === 'save') {
345
389
  save();
346
390
  }
@@ -491,7 +535,7 @@ export function setupWebSocket(server) {
491
535
  }
492
536
  export function broadcastDocumentSwitched(document, title, filename, metadata) {
493
537
  const resolvedMeta = metadata ?? getMetadata();
494
- const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: resolvedMeta });
538
+ const msg = JSON.stringify(buildDocumentSwitchedPayload(document, title, filename, resolvedMeta));
495
539
  for (const ws of clients) {
496
540
  if (ws.readyState === WebSocket.OPEN) {
497
541
  ws.send(msg);
@@ -527,6 +571,21 @@ export function broadcastTitleChanged(title) {
527
571
  ws.send(msg);
528
572
  }
529
573
  }
574
+ /**
575
+ * Broadcast a pending-metadata change for a specific docId. The client reads
576
+ * this and updates the title bar / sidebar to render the proposal-in-review
577
+ * decoration. Passing `pendingMetadata: null` signals "the proposal cleared"
578
+ * (either accepted, rejected, or matched canonical).
579
+ *
580
+ * adr: adr/pending-overlay-model.md
581
+ */
582
+ export function broadcastPendingMetadataChanged(docId, pendingMetadata) {
583
+ const msg = JSON.stringify({ type: 'pending-metadata-changed', docId, pendingMetadata });
584
+ for (const ws of clients) {
585
+ if (ws.readyState === WebSocket.OPEN)
586
+ ws.send(msg);
587
+ }
588
+ }
530
589
  // Debounced: coalesces rapid agent writes into a single broadcast.
531
590
  let pendingDocsTimer = null;
532
591
  const PENDING_DOCS_DEBOUNCE_MS = 500;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.26.0",
3
+ "version": "0.27.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",
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.16.0"
19
+ version: "0.16.3"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -73,6 +73,8 @@ You are a writing collaborator. You read documents and make edits **exclusively
73
73
  ```
74
74
 
75
75
  Use the doc title as the link label for doc-level links. Use the beat label or a short description of the block for node-level bullets — never just "node" or a raw ID. When citing multiple nodes from the same doc, group them under one **Node level** header. When citing nodes across multiple docs, use a separate block per doc. The cost is one `get_doc_link` call per cited doc; the payoff is the user goes from "where is that?" to "right there" in one click.
76
+
77
+ **The URL must come from `get_doc_link`** — it returns a real `http://...` URL. Never invent a URL scheme like `docId:abc123` or hand-construct a path; the link will be dead.
76
78
  8. **Orient by content first; pick by nodeId second.** Never call `peek_doc` or `get_nodes` with cold nodeIds. Node-targeting without prior content orientation is meaningless — IDs are byproducts of orientation, never the starting point. The two legitimate entry paths into a doc:
77
79
 
78
80
  - **Content entry** — `search_docs(query, { docId })` returns matching nodes with their IDs inside the doc. Use when you know roughly what you're looking for.
@@ -147,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
147
149
 
148
150
  | Tool | Key Params | Description |
149
151
  |------|-----------|-------------|
150
- | `read_pad` | | Read the current document (compact tagged-line format with `id:` in header) |
152
+ | `read_pad` | `docId`, `slice?` (`{from,to}` floats in [0,1]), `force?` (boolean) | Fixed-window word-position read. **Default:** first ~2,000 words; docs at or under the cap return in full. **`slice: {from, to}`** reads a percentile range (e.g. `{from: 0.5, to: 1}` = back half, `{from: 0.25, to: 0.75}` = middle 50%, sequential `{from: 0, to: 0.1}` → `{from: 0.1, to: 0.2}` … = 10% chunks at predictable per-call cost). Snaps to top-level node boundaries, subject to the cap unless `force` is set. **`force: true`** bypasses the cap entirely — returns the full requested region (whole doc, or whole slice). Use for full-doc audits and rewrites where you've accepted the cost. Truncated responses include `lastNodeId` + continuation hints for slice / force / peek_doc / outline_doc. |
151
153
  | `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
152
154
  | `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
153
155
  | `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
@@ -283,7 +285,10 @@ For making changes to existing documents — rewrites, insertions, deletions:
283
285
 
284
286
  - Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
285
287
  - Send **3-8 changes per call** for a responsive, streaming feel
286
- - Get fresh node IDs before editing. For **broad edits** spanning the doc, `read_pad` is the right call. For **surgical edits** where you already know the target area (from a prior `outline_doc`, `search_docs`, or deep-link click), `peek_doc` around the anchor returns just the nodes you need with current IDs — much cheaper on long docs.
288
+ - Get fresh node IDs before editing. Three patterns by edit scope:
289
+ - **Short doc broad edit** (≤ ~2,000 words): `read_pad({ docId })` returns the full body with all node IDs in one call.
290
+ - **Long doc broad edit**: either `read_pad({ docId, force: true })` for the whole body in one shot (cost acknowledged), or `read_pad({ docId, slice: {from, to} })` walking 10% chunks if the edit spans the whole doc but you want predictable per-call cost.
291
+ - **Surgical edit** (you already know the anchor from `outline_doc` / `search_docs` / deep-link click): `peek_doc({ around: anchor })` returns just the relevant region's current IDs — much cheaper than any read_pad form for targeted work.
287
292
  - Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
288
293
  - Content accepts markdown strings (preferred) or TipTap JSON
289
294
  - **`rewrite` preserves the target node's type.** Sending plain prose to rewrite a heading keeps it a heading; the same for list items and blockquotes. To intentionally change a node's type, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
@@ -396,7 +401,7 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
396
401
 
397
402
  ### Research (read-only, no edits coming)
398
403
 
399
- When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not `read_pad`.
404
+ When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not a default `read_pad`.
400
405
 
401
406
  ```
402
407
  1. search_docs({ query: "X" }) → ranked docs across workspace
@@ -406,29 +411,38 @@ When the user asks "find X in this doc", "what does Y argue", "show me the beat
406
411
  Use underHeading to drill into one section.
407
412
  3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
408
413
  OR pick a heading nodeId from step 2.
409
- 4. peek_doc({ docId, target: { around, before, after } })
410
- read the windowed slice
414
+ 4. Read the region pick by how wide:
415
+ - peek_doc({ docId, target: { around: nodeId, before, after } })
416
+ → node-anchored window (small, surgical)
417
+ - read_pad({ docId, slice: { from: 0.4, to: 0.6 } })
418
+ → percentile-anchored region (wider, contiguous)
419
+ - read_pad({ docId, force: true }) → whole body (you've accepted the cost)
411
420
  ```
412
421
 
413
- Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read_pad`. Use the ladder.
422
+ **Pattern:** `search_docs` returns hits with node IDs *and* approximate doc positions. When the user wants the matched paragraph + a sentence either side, `peek_doc` around the node is right. When they want "the section that hit lives in" or "the back half of the doc that contains the hit" or "a contiguous region wider than peek's 100-node window," `read_pad` with a slice is the better call.
423
+
424
+ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder (search + peek), ~3k tokens via search + read_pad slice for a 25% region, ~10k tokens for the full body via `read_pad force`. Match the read to the question.
414
425
 
415
426
  ### Single document (editing)
416
427
 
417
428
  ```
418
429
  1. get_pad_status → check pendingChanges and userSignaledReview
419
- 2. Orient on the doc:
420
- - Short doc (≤ ~2,000 words): read_pad returns the full body — node IDs included
421
- - Long doc (above the cap): outline_doc({ docId }) for shape, then
422
- peek_doc({ around: nodeId, before, after }) around the area you'll edit
423
- (only need fresh IDs for the region you're touching)
424
- - You already know the anchor (from a prior search_docs or deep-link click):
425
- skip straight to peek_doc({ around: anchor }) no full-body read needed
430
+ 2. Orient on the doc — pick by edit shape:
431
+ - Short doc (≤ ~2,000 words): read_pad({ docId }) returns the full body
432
+ - Long doc, surgical edit (you know roughly what you're touching):
433
+ search_docs({ query, docId }) → peek_doc({ around: hitNodeId, before, after })
434
+ fresh IDs for just the region you'll edit, ~500 tokens
435
+ - Long doc, broad edit on one section:
436
+ outline_doc({ docId }) → read_pad({ docId, slice: { from, to } })
437
+ where {from, to} bounds the section's percentile range
438
+ - Long doc, whole-body rewrite:
439
+ read_pad({ docId, force: true }) — explicit, cost acknowledged
426
440
  3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
427
441
  4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
428
442
  5. Wait → user accepts/rejects in browser
429
443
  ```
430
444
 
431
- `read_pad` always returns the doc opening up to ~2,000 words. For broader work on a long doc, walk the outline + peek pages — never assume you got the whole body from one read_pad call. The truncation response includes a `lastNodeId` and continuation hint pointing at exactly which tool to call next.
445
+ `read_pad` always returns the doc opening up to ~2,000 words unless you pass `slice` or `force`. Never assume you got the whole body from a default `read_pad` the truncation response tells you what's missing and gives you the exact slice/force/peek/outline calls to continue.
432
446
 
433
447
  **For tweet/article docs:** step 3 gives you the parent tweet URL (in `tweetContext.url`) and mode (`reply`/`quote`/`tweet`). Use this URL with fxtwitter to read the parent tweet for free — never search externally for it.
434
448
 
@@ -436,13 +450,29 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
436
450
 
437
451
  ```
438
452
  1. list_documents → see all docs with title + [docId] + wordCount
439
- 2. For each target doc, orient first:
440
- - Short doc: read_pad({ docId }) returns full body
441
- - Long doc: outline_doc({ docId }) → peek_doc({ docId, target: {...} })
453
+ 2. For each target doc, orient by wordCount:
454
+ - ~2,000 words: read_pad({ docId }) full body in one call
455
+ - Long doc, targeted: search_docs({ query, docId }) → peek_doc({ around: hit })
456
+ - Long doc, sectional: outline_doc({ docId }) → read_pad({ docId, slice })
457
+ - Long doc, full pass: read_pad({ docId, force: true })
442
458
  3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
443
459
  ```
444
460
 
445
- The wordCount on `list_documents` tells you up-front which docs will return in full from `read_pad` and which will truncate. Use it to plan: a 500-word doc is one round trip; an 8,000-word doc is outline + a peek or two.
461
+ The wordCount on `list_documents` tells you up-front which docs return in full from a default `read_pad` and which will truncate use it to plan the read shape per doc. A 500-word doc is one round trip; an 8,000-word doc is search + peek for surgical work, outline + slice for sectional work, or force for the rare whole-body case.
462
+
463
+ ### Reading patterns at a glance
464
+
465
+ | Intent | Best tool |
466
+ |--------|-----------|
467
+ | "What's in this doc?" | `outline_doc({ docId })` |
468
+ | "Find X in this doc" | `search_docs({ query, docId })` → `peek_doc({ around: hit })` |
469
+ | "Read around this node I already know" | `peek_doc({ around: anchor })` |
470
+ | "Read this specific region of the doc" | `read_pad({ docId, slice: { from, to } })` |
471
+ | "Walk the whole doc in predictable chunks" | `read_pad({ docId, slice })` × N sequential calls |
472
+ | "Give me everything" | `read_pad({ docId, force: true })` |
473
+ | "What's in this doc and what's it about" | `outline_doc` + frontmatter via `get_metadata` |
474
+ | "Which docs in the workspace talk about X" | `search_docs({ query })` (no docId) |
475
+ | "Scan the shelf — concept-level only" | `browse_docs({ workspaceFile })` |
446
476
 
447
477
  ### Creating new content (two-step)
448
478