openwriter 0.14.0 → 0.16.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.
Files changed (43) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/index-JMMJM_G_.js +212 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/comments.js +256 -0
  21. package/dist/server/documents.js +293 -20
  22. package/dist/server/enrichment.js +114 -0
  23. package/dist/server/helpers.js +63 -8
  24. package/dist/server/index.js +94 -40
  25. package/dist/server/install-skill.js +15 -0
  26. package/dist/server/logger.js +246 -0
  27. package/dist/server/markdown-parse.js +71 -14
  28. package/dist/server/markdown-serialize.js +136 -41
  29. package/dist/server/mcp.js +538 -99
  30. package/dist/server/node-blocks.js +22 -4
  31. package/dist/server/node-fingerprint.js +347 -73
  32. package/dist/server/node-matcher.js +76 -49
  33. package/dist/server/pending-overlay.js +862 -0
  34. package/dist/server/state.js +1178 -98
  35. package/dist/server/versions.js +18 -0
  36. package/dist/server/workspaces.js +42 -5
  37. package/dist/server/ws.js +194 -37
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +51 -21
  40. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  41. package/skill/docs/enrichment.md +179 -0
  42. package/dist/client/assets/index-BxI3DazW.js +0 -212
  43. package/dist/client/assets/index-OV13QtgQ.css +0 -1
@@ -108,6 +108,24 @@ export function forceSnapshot(docId, filePath) {
108
108
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
109
109
  lastSnapshot.set(docId, { time: now, hash });
110
110
  }
111
+ /**
112
+ * Write a snapshot from an arbitrary markdown string (rather than copying
113
+ * the current file). Used by `restore_version` so the safety checkpoint
114
+ * can represent the canonical-only state (pending reverted) rather than the
115
+ * flattened pending+canonical body that lives on disk.
116
+ *
117
+ * Returns the timestamp the snapshot was written at.
118
+ */
119
+ export function writeSnapshotMarkdown(docId, markdown) {
120
+ if (!docId)
121
+ return 0;
122
+ const hash = contentHash(markdown);
123
+ const now = Date.now();
124
+ ensureDocDir(docId);
125
+ writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
126
+ lastSnapshot.set(docId, { time: now, hash });
127
+ return now;
128
+ }
111
129
  // ============================================================================
112
130
  // LIST / GET
113
131
  // ============================================================================
@@ -326,12 +326,34 @@ export function reorderWorkspaceAfter(filename, afterFilename) {
326
326
  }
327
327
  writeOrder(order);
328
328
  }
329
- // ============================================================================
330
- // CONTEXT
331
- // ============================================================================
332
- export function updateWorkspaceContext(wsFile, context) {
329
+ const WRITING_CONTEXT_KEYS = new Set(['characters', 'settings', 'rules']);
330
+ const ENRICHMENT_FIELDS = new Set([
331
+ 'logline', 'domain', 'schema', 'vocab', 'relatedWorkspaces',
332
+ 'enrichmentVolumeThreshold', 'enrichmentDriftThreshold', 'enrichmentDisabled',
333
+ ]);
334
+ export function updateWorkspaceContext(wsFile, update) {
333
335
  const ws = getWorkspace(wsFile);
334
- ws.context = { ...ws.context, ...context };
336
+ // Writing context (characters/settings/rules) merge into ws.context.
337
+ const ctxUpdate = {};
338
+ for (const key of WRITING_CONTEXT_KEYS) {
339
+ if (key in update)
340
+ ctxUpdate[key] = update[key];
341
+ }
342
+ if (Object.keys(ctxUpdate).length > 0) {
343
+ ws.context = { ...ws.context, ...ctxUpdate };
344
+ }
345
+ // Enrichment fields set on the workspace top-level. `null` clears.
346
+ for (const key of ENRICHMENT_FIELDS) {
347
+ if (!(key in update))
348
+ continue;
349
+ const value = update[key];
350
+ if (value === null) {
351
+ delete ws[key];
352
+ }
353
+ else {
354
+ ws[key] = value;
355
+ }
356
+ }
335
357
  writeWorkspace(wsFile, ws);
336
358
  return ws;
337
359
  }
@@ -437,6 +459,21 @@ export function getWorkspaceAssignedFiles() {
437
459
  }
438
460
  return assigned;
439
461
  }
