openwriter 0.15.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 (36) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/{index-B5MXw2pg.js → index-JMMJM_G_.js} +53 -53
  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/documents.js +234 -3
  21. package/dist/server/enrichment.js +114 -0
  22. package/dist/server/install-skill.js +15 -0
  23. package/dist/server/markdown-parse.js +71 -14
  24. package/dist/server/markdown-serialize.js +14 -16
  25. package/dist/server/mcp.js +250 -23
  26. package/dist/server/node-fingerprint.js +347 -73
  27. package/dist/server/node-matcher.js +19 -44
  28. package/dist/server/pending-overlay.js +21 -4
  29. package/dist/server/state.js +203 -26
  30. package/dist/server/workspaces.js +27 -5
  31. package/dist/server/ws.js +10 -0
  32. package/package.json +1 -1
  33. package/skill/SKILL.md +26 -7
  34. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  35. package/skill/docs/enrichment.md +179 -0
  36. package/dist/client/assets/index-B3iORmCT.css +0 -1
@@ -14,36 +14,67 @@ import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSou
14
14
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
15
  import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
+ import { anyLegacyRaw } from './node-fingerprint.js';
18
+ import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
17
19
  import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
20
+ import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
18
21
  /** Read the persisted identity graph (nodes + graveyard) from a file's
19
- * frontmatter. This is the matcher's previousNodes baseline at save time —
20
- * the disk is the source of truth, not a parallel in-memory cache. Returns
21
- * empty arrays for a brand-new file or unreadable frontmatter. */
22
+ * frontmatter. The save-time matcher reads previousNodes + graveyard
23
+ * directly from disk every write — the disk is the source of truth, not
24
+ * a parallel in-memory cache.
25
+ *
26
+ * Slim disk entries are enriched against the freshly-parsed disk body so
27
+ * derived fields (position, neighbor types, etc.) flow into the rich
28
+ * Fingerprint the matcher expects. Legacy verbose-object entries are
29
+ * positionally re-fingerprinted via the same helper.
30
+ * adr: adr/node-identity-matcher.md */
22
31
  function readPersistedIdentity(filePath) {
23
32
  if (!filePath || !existsSync(filePath))
24
33
  return { previousNodes: [], graveyard: [] };
25
34
  try {
26
35
  const raw = readFileSync(filePath, 'utf-8');
27
- const { data } = matter(raw);
36
+ // Bypass gray-matter for identity reads. gray-matter caches its parsed
37
+ // `data` object by raw string within a process, so any upstream
38
+ // mutation (test wrappers, dev tools) leaks into the matcher's input
39
+ // on subsequent reads. Identity fields live in a JSON frontmatter
40
+ // block emitted by tiptapToMarkdown — parse it directly so we always
41
+ // see fresh data.
42
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
43
+ let rawNodes = [];
44
+ let rawGraveyard = [];
45
+ let body = raw;
46
+ if (fmMatch) {
47
+ try {
48
+ const fmObj = JSON.parse(fmMatch[1]);
49
+ if (Array.isArray(fmObj.nodes))
50
+ rawNodes = fmObj.nodes;
51
+ if (Array.isArray(fmObj.graveyard))
52
+ rawGraveyard = fmObj.graveyard;
53
+ }
54
+ catch { /* malformed JSON frontmatter — treat as no identity */ }
55
+ body = raw.slice(fmMatch[0].length).replace(/^[\r\n]+/, '');
56
+ }
57
+ // Slim entries derive position/parent/neighbors from the slim array
58
+ // itself — no body parse needed. Legacy entries need positional
59
+ // re-fingerprinting from the body, so we parse it lazily only then.
60
+ // This avoids a full markdown re-parse on every save for the common
61
+ // (ultra-lean) case, which was the dominant cost of switchDocument's
62
+ // pre-switch save() for large docs.
63
+ const needsBodyParse = (Array.isArray(rawNodes) && rawNodes.length > 0 && anyLegacyRaw(rawNodes));
64
+ let previousBlocks = [];
65
+ if (needsBodyParse) {
66
+ const previousDocContent = markdownToNodes(body);
67
+ previousBlocks = tiptapToBlocks({ content: previousDocContent });
68
+ }
28
69
  return {
29
- previousNodes: normalizeNodeEntries(data.nodes),
30
- graveyard: normalizeNodeEntries(data.graveyard),
70
+ previousNodes: resolvePreviousNodes(rawNodes, previousBlocks),
71
+ graveyard: resolveGraveyard(rawGraveyard),
31
72
  };
32
73
  }
33
74
  catch {
34
75
  return { previousNodes: [], graveyard: [] };
35
76
  }
36
77
  }
