openwriter 0.25.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.
@@ -16,7 +16,8 @@ import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
17
  import { anyLegacyRaw } from './node-fingerprint.js';
18
18
  import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
19
- import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
19
+ import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, loadDocFromDisk, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
20
+ import { loadPendingMetadata, savePendingMetadata } from './pending-metadata.js';
20
21
  import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
21
22
  import { clearActivityBuffer } from './activity-log.js';
22
23
  import { titleFromDoc, shouldAutoTitle } from './title-from-body.js';
@@ -84,6 +85,7 @@ const DEFAULT_DOC = {
84
85
  let state = {
85
86
  canonical: DEFAULT_DOC,
86
87
  overlay: new Map(),
88
+ pendingMetadata: null,
87
89
  document: DEFAULT_DOC,
88
90
  title: 'Untitled',
89
91
  metadata: { title: 'Untitled' },
@@ -543,6 +545,21 @@ export function getOverlay() {
543
545
  export function getTitle() {
544
546
  return state.title;
545
547
  }
548
+ /** Snapshot of the active doc's pending metadata (title, etc.). Null if no
549
+ * metadata is staged. The sidebar / title bar / ReviewTab consult this to
550
+ * decide whether to render a metadata-pending decoration.
551
+ * adr: adr/pending-overlay-model.md */
552
+ export function getPendingMetadata() {
553
+ return state.pendingMetadata ? { ...state.pendingMetadata } : null;
554
+ }
555
+ /** Replace the active doc's pending metadata. Persists to the sidecar so the
556
+ * proposal survives a doc switch or restart. Pass null to clear. */
557
+ export function setPendingMetadata(meta) {
558
+ state.pendingMetadata = meta && Object.keys(meta).length > 0 ? meta : null;
559
+ if (state.docId) {
560
+ savePendingMetadata(state.docId, state.pendingMetadata);
561
+ }
562
+ }
546
563
  export function getFilePath() {
547
564
  return state.filePath;
548
565
  }
@@ -775,6 +792,13 @@ export function setMetadata(updates) {
775
792
  state.metadata = merged;
776
793
  if (updates.title)
777
794
  state.title = updates.title;
795
+ // Same contract as updateDocument: any path that mutates persistent state
796
+ // MUST bump docVersion, otherwise writeToDisk's no-op gate
797
+ // (docVersion === lastSavedDocVersion) silently drops the write and the
798
+ // mutation never reaches disk. Metadata-only changes (mark-sent tweetUrl,
799
+ // schedule edits, autoAccept toggle) all flow through here.
800
+ // adr: adr/pending-overlay-model.md
801
+ bumpDocVersion();
778
802
  // Auto-tag based on context metadata
779
803
  const filename = state.filePath
780
804
  ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
@@ -1026,11 +1050,19 @@ export function cancelDebouncedSave() {
1026
1050
  }
1027
1051
  }
1028
1052
  export function applyChanges(changes) {
1029
- // Apply to server-side document (source of truth)
1030
- const processed = applyChangesToDocument(changes);
1031
- // Bump version + lock browser doc-updates to prevent stale state overwrite
1053
+ // Bump version BEFORE applying so new overlay entries created by
1054
+ // applyChangesToDocument's setPrimaryFromMerged → setOverlayFromEntries
1055
+ // pass are stamped with the post-bump version (the version we're about
1056
+ // to broadcast), not the pre-bump version. Without this, a stale
1057
+ // browser doc-update arriving with browserVersion == preBump satisfies
1058
+ // syncBrowserDocUpdate's `addedAtVersion > browserVersion` filter as
1059
+ // FALSE for entries just added by this call — preservedServerEntries
1060
+ // becomes 0, the overlay is wiped, and the agent's write silently
1061
+ // vanishes despite a success response. adr: adr/pending-overlay-model.md
1032
1062
  const version = bumpDocVersion();
1033
1063
  setAgentLockActive();
1064
+ // Apply to server-side document (source of truth)
1065
+ const processed = applyChangesToDocument(changes);
1034
1066
  // Broadcast processed changes (with server-assigned IDs + version) to browser clients
1035
1067
  for (const listener of listeners) {
1036
1068
  listener(processed, version);
@@ -1466,6 +1498,9 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
1466
1498
  // Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
1467
1499
  // (sidecar present, legacy migration, no pending).
1468
1500
  mergeOverlayOnLoad();
1501
+ // Pending metadata rehydration. Read the sidecar's `metadata:` slot so a
1502
+ // staged title rename survives doc-switch and restart. adr: adr/pending-overlay-model.md
1503
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
1469
1504
  // Subscribe the fs watcher to this doc so external writes (Edit tool,
1470
1505
  // VSCode, scripts) trigger a unified reload + version bump + broadcast.
1471
1506
  // adr: adr/active-doc-watcher.md
@@ -1649,6 +1684,7 @@ export function clearAllCaches() {
1649
1684
  state = {
1650
1685
  canonical: DEFAULT_DOC,
1651
1686
  overlay: new Map(),
1687
+ pendingMetadata: null,
1652
1688
  document: DEFAULT_DOC,
1653
1689
  title: 'Untitled',
1654
1690
  metadata: { title: 'Untitled' },
@@ -1750,6 +1786,12 @@ export function reloadActiveDocFromDisk() {
1750
1786
  // sidecar (or legacy migration). Recompute merged via the helper.
1751
1787
  state.canonical = canonical;
1752
1788
  setOverlayFromEntries(entries);
1789
+ // Pending metadata rehydration — mirror what setActiveDocument does.
1790
+ // External-write reloads must keep state.pendingMetadata aligned with the
1791
+ // sidecar's metadata: slot, otherwise a staged title rename gets dropped
1792
+ // from in-memory state the moment fs.watch fires.
1793
+ // adr: adr/pending-overlay-model.md
1794
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
1753
1795
  // External writes change the body but leave disk frontmatter pointing at
1754
1796
  // the previous save's fingerprints. If the user cuts/deletes a block before
1755
1797
  // the next browser-driven save, the matcher graveyards with that stale
@@ -2355,6 +2397,12 @@ export function load() {
2355
2397
  // source.
2356
2398
  // adr: adr/pending-overlay-model.md
2357
2399
  mergeOverlayOnLoad();
2400
+ // Pending metadata rehydration. Boot path bypasses setActiveDocument,
2401
+ // so the per-doc sidecar's `metadata:` slot must be loaded here too.
2402
+ // Without this, a server restart drops staged title renames from
2403
+ // in-memory state until the user switches docs.
2404
+ // adr: adr/pending-overlay-model.md
2405
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
2358
2406
  break;
2359
2407
  }
2360
2408
  catch {
@@ -2545,11 +2593,25 @@ export function addDocTag(filename, tag) {
2545
2593
  if (!tags.includes(tag)) {
2546
2594
  tags.push(tag);
2547
2595
  state.metadata.tags = tags;
2548
- // Preserve mtime tag changes shouldn't affect sidebar sort order
2596
+ // Same docVersion contract as setMetadata: tag changes mutate
2597
+ // state.metadata, so they must bump or writeToDisk's no-op gate
2598
+ // silently drops the write.
2599
+ bumpDocVersion();
2600
+ // Preserve mtime — tag changes shouldn't affect sidebar sort order.
2601
+ // After rolling back disk mtime, we MUST also roll back state.loadedMtime
2602
+ // (which writeToDisk just stamped to the post-write disk mtime).
2603
+ // The fs.watch self-suppression contract is `diskMtime === loadedMtime`;
2604
+ // a divergence here will fire a phantom "external write detected"
2605
+ // reload banner when the watcher's debounced handler runs.
2549
2606
  const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
2550
2607
  save();
2551
- if (mtime && state.filePath)
2608
+ if (mtime && state.filePath) {
2552
2609
  safeRestoreMtime(state.filePath, mtime);
2610
+ try {
2611
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2612
+ }
2613
+ catch { /* best-effort */ }
2614
+ }
2553
2615
  }
2554
2616
  }
2555
2617
  else {
@@ -2585,11 +2647,19 @@ export function removeDocTag(filename, tag) {
2585
2647
  if (idx >= 0) {
2586
2648
  tags.splice(idx, 1);
2587
2649
  state.metadata.tags = tags.length > 0 ? tags : undefined;
2588
- // Preserve mtime tag changes shouldn't affect sidebar sort order
2650
+ // Same docVersion contract as addDocTag mutation must bump or
2651
+ // writeToDisk's no-op gate silently drops the write.
2652
+ bumpDocVersion();
2653
+ // Same loadedMtime re-stamp as addDocTag — see comment there.
2589
2654
  const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
2590
2655
  save();
2591
- if (mtime && state.filePath)
2656
+ if (mtime && state.filePath) {
2592
2657
  safeRestoreMtime(state.filePath, mtime);
2658
+ try {
2659
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2660
+ }
2661
+ catch { /* best-effort */ }
2662
+ }
2593
2663
  }
2594
2664
  }
2595
2665
  else {
@@ -2897,41 +2967,50 @@ function flushDocToFile(filename, doc, title, metadata) {
2897
2967
  invalidateBacklinksCache();
2898
2968
  }
2899
2969
  export function populateDocumentFile(filename, doc) {
2900
- const targetPath = resolveDocPath(filename);
2901
- const raw = readFileSync(targetPath, 'utf-8');
2902
- const parsed = markdownToTiptap(raw);
2970
+ // Read the existing doc as the user-visible MERGED view — canonical body
2971
+ // plus any pre-existing sidecar overlay. Using the bare markdownToTiptap
2972
+ // here would silently drop pre-existing pending entries (the file's
2973
+ // sidecar lives outside the .md body), so a re-populate or a populate on
2974
+ // a doc with prior pending state would clobber the overlay on the
2975
+ // subsequent flushDocToFile. adr: adr/pending-overlay-model.md
2976
+ const loaded = loadDocFromDisk(filename);
2903
2977
  // Skip pending tagging when the target doc effectively has autoAccept on —
2904
2978
  // content commits directly as accepted.
2905
- if (!isAutoAcceptActive(filename, parsed.metadata)) {
2979
+ if (!isAutoAcceptActive(filename, loaded.metadata)) {
2906
2980
  markAllNodesAsPending(doc, 'insert');
2907
2981
  }
2908
- // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
2909
- // flushDocToFile writes `doc` directly it does NOT merge with the existing
2910
- // parsed.document on disk. Without this merge step, the stub's auto-generated
2911
- // trailing paragraph falls out of canonical, the matcher's `previousNodes`
2912
- // for any subsequent save no longer references it, and a follow-up Accept All
2913
- // doc-update can find itself with no matching previousNodes to anchor against.
2914
- // Cascading: the matcher classifies the newly accepted inserts as deletions
2915
- // (orphaned from the empty previousNodes set), they go to graveyard, the disk
2916
- // body ends up empty.
2917
- // Mirrors the active-doc fix in mcp.ts:populate_document.
2918
- if (parsed.document?.content?.length) {
2982
+ // Preserve any pre-existing real content (pending nodes from a prior
2983
+ // populate or write) that isn't in the incoming set so a re-populate or
2984
+ // populate-on-top doesn't clobber prior agent proposals. flushDocToFile
2985
+ // writes `doc` directly and extracts its overlay wholesale, so what isn't
2986
+ // in `doc.content` after this step disappears from the next save.
2987
+ //
2988
+ // Empty paragraphs are explicitly NOT preserved. createDocumentFile mints
2989
+ // a trailing empty paragraph as a TipTap "doc must have at least one
2990
+ // node" stub; that stub is obsolete the moment populate provides real
2991
+ // content, and preserving it leaves a phantom empty paragraph at the end
2992
+ // of every populated doc forever. Mirrors the active-doc preserve in
2993
+ // mcp.ts:populate_document. adr: adr/pending-overlay-model.md
2994
+ if (loaded.document?.content?.length) {
2919
2995
  const incomingIds = new Set(doc.content
2920
2996
  .map((n) => n?.attrs?.id)
2921
2997
  .filter((id) => typeof id === 'string'));
2922
- const preserved = parsed.document.content.filter((n) => {
2998
+ const preserved = loaded.document.content.filter((n) => {
2923
2999
  const id = n?.attrs?.id;
2924
- return id && !incomingIds.has(id);
3000
+ if (!id || incomingIds.has(id))
3001
+ return false;
3002
+ const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
3003
+ return !isEmptyParagraph;
2925
3004
  });
2926
3005
  if (preserved.length > 0) {
2927
3006
  doc.content = [...doc.content, ...preserved];
2928
3007
  }
2929
3008
  }
2930
- flushDocToFile(filename, doc, parsed.title, parsed.metadata);
3009
+ flushDocToFile(filename, doc, loaded.title, loaded.metadata);
2931
3010
  const pendingCount = countPending(doc.content);
2932
3011
  const text = extractText(doc.content);
2933
3012
  const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
2934
- return { title: parsed.title, wordCount, pendingCount };
3013
+ return { title: loaded.title, wordCount, pendingCount };
2935
3014
  }
2936
3015
  /**
2937
3016
  * Apply node changes to a non-active document file on disk.
@@ -2954,12 +3033,17 @@ export function applyChangesToFile(filename, changes) {
2954
3033
  isTemp = cached.isTemp;
2955
3034
  }
2956
3035
  else {
2957
- const raw = readFileSync(targetPath, 'utf-8');
2958
- const parsed = markdownToTiptap(raw);
2959
- doc = parsed.document;
2960
- title = parsed.title;
2961
- metadata = parsed.metadata;
2962
- docId = metadata.docId || '';
3036
+ // Cache miss — load the MERGED view (canonical + sidecar overlay). The
3037
+ // bare markdownToTiptap would give canonical-only, so pre-existing pending
3038
+ // entries would not be in `doc` when we apply the new changes. The
3039
+ // subsequent flushDocToFile then extracts the overlay from `doc` and
3040
+ // overwrites the sidecar — silently dropping every prior pending entry.
3041
+ // adr: adr/pending-overlay-model.md
3042
+ const loaded = loadDocFromDisk(filename);
3043
+ doc = loaded.document;
3044
+ title = loaded.title;
3045
+ metadata = loaded.metadata;
3046
+ docId = loaded.docId;
2963
3047
  isTemp = false;
2964
3048
  }
2965
3049
  const autoAccept = isAutoAcceptActive(filename, metadata);
@@ -3003,12 +3087,15 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
3003
3087
  isTemp = cached.isTemp;
3004
3088
  }
3005
3089
  else {
3006
- const raw = readFileSync(targetPath, 'utf-8');
3007
- const parsed = markdownToTiptap(raw);
3008
- doc = parsed.document;
3009
- title = parsed.title;
3010
- metadata = parsed.metadata;
3011
- docId = metadata.docId || '';
3090
+ // Cache miss load the merged view so a target node living in the
3091
+ // sidecar overlay (a pending insert the agent is now text-editing) is
3092
+ // findable. Bare markdownToTiptap would only show canonical, and the
3093
+ // findNode call below would fail. adr: adr/pending-overlay-model.md
3094
+ const loaded = loadDocFromDisk(filename);
3095
+ doc = loaded.document;
3096
+ title = loaded.title;
3097
+ metadata = loaded.metadata;
3098
+ docId = loaded.docId;
3012
3099
  isTemp = false;
3013
3100
  }
3014
3101
  const found = findNode(doc.content, nodeId, doc.content);
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.25.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.15.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.
@@ -85,10 +87,18 @@ You are a writing collaborator. You read documents and make edits **exclusively
85
87
  2. `browse_docs({ workspaceFile })` — concept-level shelf scan (~60 tokens per doc)
86
88
  3. `outline_doc(docId)` — heading tree (~5 tokens per heading)
87
89
  4. `search_docs(query, { docId })` — in-doc content search → matching nodeIds
88
- 5. `peek_doc(docId, target)` — windowed node read
89
- 6. `read_pad(docId)` — first ~2,000 words of the body, ALWAYS truncated above the cap
90
+ 5. `peek_doc(docId, target)` — windowed node read by nodeId
91
+ 6. `read_pad(docId, ...)` — fixed-window word-position read (default: first ~2,000 words)
92
+
93
+ `read_pad` is a fixed-window tool by default but accepts two knobs for full control:
94
+
95
+ - **Default** — `read_pad({ docId })` returns the first ~2,000 words. Docs at or under the cap return in full.
96
+ - **Slice** — `read_pad({ docId, slice: { from: 0.5, to: 1 } })` reads a percentile range. `{from:0.5, to:1}` = back half, `{from:0.25, to:0.75}` = middle 50%, sequential `{from:0.0,to:0.1}` → `{from:0.1,to:0.2}` … = 10% chunks for whole-doc coverage at predictable per-call cost. Snaps to top-level node boundaries; subject to the cap unless `force` is set.
97
+ - **Force** — `read_pad({ docId, force: true })` bypasses the cap and returns the full requested region. Use for full-doc audits, rewrites, or anywhere you've explicitly accepted the cost.
90
98
 
91
- `read_pad` is a fixed-window tool by contract. Docs ~2,000 words return in full. Above the cap, you get the doc opening (title + intro + first few sections — the most context-rich slice) plus a `lastNodeId` and a continuation hint pointing at `peek_doc({ around: lastNodeId, after: N })`, `outline_doc`, or `search_docs({ query, docId })`. There is no `force` flag the cap is the contract.
99
+ Slice vs peek: peek anchors to a known nodeId (good for "read around this hit"); slice anchors to a word-position percentile (good for "give me the back half" or "walk this doc in 10% chunks"). Use the one that matches your intentneither is strictly better.
100
+
101
+ When the cap kicks in, the response includes `lastNodeId` plus continuation hints for all four follow-up tools (read_pad slice, read_pad force, peek_doc, outline_doc).
92
102
 
93
103
  **Implication for doc structure:** monolith docs (8k+ words in one file) push you up the ladder on every read. Splitting into chapters, sections, or topic-sized docs makes everything cheaper — outline_doc shows the whole shape, browse_docs returns concept-level summaries, and individual reads come back complete. The cap is friction designed to surface monoliths as the wrong unit for AI-assisted writing in this era.
94
104
 
@@ -139,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
139
149
 
140
150
  | Tool | Key Params | Description |
141
151
  |------|-----------|-------------|
142
- | `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. |
143
153
  | `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
144
154
  | `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
145
155
  | `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
@@ -275,7 +285,10 @@ For making changes to existing documents — rewrites, insertions, deletions:
275
285
 
276
286
  - Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
277
287
  - Send **3-8 changes per call** for a responsive, streaming feel
278
- - 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.
279
292
  - Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
280
293
  - Content accepts markdown strings (preferred) or TipTap JSON
281
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.
@@ -388,7 +401,7 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
388
401
 
389
402
  ### Research (read-only, no edits coming)
390
403
 
391
- 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`.
392
405
 
393
406
  ```
394
407
  1. search_docs({ query: "X" }) → ranked docs across workspace
@@ -398,29 +411,38 @@ When the user asks "find X in this doc", "what does Y argue", "show me the beat
398
411
  Use underHeading to drill into one section.
399
412
  3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
400
413
  OR pick a heading nodeId from step 2.
401
- 4. peek_doc({ docId, target: { around, before, after } })
402
- 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)
403
420
  ```
404
421
 
405
- 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.
406
425
 
407
426
  ### Single document (editing)
408
427
 
409
428
  ```
410
429
  1. get_pad_status → check pendingChanges and userSignaledReview
411
- 2. Orient on the doc:
412
- - Short doc (≤ ~2,000 words): read_pad returns the full body — node IDs included
413
- - Long doc (above the cap): outline_doc({ docId }) for shape, then
414
- peek_doc({ around: nodeId, before, after }) around the area you'll edit
415
- (only need fresh IDs for the region you're touching)
416
- - You already know the anchor (from a prior search_docs or deep-link click):
417
- 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
418
440
  3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
419
441
  4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
420
442
  5. Wait → user accepts/rejects in browser
421
443
  ```
422
444
 
423
- `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.
424
446
 
425
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.
426
448
 
@@ -428,13 +450,29 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
428
450
 
429
451
  ```
430
452
  1. list_documents → see all docs with title + [docId] + wordCount
431
- 2. For each target doc, orient first:
432
- - Short doc: read_pad({ docId }) returns full body
433
- - 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 })
434
458
  3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
435
459
  ```
436
460
 
437
- 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 })` |
438
476
 
439
477
  ### Creating new content (two-step)
440
478