462
+ /** Return every workspace manifest filename that contains this doc. A doc may
463
+ * appear in multiple workspaces; callers that want one usually pick the first. */
464
+ export function findWorkspacesContainingDoc(file) {
465
+ const workspaces = listWorkspaces();
466
+ const result = [];
467
+ for (const info of workspaces) {
468
+ try {
469
+ const ws = readWorkspace(info.filename);
470
+ if (collectAllFiles(ws.root).includes(file))
471
+ result.push(info);
472
+ }
473
+ catch { /* skip corrupt manifests */ }
474
+ }
475
+ return result;
476
+ }
440
477
  /**
441
478
  * Walk every workspace and return true if `file` is inside one where auto-accept
442
479
  * is on at the workspace level or on any ancestor container. Returns false when
package/dist/server/ws.js CHANGED
@@ -2,21 +2,42 @@
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, setAgentLock, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
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, isAgentStub, unmarkAgentStub, } from './state.js';
6
6
  import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
+ import { canonicalizeIdentifier } from './helpers.js';
9
+ import { nodeTextPreview, diagLog } from './pending-overlay.js';
10
+ import { generateRequestId, withRequestId } from './logger.js';
11
+ /** Walk a doc and return a per-pending-node summary for diagnostic logging.
12
+ * Produces lines like "nodeId/status text=\"...\" orig=\"...\"" — empty
13
+ * if the doc has no pending nodes. adr: adr/pending-overlay-model.md */
14
+ function pendingSummary(doc) {
15
+ if (!doc?.content)
16
+ return '<none>';
17
+ const lines = [];
18
+ function walk(nodes) {
19
+ if (!Array.isArray(nodes))
20
+ return;
21
+ for (const node of nodes) {
22
+ const status = node?.attrs?.pendingStatus;
23
+ if (status && node?.attrs?.id) {
24
+ const text = nodeTextPreview(node);
25
+ const orig = node.attrs.pendingOriginalContent ? nodeTextPreview(node.attrs.pendingOriginalContent) : '<none>';
26
+ const identity = (status === 'rewrite' && text === orig) ? ' IDENTITY!' : '';
27
+ lines.push(`${node.attrs.id}/${status} text="${text}" orig="${orig}"${identity}`);
28
+ }
29
+ if (node.content)
30
+ walk(node.content);
31
+ }
32
+ }
33
+ walk(doc.content);
34
+ return lines.length === 0 ? '<none>' : lines.join(' | ');
35
+ }
8
36
  const clients = new Set();
9
37
  let currentAgentConnected = false;
10
- // Debounced auto-save: persist to disk 2s after last doc-update
11
- let saveTimer = null;
12
- function debouncedSave() {
13
- if (saveTimer)
14
- clearTimeout(saveTimer);
15
- saveTimer = setTimeout(() => {
16
- save();
17
- console.log('[WS] Auto-saved to disk');
18
- }, 2000);
19
- }
38
+ // Debounced auto-save lives in state.ts now one timer for the whole process.
39
+ // Both browser doc-update (here) and MCP write paths (state.ts) call into the
40
+ // same timer so a single TTL governs all save activity.
20
41
  // Debounced sidebar refresh: notify clients after title changes settle
21
42
  let docsChangedTimer = null;