37
- /** Defensive parse of frontmatter node entries — drops any malformed rows.
38
- * Mirrors the same-named helper in markdown-parse.ts so save and load apply
39
- * identical validation. */
40
- function normalizeNodeEntries(raw) {
41
- if (!Array.isArray(raw))
42
- return [];
43
- return raw
44
- .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
45
- .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
46
- }
47
78
  const DEFAULT_DOC = {
48
79
  type: 'doc',
49
80
  content: [{ type: 'paragraph', content: [] }],
@@ -293,11 +324,8 @@ function handleWatcherEvent() {
293
324
  const reloaded = reloadActiveDocFromDisk();
294
325
  if (!reloaded)
295
326
  return;
296
- // Bump version so any in-flight stale browser autosave is rejected by
297
- // the existing version check in the WS handler. Without this bump, the
298
- // browser's pre-external-write state would silently overwrite the
299
- // freshly-loaded disk content.
300
- bumpDocVersion();
327
+ // (docVersion already bumped inside reloadActiveDocFromDisk single
328
+ // source of truth for the reload-version-bump.)
301
329
  notifyDocumentReloaded({
302
330
  filePath: state.filePath,
303
331
  filename: reloaded.filename,
@@ -790,6 +818,14 @@ export function updateDocument(doc) {
790
818
  // recomputed merged view. Direct assignment is forbidden.
791
819
  setPrimaryFromMerged(doc);
792
820
  state.lastModified = new Date();
821
+ // Bump docVersion so the writeToDisk no-op gate (which compares
822
+ // docVersion to lastSavedDocVersion) sees this mutation. Browser
823
+ // doc-updates flow through here, and without the bump the subsequent
824
+ // debouncedSave would short-circuit and the user's edits would never
825
+ // hit disk. The canonical contract: any path that mutates
826
+ // state.document MUST bump docVersion. applyChanges does the same.
827
+ // adr: adr/pending-overlay-model.md
828
+ bumpDocVersion();
793
829
  // Validate: if server had pending changes, verify they survived the transfer
794
830
  if (serverHadPending && !hasPendingChanges()) {
795
831
  console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
@@ -913,6 +949,14 @@ export function isAgentLocked(filename) {
913
949
  }
914
950
  // ---- Document version counter: prevents stale browser doc-updates ----
915
951
  let docVersion = 0;
952
+ // Counterpart to docVersion: the docVersion at which we last confirmed
953
+ // in-memory state matches disk. save()/writeToDisk() is a strict no-op when
954
+ // these are equal (and a file exists on disk). This is the server-side
955
+ // counterpart to the client diff-gate in App.tsx — it makes doc-switches
956
+ // between unchanged docs free (no serialize, no sidecar write, no snapshot,
957
+ // no mtime bump that would invalidate the doc cache).
958
+ // adr: adr/pending-overlay-model.md
959
+ let lastSavedDocVersion = 0;
916
960
  /** Increment version after agent writes. Returns the new version. */
917
961
  export function bumpDocVersion() {
918
962
  return ++docVersion;
@@ -925,9 +969,13 @@ export function getDocVersion() {
925
969
  export function isVersionCurrent(browserVersion) {
926
970
  return browserVersion >= docVersion;
927
971
  }
928
- /** Reset version on document switch (new document = new version lineage). */
972
+ /** Reset version on document switch (new document = new version lineage).
973
+ * Both counters move together: the new doc was just loaded from disk (or
974
+ * cache, which mtime-validates against disk), so in-memory matches disk
975
+ * by definition. */
929
976
  export function resetDocVersion() {
930
977
  docVersion = 0;
978
+ lastSavedDocVersion = 0;
931
979
  }
932
980
  // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
933
981
  //
@@ -1669,6 +1717,26 @@ export function reloadActiveDocFromDisk() {
1669
1717
  // sidecar (or legacy migration). Recompute merged via the helper.
1670
1718
  state.canonical = canonical;
1671
1719
  setOverlayFromEntries(entries);
1720
+ // External writes change the body but leave disk frontmatter pointing at
1721
+ // the previous save's fingerprints. If the user cuts/deletes a block before
1722
+ // the next browser-driven save, the matcher graveyards with that stale
1723
+ // fingerprint and a later paste-back can't match by exact fingerprint —
1724
+ // graveyard-restore silently misses. Resync disk frontmatter with the
1725
+ // reloaded body now: the matcher's edit rule pins IDs and emits fresh
1726
+ // per-block fingerprints. fs.watch self-suppression via state.loadedMtime
1727
+ // (handleWatcherEvent) prevents a reload→save→reload loop.
1728
+ //
1729
+ // Bump docVersion BEFORE writeToDisk: serves two purposes at once. (1)
1730
+ // Rejects any in-flight stale browser autosaves (the WS handler checks
1731
+ // version currency). (2) Forces writeToDisk's no-op gate to see "dirty"
1732
+ // state and actually persist the refreshed frontmatter — without the bump,
1733
+ // the gate would short-circuit and the stale-fingerprint bug returns.
1734
+ // adr: adr/node-identity-matcher.md
1735
+ bumpDocVersion();
1736
+ try {
1737
+ writeToDisk();
1738
+ }
1739
+ catch { /* best-effort — reload still useful even if save fails */ }
1672
1740
  return {
1673
1741
  document: state.document,
1674
1742
  title: state.title,
@@ -1822,7 +1890,8 @@ export function hasAcceptedContent(doc) {
1822
1890
  * Mark leaf block nodes as pending within a node array.
1823
1891
  * Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
1824
1892
  * NOT container nodes (bulletList, orderedList, listItem, blockquote).
1825
- * This ensures collectPendingState captures them correctly on save.
1893
+ * Used by `applyChangesToDoc` for write_to_pad inserts where containers
1894
+ * are handled by the explicit firstNode top-level mark.
1826
1895
  */
1827
1896
  function markLeafBlocksAsPending(nodes, status) {
1828
1897
  if (!nodes)
@@ -1839,8 +1908,45 @@ function markLeafBlocksAsPending(nodes, status) {
1839
1908
  }
1840
1909
  }
1841
1910
  }
1911
+ /**
1912
+ * Block-level container types. Tagged as pending alongside leaves on the
1913
+ * populate path so a fresh doc with nested content (lists, blockquotes)
1914
+ * records the wrappers as overlay entries, not just the inner paragraphs.
1915
+ * Without this, on reload the wrappers are gone (empty containers have no
1916
+ * markdown representation) and inner-paragraph entries with parentNodeId
1917
+ * pointing at the missing wrapper get classified as orphans.
1918
+ *
1919
+ * adr: adr/pending-overlay-model.md
1920
+ */
1921
+ const CONTAINER_BLOCK_TYPES = new Set([
1922
+ 'bulletList', 'orderedList', 'listItem',
1923
+ 'taskList', 'taskItem',
1924
+ 'blockquote',
1925
+ ]);
1926
+ /**
1927
+ * Mark every block node (leaves + containers) as pending. Used by the
1928
+ * populate path where the entire doc tree is the agent's proposal — every
1929
+ * structural node must become an overlay entry so on reload the leaves'
1930
+ * parentNodeId references resolve through entries placed earlier in the
1931
+ * same batch.
1932
+ */
1933
+ function markAllBlockNodesAsPending(nodes, status) {
1934
+ if (!nodes)
1935
+ return;
1936
+ for (const node of nodes) {
1937
+ if (node.type && (LEAF_BLOCK_TYPES.has(node.type) || CONTAINER_BLOCK_TYPES.has(node.type))) {
1938
+ node.attrs = { ...node.attrs, pendingStatus: status };
1939
+ if (!node.attrs.id) {
1940
+ node.attrs.id = generateNodeId();
1941
+ }
1942
+ }
1943
+ if (node.content && !LEAF_BLOCK_TYPES.has(node.type)) {
1944
+ markAllBlockNodesAsPending(node.content, status);
1945
+ }
1946
+ }
1947
+ }
1842
1948
  export function markAllNodesAsPending(doc, status) {
1843
- markLeafBlocksAsPending(doc.content, status);
1949
+ markAllBlockNodesAsPending(doc.content, status);
1844
1950
  }
1845
1951
  /** Read pending doc info from in-memory cache (O(1) instead of disk scan). */
1846
1952
  export function getPendingDocInfo() {
@@ -1866,6 +1972,16 @@ export function getPendingDocInfo() {
1866
1972
  // PERSISTENCE
1867
1973
  // ============================================================================
1868
1974
  function writeToDisk() {
1975
+ // No-op gate: when the in-memory document hasn't been mutated since the
1976
+ // last successful write (or byte-equality skip), bail before any work.
1977
+ // Skips the full serialize + matcher pipeline (~50ms on medium docs), the
1978
+ // sidecar overlay write, the snapshot read+write, and the mtime bump that
1979
+ // would invalidate the doc cache. The existsSync check ensures first-save
1980
+ // of a new file still runs even when version state looks clean.
1981
+ // adr: adr/pending-overlay-model.md
1982
+ if (state.filePath && existsSync(state.filePath) && docVersion === lastSavedDocVersion) {
1983
+ return;
1984
+ }
1869
1985
  ensureDataDir();
1870
1986
  // Capture old forward links BEFORE we overwrite the file — needed by the
1871
1987
  // backlinks engine to know which target docs to refresh when source changes.
@@ -1915,12 +2031,23 @@ function writeToDisk() {
1915
2031
  // adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
1916
2032
  const canonical = cloneWithPendingReverted(state.document);
1917
2033
  const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
2034
+ // previousNodes and graveyard are already in rich Fingerprint form —
2035
+ // readPersistedIdentity handles slim-tuple enrichment and legacy
2036
+ // re-fingerprinting before returning. Matcher gets a uniform input
2037
+ // regardless of what's on disk.
2038
+ // adr: adr/node-identity-matcher.md
2039
+ //
2040
+ // newBlocks is computed once and reused by:
2041
+ // (a) the matcher branch below (when there are previous nodes to match)
2042
+ // (b) the enrichment staleness check (always — even on first save)
2043
+ // Hoisted outside the matcher conditional so first-save staleness still
2044
+ // gets the current sentence-hash signal.
2045
+ const newBlocks = tiptapToBlocks(canonical);
1918
2046
  let nextGraveyard = graveyard;
1919
2047
  const idTranslation = new Map();
1920
2048
  if (previousNodes.length > 0) {
1921
- const newBlocks = tiptapToBlocks(canonical);
1922
2049
  const beforeIds = newBlocks.map((b) => b.id);
1923
- const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
2050
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard: nextGraveyard });
1924
2051
  const pinnedByPosition = new Map();
1925
2052
  for (const p of matchResult.pinned)
1926
2053
  pinnedByPosition.set(p.position, p.id);
@@ -1964,6 +2091,27 @@ function writeToDisk() {
1964
2091
  if (state.docId) {
1965
2092
  saveOverlay(state.docId, Array.from(state.overlay.values()));
1966
2093
  }
2094
+ // ENRICHMENT STALENESS — reuses the matcher's sentence-hash machinery.
2095
+ // After the matcher pass, harvest current sentence hashes + char count
2096
+ // from the same blocks the matcher just operated on; compare against the
2097
+ // at-enrichment baseline in frontmatter. Flip enrichmentStale=true when
2098
+ // volume or drift thresholds trip. OpenWriter never clears the flag —
2099
+ // that's the agent's job via mark_enriched (Phase 4).
2100
+ //
2101
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2102
+ try {
2103
+ const currentSentences = harvestSentenceHashes(newBlocks);
2104
+ const currentChars = harvestCharCount(newBlocks);
2105
+ const stale = isEnrichmentStale(currentSentences, currentChars, state.metadata);
2106
+ if (stale && state.metadata.enrichmentStale !== true) {
2107
+ state.metadata.enrichmentStale = true;
2108
+ diagLog(`[Enrichment] stale: ${state.filePath}`);
2109
+ }
2110
+ }
2111
+ catch (err) {
2112
+ // Staleness detection is observational, never load-bearing for the save.
2113
+ console.error('[Enrichment] staleness check failed:', err);
2114
+ }
1967
2115
  // Pass graveyard through metadata so the serializer can emit it in frontmatter.
1968
2116
  const metaWithGraveyard = nextGraveyard.length > 0
1969
2117
  ? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
@@ -1984,6 +2132,10 @@ function writeToDisk() {
1984
2132
  state.loadedMtime = statSync(state.filePath).mtimeMs;
1985
2133
  }
1986
2134
  catch { /* best-effort */ }
2135
+ // Mark in-sync at this docVersion so the next save bails at the
2136
+ // top-level gate before re-running serialize. Without this, the
2137
+ // gate would only kick in after a real disk write.
2138
+ lastSavedDocVersion = docVersion;
1987
2139
  return;
1988
2140
  }
1989
2141
  }
@@ -2037,6 +2189,9 @@ function writeToDisk() {
2037
2189
  state.loadedMtime = statSync(state.filePath).mtimeMs;
2038
2190
  }
2039
2191
  catch { /* best-effort */ }
2192
+ // Record that disk now matches in-memory at this docVersion. Subsequent
2193
+ // save() calls without further mutations will bail at the top-level gate.
2194
+ lastSavedDocVersion = docVersion;
2040
2195
  // Best-effort version snapshot — never blocks saves
2041
2196
  try {
2042
2197
  snapshotIfNeeded(state.docId, state.filePath);
@@ -2554,6 +2709,28 @@ export function countPending(nodes) {
2554
2709
  * adr: adr/pending-overlay-model.md */
2555
2710
  function flushDocToFile(filename, doc, title, metadata) {
2556
2711
  const targetPath = resolveDocPath(filename);
2712
+ // Enrichment staleness — same signal as writeToDisk, but flushDocToFile
2713
+ // bypasses the matcher entirely so we harvest sentence hashes directly.
2714
+ // Measure the canonical (pending-reverted) view since that's what lands on
2715
+ // disk; pending overlay content rides in the sidecar and isn't part of the
2716
+ // doc's "published" content for enrichment purposes. External docs skip —
2717
+ // they don't participate in the enrichment graph.
2718
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2719
+ if (!isExternalDoc(targetPath)) {
2720
+ try {
2721
+ const canonical = cloneWithPendingReverted(doc);
2722
+ const blocks = tiptapToBlocks(canonical);
2723
+ const currentSentences = harvestSentenceHashes(blocks);
2724
+ const currentChars = harvestCharCount(blocks);
2725
+ const stale = isEnrichmentStale(currentSentences, currentChars, metadata);
2726
+ if (stale && metadata.enrichmentStale !== true) {
2727
+ metadata.enrichmentStale = true;
2728
+ }
2729
+ }
2730
+ catch (err) {
2731
+ console.error('[Enrichment] staleness check (flush) failed:', err);
2732
+ }
2733
+ }
2557
2734
  const markdown = tiptapToMarkdown(doc, title, metadata);
2558
2735
  atomicWriteFileSync(targetPath, markdown);
2559
2736
  const docId = (metadata && typeof metadata.docId === 'string') ? metadata.docId : '';
@@ -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
  }
package/dist/server/ws.js CHANGED
@@ -323,8 +323,18 @@ export function setupWebSocket(server) {
323
323
  }
324
324
  if (msg.type === 'switch-document' && msg.filename) {
325
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}`);
326
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`);
327
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`);
328
338
  }
329
339
  catch (err) {
330
340
  console.error('[WS] Switch document failed:', err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.15.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.3"
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
 
@@ -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,6 +165,16 @@ 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
 
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
+
156
178
  ### Comments
157
179
 
158
180
  | Tool | Key Params | Description |
@@ -646,10 +668,7 @@ Then restart your Claude Code session (`/mcp` to reconnect).
646
668
 
647
669
  ### Restarting the MCP server
648
670
 
649
- Depends on how OpenWriter is configured:
650
-
651
- - **Claude Code with `~/.claude.json`** — run `/mcp` to reconnect.
652
- - **Claude Desktop with settings → developer** — 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 (`taskkill /F /PID <pid>` on Windows, `kill <pid>` on macOS/Linux) first so the spawn picks up the new build.
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`.
653
672
 
654
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.
655
674