22
43
  function debouncedBroadcastDocumentsChanged() {
@@ -64,7 +85,7 @@ export function setupWebSocket(server) {
64
85
  // Re-set agent lock so the 3s window starts NOW, not from the original insert.
65
86
  // Tweet thread resyncs recreate all editors which fire onUpdate → stale doc-updates.
66
87
  // Without this reset, the lock expires before the browser finishes recreating editors.
67
- setAgentLock();
88
+ setAgentLockActive();
68
89
  const filePath = getFilePath();
69
90
  const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
70
91
  const msg = JSON.stringify({
@@ -90,6 +111,76 @@ export function setupWebSocket(server) {
90
111
  // Notify browser of updated pending docs list (debounced)
91
112
  broadcastPendingDocsChanged();
92
113
  });
114
+ // Push matcher-driven ID rewrites to clients so their in-memory TipTap state
115
+ // tracks the server's authoritative ID assignment. Without this, the browser
116
+ // can hold stale IDs after a save-time matcher reassignment, subsequent
117
+ // server→browser messages targeting the new IDs silently fail to resolve at
118
+ // the bridge anchor lookup, and the browser's debounced autosave eventually
119
+ // overwrites fresh server state with the stale view (the v0.14.0 bug pattern).
120
+ // adr: adr/node-identity-matcher.md
121
+ onIdRewrites((rewrites) => {
122
+ if (rewrites.length === 0)
123
+ return;
124
+ const msg = JSON.stringify({ type: 'id-rewrites', rewrites });
125
+ for (const ws of clients) {
126
+ if (ws.readyState === WebSocket.OPEN)
127
+ ws.send(msg);
128
+ }
129
+ console.log(`[WS] Broadcast id-rewrites (${rewrites.length} block(s))`);
130
+ });
131
+ // Push-based: the fs.watch on the active doc fires this listener when an
132
+ // external writer (Edit tool, VSCode, a script) modifies the file. The
133
+ // server has already reloaded its in-memory state from disk and bumped
134
+ // docVersion — we just need to swap the browser's TipTap view so it
135
+ // matches and surface a toast. Stale autosaves from before the external
136
+ // write are rejected by the existing version check in the doc-update
137
+ // handler.
138
+ //
139
+ // Version sync: we include the freshly-bumped docVersion in the message.
140
+ // The browser adopts it as its new baseline so subsequent autosaves (from
141
+ // the user typing on top of the reloaded content) pass the isVersionCurrent
142
+ // check. Without this, browser sits at version 0 while the server is at
143
+ // N+1, and every browser edit gets BLOCKED — typing silently fails to save.
144
+ // adr: adr/active-doc-watcher.md
145
+ onDocumentReloaded((event) => {
146
+ const msg = JSON.stringify({
147
+ type: 'document-reloaded',
148
+ filename: event.filename,
149
+ document: event.document,
150
+ title: event.title,
151
+ docId: event.docId,
152
+ metadata: event.metadata,
153
+ version: getDocVersion(),
154
+ orphanCount: event.orphans.length,
155
+ staleBaselineCount: event.staleBaseline.length,
156
+ });
157
+ for (const ws of clients) {
158
+ if (ws.readyState === WebSocket.OPEN)
159
+ ws.send(msg);
160
+ }
161
+ // Pending count may have shifted (orphan rewrites convert to inserts).
162
+ broadcastPendingDocsChanged();
163
+ });
164
+ // Legacy: surface external-write conflicts when writeToDisk's mtime
165
+ // guard fires. With the active-doc watcher in place, the watcher should
166
+ // reload before any save races, but the guard remains as a backstop —
167
+ // if it fires, we still want to tell the user.
168
+ // adr: adr/external-write-guard.md
169
+ onExternalWriteConflict((conflict) => {
170
+ const filename = conflict.filePath.split(/[/\\]/).pop() || conflict.filePath;
171
+ const msg = JSON.stringify({
172
+ type: 'external-write-conflict',
173
+ filePath: conflict.filePath,
174
+ filename,
175
+ diskMtime: conflict.diskMtime,
176
+ loadedMtime: conflict.loadedMtime,
177
+ });
178
+ for (const ws of clients) {
179
+ if (ws.readyState === WebSocket.OPEN)
180
+ ws.send(msg);
181
+ }
182
+ console.warn(`[WS] Broadcast external-write-conflict for ${filename}`);
183
+ });
93
184
  wss.on('connection', (ws) => {
94
185
  clients.add(ws);
95
186
  console.log(`[WS] Client connected (total: ${clients.size})`);
@@ -103,9 +194,12 @@ export function setupWebSocket(server) {
103
194
  // (prevents stale browser tabs from displaying old content)
104
195
  const filePath = getFilePath();
105
196
  const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
197
+ const docOnConnect = getDocument();
198
+ const pendingOnConnect = pendingSummary(docOnConnect);
199
+ diagLog(`[WS] document-switched SEND on-connect docId=${getDocId()} v=${getDocVersion()} pending=[${pendingOnConnect}]`);
106
200
  ws.send(JSON.stringify({
107
201
  type: 'document-switched',
108
- document: getDocument(),
202
+ document: docOnConnect,
109
203
  title: getTitle(),
110
204
  filename,
111
205
  docId: getDocId(),
@@ -124,22 +218,57 @@ export function setupWebSocket(server) {
124
218
  ws.on('message', async (data) => {
125
219
  try {
126
220
  const msg = JSON.parse(data.toString());
221
+ // Every WS message gets a request ID — every downstream log emitted
222
+ // while handling this message inherits it. Trace one request through
223
+ // the whole system with: jq 'select(.requestId=="ws-xxxxxx")'.
224
+ // adr: adr/logging-system.md
225
+ const reqId = generateRequestId(`ws-${msg.type || 'unknown'}`);
226
+ return await withRequestId(reqId, async () => { return await handleMessage(msg); });
227
+ }
228
+ catch (err) {
229
+ console.error('[WS] Message error:', err.message);
230
+ }
231
+ });
232
+ /** Inner handler — body extracted so it runs inside the withRequestId
233
+ * scope above. Returns void. */
234
+ async function handleMessage(msg) {
235
+ try {
127
236
  if (msg.type === 'doc-update' && msg.document) {
128
237
  const docContent = msg.document?.content || [];
129
238
  const nodeCount = docContent.length;
130
239
  const currentNodeCount = getDocument()?.content?.length || 0;
131
240
  const browserVersion = typeof msg.version === 'number' ? msg.version : -1;
132
241
  const serverVersion = getDocVersion();
133
- if (isAgentLocked()) {
134
- console.log(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
242
+ // Canonicalize the browser-sent filename so comparison against
243
+ // getActiveFilename() doesn't trip on separator/case differences
244
+ // for the same file. Without this, a browser that cached the
245
+ // pre-canonicalization spelling sends doc-updates that look like
246
+ // they're for a different doc, triggering saveDocToFile() to
247
+ // write to the old non-canonical path — re-creating the
248
+ // duplicate-document bug we just fixed.
249
+ // adr: adr/path-canonicalization.md
250
+ const browserFilename = msg.filename ? canonicalizeIdentifier(msg.filename) : msg.filename;
251
+ if (isAgentLocked(browserFilename || getActiveFilename())) {
252
+ diagLog(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes) filename=${browserFilename || '<active>'} browserPending=[${pendingSummary(msg.document)}]`);
135
253
  }
136
254
  else if (browserVersion >= 0 && !isVersionCurrent(browserVersion)) {
137
- console.log(`[WS] doc-update BLOCKED by stale version (browser: v${browserVersion}, server: v${serverVersion})`);
255
+ // Stale-version doc-update: instead of rejecting (which silently
256
+ // discarded the user's typing), MERGE — keep browser's view of
257
+ // canonical, union the overlays with server-recent additions
258
+ // preserved. adr: adr/pending-overlay-model.md
259
+ if (msg.document.content) {
260
+ msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
261
+ }
262
+ const result = syncBrowserDocUpdate(msg.document, browserVersion);
263
+ diagLog(`[WS] doc-update SYNC-MERGED stale v${browserVersion}→v${serverVersion} preservedServerEntries=${result.preservedServerEntries}`);
264
+ updatePendingCacheForActiveDoc();
265
+ debouncedSave();
138
266
  }
139
- else if (msg.filename && msg.filename !== getActiveFilename()) {
267
+ else if (browserFilename && browserFilename !== getActiveFilename()) {
140
268
  // Browser sent a doc-update for a different document (race: server switched away).
141
269
  // Save directly to that file on disk instead of corrupting the active doc.
142
- saveDocToFile(msg.filename, msg.document);
270
+ diagLog(`[WS] doc-update ROUTED-TO-NON-ACTIVE filename=${browserFilename} browserPending=[${pendingSummary(msg.document)}]`);
271
+ saveDocToFile(browserFilename, msg.document);
143
272
  }
144
273
  else {
145
274
  // Strip ephemeral imageLoading nodes — they're transient placeholders that should
@@ -148,7 +277,16 @@ export function setupWebSocket(server) {
148
277
  msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
149
278
  }
150
279
  const cleanedCount = msg.document.content?.length || 0;
151
- console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
280
+ // Capture server's BEFORE state so we can diff against the incoming.
281
+ // adr: adr/pending-overlay-model.md
282
+ const serverBefore = pendingSummary(getDocument());
283
+ const browserAfter = pendingSummary(msg.document);
284
+ if (serverBefore !== browserAfter) {
285
+ diagLog(`[WS] doc-update PENDING-DIFF v${browserVersion}→v${serverVersion}`);
286
+ diagLog(` BEFORE(server): ${serverBefore}`);
287
+ diagLog(` AFTER (browser): ${browserAfter}`);
288
+ }
289
+ diagLog(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
152
290
  updateDocument(msg.document);
153
291
  updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
154
292
  debouncedSave();
@@ -185,8 +323,18 @@ export function setupWebSocket(server) {
185
323
  }
186
324
  if (msg.type === 'switch-document' && msg.filename) {
187
325
  try {
326
+ // adr-perf: full server-side switch timing. [Switch] CLICK/SEND
327
+ // on the client, [Switch:Server] RECV/DONE/BCAST on the server,
328
+ // [Switch] RECEIVE/COMMIT and [Editor] mounted back on the
329
+ // client. Each delta exposes where the latency lives.
330
+ const tRecv = performance.now();
331
+ diagLog(`[Switch:Server] RECV filename=${msg.filename}`);
188
332
  const result = switchDocument(msg.filename);
333
+ const tSwitchDone = performance.now();
334
+ diagLog(`[Switch:Server] DONE filename=${msg.filename} switchDoc=${(tSwitchDone - tRecv).toFixed(1)}ms`);
189
335
  broadcastDocumentSwitched(result.document, result.title, result.filename);
336
+ const tBcastDone = performance.now();
337
+ diagLog(`[Switch:Server] BCAST filename=${msg.filename} stringify+send=${(tBcastDone - tSwitchDone).toFixed(1)}ms totalServer=${(tBcastDone - tRecv).toFixed(1)}ms`);
190
338
  }
191
339
  catch (err) {
192
340
  console.error('[WS] Switch document failed:', err.message);
@@ -256,17 +404,22 @@ export function setupWebSocket(server) {
256
404
  const action = msg.action; // 'accept' or 'reject'
257
405
  const resolvedFilename = msg.filename;
258
406
  const isActiveDoc = resolvedFilename === getActiveFilename();
259
- // Get metadata from the correct source (active state or disk file)
260
- const metadata = isActiveDoc ? getMetadata() : null;
261
- if (action === 'reject' && metadata?.agentCreated) {
262
- // Agent-created doc with all content rejected → delete the file
263
- // Cancel debounced save (doc-update may have queued one for the now-empty doc)
264
- if (saveTimer) {
265
- clearTimeout(saveTimer);
266
- saveTimer = null;
267
- }
407
+ // Stub-cleanup: when the user rejects all pending decorations on a
408
+ // doc that's still a fresh agent stub, delete the file. The stub
409
+ // had no real content; the user said no to the populated content;
410
+ // there's nothing left to keep.
411
+ //
412
+ // Stub status is consulted from the in-memory registry — NEVER
413
+ // from disk frontmatter. The previous on-disk `agentCreated: true`
414
+ // model was a silent-data-loss landmine: the flag survived across
415
+ // sessions, restarts, and the doc's entire useful lifetime, so a
416
+ // reject-all years later would destroy real work. The in-memory
417
+ // model can only mark a doc as a stub during the brief window
418
+ // between create_document and the first accepted save.
419
+ // adr: adr/agent-stub-model.md
420
+ if (action === 'reject' && isAgentStub(resolvedFilename)) {
421
+ cancelDebouncedSave();
268
422
  try {
269
- // Remove from any workspace manifests before deleting the file
270
423
  removeDocFromAllWorkspaces(resolvedFilename);
271
424
  const result = await deleteDocument(resolvedFilename);
272
425
  if (result.switched && result.newDoc) {
@@ -278,15 +431,17 @@ export function setupWebSocket(server) {
278
431
  return; // File deleted — no strip/save needed
279
432
  }
280
433
  catch (err) {
281
- console.error('[WS] Failed to delete rejected agent doc:', err.message);
434
+ console.error('[WS] Failed to delete rejected agent stub:', err.message);
282
435
  // Fall through to normal strip+save (e.g. only doc remaining)
283
436
  }
284
437
  }
285
438
  if (isActiveDoc) {
286
- // Normal path: resolved doc is the active one
287
- if (action === 'accept' && metadata?.agentCreated) {
288
- delete metadata.agentCreated;
289
- }
439
+ // Normal path: resolved doc is the active one. Accept-all
440
+ // graduates the doc out of stub status (it now has accepted
441
+ // content); the writeToDisk graduation does the same for
442
+ // saves with mixed pending+accepted content.
443
+ if (action === 'accept')
444
+ unmarkAgentStub(resolvedFilename);
290
445
  stripPendingAttrs();
291
446
  save();
292
447
  updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
@@ -294,6 +449,8 @@ export function setupWebSocket(server) {
294
449
  else {
295
450
  // Race path: resolved doc is NOT the active one (server switched away).
296
451
  // Strip pending attrs directly from the file on disk.
452
+ if (action === 'accept')
453
+ unmarkAgentStub(resolvedFilename);
297
454
  stripPendingAttrsFromFile(resolvedFilename, action === 'accept');
298
455
  }
299
456
  broadcastPendingDocsChanged();
@@ -302,7 +459,7 @@ export function setupWebSocket(server) {
302
459
  catch {
303
460
  // Ignore malformed messages
304
461
  }
305
- });
462
+ }
306
463
  ws.on('close', () => {
307
464
  clients.delete(ws);
308
465
  console.log(`[WS] Client disconnected (total: ${clients.size})`);
@@ -457,8 +614,8 @@ export function getPendingWritesSnapshot() {
457
614
  startedAt,
458
615
  }));
459
616
  }
460
- export function broadcastMarksChanged(filename) {
461
- const msg = JSON.stringify({ type: 'marks-changed', filename });
617
+ export function broadcastCommentsChanged(filename) {
618
+ const msg = JSON.stringify({ type: 'comments-changed', filename });
462
619
  for (const ws of clients) {
463
620
  if (ws.readyState === WebSocket.OPEN)
464
621
  ws.send(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.14.0",
3
+ "version": "0.16.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.7.0"
19
+ version: "0.7.5"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -31,6 +31,18 @@ You are a writing collaborator. You read documents and make edits **exclusively
31
31
  2. **The terminal is for discussion only.** Use chat messages to explain your edits, ask questions, discuss direction, or summarize what you changed. Never use it as the writing surface.
32
32
  3. **Name every document.** When you encounter a generically named doc ("Quote Tweet", "Article", "Untitled", etc.), rename it based on its content before proceeding. Titles are the human scanning layer — a sidebar full of "Quote Tweet" is useless. Use `rename_item` with the docId. Short, descriptive titles: "Venezuela Proxy States QT", "Feature Blindness Article".
33
33
  4. **Metadata first.** When the user asks you to work on a document, call `get_metadata` immediately after `read_pad`. Tweet docs store the parent URL in `tweetContext.url`, article docs store context in `articleContext`. **Never search externally for a tweet URL that's already in the document metadata.** This prevents wasting paid API calls on information you already have.
34
+ 5. **Dispatch the enrichment minion when openwriter says so.** OpenWriter surfaces stale enrichment two ways: (a) `ENRICHMENT_STATUS: N docs need enrichment ...` in the MCP server's session-start instructions; (b) a `⚠ N docs need enrichment — spawn the enrichment minion` footer on `list_documents`, `list_workspaces`, and `get_workspace_structure` responses. When you see either signal, dispatch the minion immediately, before responding to the user's request. Use the Agent tool with `subagent_type: "openwriter-enrichment-minion"` — that subagent is installed by `npx openwriter install-skill` at `~/.claude/agents/openwriter-enrichment-minion.md` and ships with its tool allowlist restricted to the 4 MCP tools it needs (no full registry overhead). Pass a one-line prompt like `"Enrich all currently stale openwriter docs."` — the subagent has its full procedure encoded already. It runs autonomously, calls `list_dirty_docs` → `read_pad` per doc → bulk `mark_enriched`, and returns a short summary.
35
+
36
+ **Surfacing to the user:** treat enrichment like the inbox — a maintenance reflex, not a feature they have to ask for. Phrasing depends on context:
37
+
38
+ - **First time in a session, small batch (N ≤ 5):** silent dispatch + one-line aside in your response: "Enriched 3 docs in the background. Now, ..."
39
+ - **First time in a session, medium batch (5 < N ≤ 20):** brief explanation on first surface: "OpenWriter just refreshed loglines and concepts on 12 docs in the background. Now, ..." Sets expectations once; subsequent runs can stay silent.
40
+ - **First time in a session, large batch (N > 20):** give the user a heads-up BEFORE dispatching: "OpenWriter detected 47 docs that haven't been summarized yet — first-time setup. Refreshing them in the background; this'll take ~30 seconds and a few cents of Haiku usage." Then dispatch and report when done.
41
+ - **Very large batch (N > 30):** one minion can't get through that many in reasonable wall time. Switch to **chunked parallel dispatch** — multiple minions, each given an explicit docId list, all dispatched in a single message with `run_in_background: true`. Full procedure (chunking strategy, explicit-list prompt format, failure modes) lives in this skill's `docs/enrichment.md`. Read that doc before dispatching anything over 30 docs.
42
+
43
+ **If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-enrichment-minion' not found`. Tell the user once: "OpenWriter has stale docs but the enrichment minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
44
+
45
+ **If the user opts out** ("stop nagging me about enrichment for X workspace"): call `update_workspace_context` with `enrichmentDisabled: true` for that workspace. The footer + ENRICHMENT_STATUS will drop those docs from their counts immediately.
34
46
 
35
47
  ## Setup — Which Path?
36
48
 
@@ -118,7 +130,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
118
130
  | Tool | Key Params | Description |
119
131
  |------|-----------|-------------|
120
132
  | `list_documents` | — | List all documents with title, docId, word count, active status |
121
- | `switch_document` | `docId` | Switch to a different document by docId |
133
+ | `switch_document` | `docId` | Change the user's view to a different document. **Rarely needed** — every tool targets docs by docId directly, so reads, writes, and creations never require switching. Use ONLY when you want to pull the user's attention to a specific doc (e.g. "I've loaded this up for your review"). The user may be perusing other docs — don't yank their view as part of normal work. |
122
134
  | `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" |
123
135
  | `open_file` | `path` | Open an existing .md file from any location on disk |
124
136
  | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
@@ -138,8 +150,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
138
150
  | `list_workspaces` | List all workspaces with title and doc count |
139
151
  | `create_workspace` | Create a new workspace |
140
152
  | `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
141
- | `get_workspace_structure` | Get full workspace tree: containers, docs, tags, context |
142
- | `get_item_context` | Get progressive disclosure context for a doc in a workspace |
153
+ | `get_workspace_structure` | Get full workspace tree: containers, docs, enrichment (logline/domain/docRole per doc), workspace-level vocab/schema, plus context (characters, settings, rules) |
154
+ | `get_item_context` | Get progressive disclosure context for a doc workspace context + the doc's own enrichment (logline, domain, concepts, docRole, status, enrichmentStale) |
143
155
  | `update_workspace_context` | Update workspace context (characters, settings, rules) |
144
156
 
145
157
  ### Workspace Organization
@@ -153,12 +165,24 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
153
165
  | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
154
166
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
155
167
 
156
- ### Agent Marks
168
+ ### Enrichment (frontmatter classification + crawlability)
169
+
170
+ OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-hash Jaccard drift, character-count volume ratio) on every save and stamps `enrichmentStale: true`. The agent's job is to dispatch the enrichment minion (see firm rule 5 + `docs/enrichment.md` in this skill) to refresh the loglines, domain, concepts, docRole, and status fields.
171
+
172
+ | Tool | Key Params | Description |
173
+ |------|-----------|-------------|
174
+ | `list_dirty_docs` | `workspaceFile?` | List docs that need enrichment (never enriched OR explicitly flagged stale). Returns identity + reason only — no bodies. Optionally scoped to one workspace. Docs in opted-out workspaces (`enrichmentDisabled: true`) are excluded. |
175
+ | `mark_enriched` | `docs: [{docId, logline?, domain?, concepts?, docRole?, status?}]` | Stamp one or more docs as freshly enriched. OpenWriter auto-computes baselines (`lastEnrichedAt`, `lastEnrichedCharCount`, `lastEnrichedSentences`) and clears `enrichmentStale`. The minion calls this once at the end of its run with the full batch. |
176
+ | `crawl` | `workspaceFile?`, `domain?`, `tags?`, `concepts?`, `docRole?`, `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~150 tokens per doc, no bodies. Pick which bodies to actually read after crawling. |
177
+
178
+ ### Comments
157
179
 
158
180
  | Tool | Key Params | Description |
159
181
  |------|-----------|-------------|
160
- | `get_agent_marks` | `docId?` | Get inline feedback marks left by the user (optional docId omit for all docs) |
161
- | `resolve_agent_marks` | `mark_ids` | Remove marks after addressing feedback (pass mark IDs) |
182
+ | `get_comments` | `docId?`, `scope?` | Get comments left by the user. Default scope is `workspace` when a docId is given (returns comments for every doc in the same project); pass `scope: "document"` to narrow, or `scope: "all"` for every doc on disk |
183
+ | `resolve_comments` | `comment_ids` | Remove comments after addressing feedback (pass comment IDs) |
184
+
185
+ The older names `get_agent_marks` and `resolve_agent_marks` remain as deprecated aliases.
162
186
 
163
187
  ### Task Management
164
188
 
@@ -233,7 +257,7 @@ The user can turn on **auto-accept** on a per-doc basis (right-click the doc in
233
257
  **Rules:**
234
258
  - `create_document` does NOT accept a `content` parameter — it always creates an empty doc
235
259
  - Step 1 (`create_document`) — shows spinner, creates empty doc, does NOT switch the editor
236
- - Step 2 (`populate_document`) — writes content to the active doc, marks as pending decorations, switches the editor, clears the spinner
260
+ - Step 2 (`populate_document`) — pass the `docId` from step 1 to write content directly to that doc, marks as pending decorations, clears the spinner. Does NOT switch the user's view — they keep working wherever they are.
237
261
  - Never use `write_to_pad` for the initial population — use `populate_document` exclusively
238
262
 
239
263
  ### Workspace-Integrated Creation
@@ -301,7 +325,7 @@ For voice-matched drafting without a custom Author's Voice profile, install the
301
325
 
302
326
  ```
303
327
  1. list_documents → see all docs with title + [docId]
304
- 2. read_pad read active doc (or switch_document({ docId }) first)
328
+ 2. read_pad({ docId: "e5f6a7b8" }) reads that doc directly, no switch needed
305
329
  3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
306
330
  → edits go to the identified doc, no view switch needed
307
331
  ```
@@ -333,21 +357,23 @@ For voice-matched drafting without a custom Author's Voice profile, install the
333
357
 
334
358
  The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
335
359
 
336
- ### Agent marks (inline feedback)
360
+ ### Comments (inline feedback)
337
361
 
338
- Users can select text in the browser, right-click, and leave an "Agent Mark" — a note attached to a specific text range. Marks appear as dotted underlines in the editor. This is the user's way of marking up a document with feedback for you to address.
362
+ Users can select text in the browser, right-click, and leave a comment — a note attached to a specific text range. Comments appear as dotted underlines in the editor. This is the user's way of marking up a document with feedback for you to address.
339
363
 
340
364
  ```
341
- 1. User says "check my marks" (or you see the hint in read_pad output)
342
- 2. get_agent_marks all marks across all docs, grouped by document
343
- 3. Address each mark → rewrite, insert, delete via write_to_pad (use docId)
344
- 4. resolve_agent_marks([ids]) → clears decorations in browser
365
+ 1. User says "check my comments" (or you see the hint in read_pad output)
366
+ 2. get_comments({ docId }) comments for the current workspace by default
367
+ 3. Address each comment → rewrite, insert, delete via write_to_pad (use docId)
368
+ 4. resolve_comments([ids]) → clears decorations in browser
345
369
  ```
346
370
 
347
- - `read_pad` automatically shows mark counts: this doc + other docs
348
- - Always resolve marks after addressing them — the dotted underlines clear immediately
349
- - A mark with an empty note means "fix this" use your judgment
350
- - A mark with a note is specific feedback follow the instruction
371
+ - `read_pad` automatically shows comment counts: this doc + other docs
372
+ - Default scope is `workspace` when a docId is provided you see comments across every doc in the user's current project, not just the one they're viewing
373
+ - Pass `scope: "document"` to narrow to one doc, `scope: "all"` to span everything on disk
374
+ - Always resolve comments after addressing them — `resolve_comments` is a state change ("addressed, archive it"), not a destructive delete. The record stays in storage; only the decoration disappears. `get_comments` skips resolved ones by default
375
+ - A comment with an empty note means "fix this" — use your judgment
376
+ - A comment with a note is specific feedback — follow the instruction
351
377
 
352
378
  ### Book workspace guidelines
353
379
 
@@ -638,7 +664,11 @@ Then restart your Claude Code session (`/mcp` to reconnect).
638
664
 
639
665
  **MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
640
666
 
641
- **Browser dies mid-session** — The MCP stdio pipe can break during context compaction or session resets. The HTTP server survives (crash guards), but MCP tools stop working. Tell the user: **run `/mcp` to reconnect.** The new process enters client mode and proxies MCP calls to the surviving HTTP server. The browser will auto-reconnect.
667
+ **Browser dies mid-session** — The MCP stdio pipe can break during context compaction or session resets. The HTTP server survives (crash guards), but MCP tools stop working. Reconnect by [restarting the MCP server](#restarting-the-mcp-server) (see below). The new process enters client mode and proxies MCP calls to the surviving HTTP server. The browser will auto-reconnect.
668
+
669
+ ### Restarting the MCP server
670
+
671
+ Both Claude Code and Claude Desktop work the same way: there's no explicit restart button. Call `list_documents` (zero params, read-only, fast). If the previous process is dead, Claude auto-spawns a fresh one to satisfy the call. After code changes, kill the old process first (`taskkill /F /PID <pid>` on Windows, `kill <pid>` on macOS/Linux) so the spawn picks up the new build. Only fall back to `/mcp` (Claude Code) if tool calls keep returning `Connection error: fetch failed`.
642
672
 
643
673
  **Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
644
674
 
@@ -648,6 +678,6 @@ Then restart your Claude Code session (`/mcp` to reconnect).
648
678
 
649
679
  **Server not starting** — Ensure `openwriter` works from your terminal (`npm install -g openwriter` first). If on Windows and the global command isn't found, the MCP config may need `"command": "cmd"` with `"args": ["/c", "openwriter", "--no-open"]`.
650
680
 
651
- **After code changes** — Run `npm run build` in `packages/openwriter`, then `/mcp` to restart with the new build.
681
+ **After code changes** — Run `npm run build` in `packages/openwriter`, kill the running openwriter process, then [restart the MCP server](#restarting-the-mcp-server). `/mcp` alone only reconnects to the existing process; it won't pick up new code unless the old process dies first.
652
682
 
653
683
  **Slow to load / loads last** — MCP servers load sequentially in config order. Move `openwriter` to the first position in `mcpServers` in `~/.claude.json`. See setup instructions above.