openwriter 0.15.0 → 0.17.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 (40) hide show
  1. package/dist/client/assets/index-0ttVnjRp.css +1 -0
  2. package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
  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/compact.js +28 -2
  21. package/dist/server/documents.js +234 -3
  22. package/dist/server/enrichment.js +125 -0
  23. package/dist/server/export-routes.js +2 -0
  24. package/dist/server/install-skill.js +15 -0
  25. package/dist/server/markdown-parse.js +153 -14
  26. package/dist/server/markdown-serialize.js +100 -17
  27. package/dist/server/mcp.js +291 -25
  28. package/dist/server/node-blocks.js +41 -1
  29. package/dist/server/node-fingerprint.js +347 -73
  30. package/dist/server/node-matcher.js +19 -44
  31. package/dist/server/pending-overlay.js +21 -4
  32. package/dist/server/state.js +225 -41
  33. package/dist/server/workspaces.js +27 -5
  34. package/dist/server/ws.js +10 -0
  35. package/package.json +2 -1
  36. package/skill/SKILL.md +38 -7
  37. package/skill/agents/openwriter-enrichment-minion.md +177 -0
  38. package/skill/docs/enrichment.md +179 -0
  39. package/skill/docs/footnotes.md +178 -0
  40. 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,
@@ -777,23 +805,28 @@ export function updateDocument(doc) {
777
805
  console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
778
806
  return;
779
807
  }
780
- // Preserve pending attrs from server state incoming browser doc.
781
- // The browser's PendingAttributes extension tracks pendingStatus in the TipTap
782
- // document model, but transferPendingAttrs provides a safety net in case the
783
- // browser's doc-update lost them (e.g. timing edge case, stale transaction).
784
- const serverHadPending = hasPendingChanges();
785
- if (serverHadPending) {
786
- transferPendingAttrs(state.document, doc);
787
- }
788
- // Route the incoming merged-shape doc through the primary-state setter
789
- // so canonical + overlay get refreshed and state.document is the
790
- // recomputed merged view. Direct assignment is forbidden.
808
+ // Trust the browser-sent doc as authoritative. The WebSocket handler's
809
+ // version gate (isVersionCurrent) already routed stale browser submissions
810
+ // through syncBrowserDocUpdate (the merge path); by the time we land here,
811
+ // the browser saw the same view of pending state the server has. An
812
+ // incoming doc with pending markers cleared is by definition an intentional
813
+ // accept — never an attrs-lost-in-transit error. The older safety net
814
+ // (transferPendingAttrs re-stamping server's pending onto the incoming doc)
815
+ // worked under the pre-fb666e6 model where state.document was authoritative,
816
+ // but under the canonical+overlay split model it actively reverted user
817
+ // accepts: re-stamped 'insert' markers got filtered out of canonical by
818
+ // stripPendingFromDoc, and the just-accepted body disappeared from disk.
819
+ // adr: adr/pending-overlay-model.md
791
820
  setPrimaryFromMerged(doc);
792
821
  state.lastModified = new Date();
793
- // Validate: if server had pending changes, verify they survived the transfer
794
- if (serverHadPending && !hasPendingChanges()) {
795
- console.error('[State] WARNING: pending changes lost after updateDocument browser doc-update overwrote pending attrs');
796
- }
822
+ // Bump docVersion so the writeToDisk no-op gate (which compares
823
+ // docVersion to lastSavedDocVersion) sees this mutation. Browser
824
+ // doc-updates flow through here, and without the bump the subsequent
825
+ // debouncedSave would short-circuit and the user's edits would never
826
+ // hit disk. The canonical contract: any path that mutates
827
+ // state.document MUST bump docVersion. applyChanges does the same.
828
+ // adr: adr/pending-overlay-model.md
829
+ bumpDocVersion();
797
830
  }
798
831
  /**
799
832
  * Transfer pending attrs from source doc to target doc by matching node IDs.
@@ -913,6 +946,14 @@ export function isAgentLocked(filename) {
913
946
  }
914
947
  // ---- Document version counter: prevents stale browser doc-updates ----
915
948
  let docVersion = 0;
949
+ // Counterpart to docVersion: the docVersion at which we last confirmed
950
+ // in-memory state matches disk. save()/writeToDisk() is a strict no-op when
951
+ // these are equal (and a file exists on disk). This is the server-side
952
+ // counterpart to the client diff-gate in App.tsx — it makes doc-switches
953
+ // between unchanged docs free (no serialize, no sidecar write, no snapshot,
954
+ // no mtime bump that would invalidate the doc cache).
955
+ // adr: adr/pending-overlay-model.md
956
+ let lastSavedDocVersion = 0;
916
957
  /** Increment version after agent writes. Returns the new version. */
917
958
  export function bumpDocVersion() {
918
959
  return ++docVersion;
@@ -925,9 +966,13 @@ export function getDocVersion() {
925
966
  export function isVersionCurrent(browserVersion) {
926
967
  return browserVersion >= docVersion;
927
968
  }
928
- /** Reset version on document switch (new document = new version lineage). */
969
+ /** Reset version on document switch (new document = new version lineage).
970
+ * Both counters move together: the new doc was just loaded from disk (or
971
+ * cache, which mtime-validates against disk), so in-memory matches disk
972
+ * by definition. */
929
973
  export function resetDocVersion() {
930
974
  docVersion = 0;
975
+ lastSavedDocVersion = 0;
931
976
  }
932
977
  // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
933
978
  //
@@ -1669,6 +1714,26 @@ export function reloadActiveDocFromDisk() {
1669
1714
  // sidecar (or legacy migration). Recompute merged via the helper.
1670
1715
  state.canonical = canonical;
1671
1716
  setOverlayFromEntries(entries);
1717
+ // External writes change the body but leave disk frontmatter pointing at
1718
+ // the previous save's fingerprints. If the user cuts/deletes a block before
1719
+ // the next browser-driven save, the matcher graveyards with that stale
1720
+ // fingerprint and a later paste-back can't match by exact fingerprint —
1721
+ // graveyard-restore silently misses. Resync disk frontmatter with the
1722
+ // reloaded body now: the matcher's edit rule pins IDs and emits fresh
1723
+ // per-block fingerprints. fs.watch self-suppression via state.loadedMtime
1724
+ // (handleWatcherEvent) prevents a reload→save→reload loop.
1725
+ //
1726
+ // Bump docVersion BEFORE writeToDisk: serves two purposes at once. (1)
1727
+ // Rejects any in-flight stale browser autosaves (the WS handler checks
1728
+ // version currency). (2) Forces writeToDisk's no-op gate to see "dirty"
1729
+ // state and actually persist the refreshed frontmatter — without the bump,
1730
+ // the gate would short-circuit and the stale-fingerprint bug returns.
1731
+ // adr: adr/node-identity-matcher.md
1732
+ bumpDocVersion();
1733
+ try {
1734
+ writeToDisk();
1735
+ }
1736
+ catch { /* best-effort — reload still useful even if save fails */ }
1672
1737
  return {
1673
1738
  document: state.document,
1674
1739
  title: state.title,
@@ -1822,7 +1887,8 @@ export function hasAcceptedContent(doc) {
1822
1887
  * Mark leaf block nodes as pending within a node array.
1823
1888
  * Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
1824
1889
  * NOT container nodes (bulletList, orderedList, listItem, blockquote).
1825
- * This ensures collectPendingState captures them correctly on save.
1890
+ * Used by `applyChangesToDoc` for write_to_pad inserts where containers
1891
+ * are handled by the explicit firstNode top-level mark.
1826
1892
  */
1827
1893
  function markLeafBlocksAsPending(nodes, status) {
1828
1894
  if (!nodes)
@@ -1839,8 +1905,55 @@ function markLeafBlocksAsPending(nodes, status) {
1839
1905
  }
1840
1906
  }
1841
1907
  }
1908
+ /**
1909
+ * Block-level container types. Tagged as pending alongside leaves on the
1910
+ * populate path so a fresh doc with nested content (lists, blockquotes)
1911
+ * records the wrappers as overlay entries, not just the inner paragraphs.
1912
+ * Without this, on reload the wrappers are gone (empty containers have no
1913
+ * markdown representation) and inner-paragraph entries with parentNodeId
1914
+ * pointing at the missing wrapper get classified as orphans.
1915
+ *
1916
+ * adr: adr/pending-overlay-model.md
1917
+ */
1918
+ const CONTAINER_BLOCK_TYPES = new Set([
1919
+ 'bulletList', 'orderedList', 'listItem',
1920
+ 'taskList', 'taskItem',
1921
+ 'blockquote',
1922
+ // Footnote containers — without these, populate_document's
1923
+ // markAllNodesAsPending pass skipped the section + definition shells,
1924
+ // leaving their pendingStatus unset. The serializer's revert pass then
1925
+ // dropped the inner pending paragraphs but kept the empty container
1926
+ // shells, producing an on-disk file with `[^N]:` definition headers and
1927
+ // no content. Marking them container-level pending makes the entire
1928
+ // subtree get dropped together on canonical serialize and carried whole
1929
+ // in the pending overlay.
1930
+ // adr: adr/footnote-system.md
1931
+ 'footnoteSection', 'footnoteDefinition',
1932
+ ]);
1933
+ /**
1934
+ * Mark every block node (leaves + containers) as pending. Used by the
1935
+ * populate path where the entire doc tree is the agent's proposal — every
1936
+ * structural node must become an overlay entry so on reload the leaves'
1937
+ * parentNodeId references resolve through entries placed earlier in the
1938
+ * same batch.
1939
+ */
1940
+ function markAllBlockNodesAsPending(nodes, status) {
1941
+ if (!nodes)
1942
+ return;
1943
+ for (const node of nodes) {
1944
+ if (node.type && (LEAF_BLOCK_TYPES.has(node.type) || CONTAINER_BLOCK_TYPES.has(node.type))) {
1945
+ node.attrs = { ...node.attrs, pendingStatus: status };
1946
+ if (!node.attrs.id) {
1947
+ node.attrs.id = generateNodeId();
1948
+ }
1949
+ }
1950
+ if (node.content && !LEAF_BLOCK_TYPES.has(node.type)) {
1951
+ markAllBlockNodesAsPending(node.content, status);
1952
+ }
1953
+ }
1954
+ }
1842
1955
  export function markAllNodesAsPending(doc, status) {
1843
- markLeafBlocksAsPending(doc.content, status);
1956
+ markAllBlockNodesAsPending(doc.content, status);
1844
1957
  }
1845
1958
  /** Read pending doc info from in-memory cache (O(1) instead of disk scan). */
1846
1959
  export function getPendingDocInfo() {
@@ -1866,6 +1979,16 @@ export function getPendingDocInfo() {
1866
1979
  // PERSISTENCE
1867
1980
  // ============================================================================
1868
1981
  function writeToDisk() {
1982
+ // No-op gate: when the in-memory document hasn't been mutated since the
1983
+ // last successful write (or byte-equality skip), bail before any work.
1984
+ // Skips the full serialize + matcher pipeline (~50ms on medium docs), the
1985
+ // sidecar overlay write, the snapshot read+write, and the mtime bump that
1986
+ // would invalidate the doc cache. The existsSync check ensures first-save
1987
+ // of a new file still runs even when version state looks clean.
1988
+ // adr: adr/pending-overlay-model.md
1989
+ if (state.filePath && existsSync(state.filePath) && docVersion === lastSavedDocVersion) {
1990
+ return;
1991
+ }
1869
1992
  ensureDataDir();
1870
1993
  // Capture old forward links BEFORE we overwrite the file — needed by the
1871
1994
  // backlinks engine to know which target docs to refresh when source changes.
@@ -1915,12 +2038,23 @@ function writeToDisk() {
1915
2038
  // adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
1916
2039
  const canonical = cloneWithPendingReverted(state.document);
1917
2040
  const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
2041
+ // previousNodes and graveyard are already in rich Fingerprint form —
2042
+ // readPersistedIdentity handles slim-tuple enrichment and legacy
2043
+ // re-fingerprinting before returning. Matcher gets a uniform input
2044
+ // regardless of what's on disk.
2045
+ // adr: adr/node-identity-matcher.md
2046
+ //
2047
+ // newBlocks is computed once and reused by:
2048
+ // (a) the matcher branch below (when there are previous nodes to match)
2049
+ // (b) the enrichment staleness check (always — even on first save)
2050
+ // Hoisted outside the matcher conditional so first-save staleness still
2051
+ // gets the current sentence-hash signal.
2052
+ const newBlocks = tiptapToBlocks(canonical);
1918
2053
  let nextGraveyard = graveyard;
1919
2054
  const idTranslation = new Map();
1920
2055
  if (previousNodes.length > 0) {
1921
- const newBlocks = tiptapToBlocks(canonical);
1922
2056
  const beforeIds = newBlocks.map((b) => b.id);
1923
- const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
2057
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard: nextGraveyard });
1924
2058
  const pinnedByPosition = new Map();
1925
2059
  for (const p of matchResult.pinned)
1926
2060
  pinnedByPosition.set(p.position, p.id);
@@ -1964,6 +2098,27 @@ function writeToDisk() {
1964
2098
  if (state.docId) {
1965
2099
  saveOverlay(state.docId, Array.from(state.overlay.values()));
1966
2100
  }
2101
+ // ENRICHMENT STALENESS — reuses the matcher's sentence-hash machinery.
2102
+ // After the matcher pass, harvest current sentence hashes + char count
2103
+ // from the same blocks the matcher just operated on; compare against the
2104
+ // at-enrichment baseline in frontmatter. Flip enrichmentStale=true when
2105
+ // volume or drift thresholds trip. OpenWriter never clears the flag —
2106
+ // that's the agent's job via mark_enriched (Phase 4).
2107
+ //
2108
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2109
+ try {
2110
+ const currentSentences = harvestSentenceHashes(newBlocks);
2111
+ const currentChars = harvestCharCount(newBlocks);
2112
+ const stale = isEnrichmentStale(currentSentences, currentChars, state.metadata);
2113
+ if (stale && state.metadata.enrichmentStale !== true) {
2114
+ state.metadata.enrichmentStale = true;
2115
+ diagLog(`[Enrichment] stale: ${state.filePath}`);
2116
+ }
2117
+ }
2118
+ catch (err) {
2119
+ // Staleness detection is observational, never load-bearing for the save.
2120
+ console.error('[Enrichment] staleness check failed:', err);
2121
+ }
1967
2122
  // Pass graveyard through metadata so the serializer can emit it in frontmatter.
1968
2123
  const metaWithGraveyard = nextGraveyard.length > 0
1969
2124
  ? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
@@ -1984,6 +2139,10 @@ function writeToDisk() {
1984
2139
  state.loadedMtime = statSync(state.filePath).mtimeMs;
1985
2140
  }
1986
2141
  catch { /* best-effort */ }
2142
+ // Mark in-sync at this docVersion so the next save bails at the
2143
+ // top-level gate before re-running serialize. Without this, the
2144
+ // gate would only kick in after a real disk write.
2145
+ lastSavedDocVersion = docVersion;
1987
2146
  return;
1988
2147
  }
1989
2148
  }
@@ -2037,6 +2196,9 @@ function writeToDisk() {
2037
2196
  state.loadedMtime = statSync(state.filePath).mtimeMs;
2038
2197
  }
2039
2198
  catch { /* best-effort */ }
2199
+ // Record that disk now matches in-memory at this docVersion. Subsequent
2200
+ // save() calls without further mutations will bail at the top-level gate.
2201
+ lastSavedDocVersion = docVersion;
2040
2202
  // Best-effort version snapshot — never blocks saves
2041
2203
  try {
2042
2204
  snapshotIfNeeded(state.docId, state.filePath);
@@ -2554,6 +2716,28 @@ export function countPending(nodes) {
2554
2716
  * adr: adr/pending-overlay-model.md */
2555
2717
  function flushDocToFile(filename, doc, title, metadata) {
2556
2718
  const targetPath = resolveDocPath(filename);
2719
+ // Enrichment staleness — same signal as writeToDisk, but flushDocToFile
2720
+ // bypasses the matcher entirely so we harvest sentence hashes directly.
2721
+ // Measure the canonical (pending-reverted) view since that's what lands on
2722
+ // disk; pending overlay content rides in the sidecar and isn't part of the
2723
+ // doc's "published" content for enrichment purposes. External docs skip —
2724
+ // they don't participate in the enrichment graph.
2725
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2726
+ if (!isExternalDoc(targetPath)) {
2727
+ try {
2728
+ const canonical = cloneWithPendingReverted(doc);
2729
+ const blocks = tiptapToBlocks(canonical);
2730
+ const currentSentences = harvestSentenceHashes(blocks);
2731
+ const currentChars = harvestCharCount(blocks);
2732
+ const stale = isEnrichmentStale(currentSentences, currentChars, metadata);
2733
+ if (stale && metadata.enrichmentStale !== true) {
2734
+ metadata.enrichmentStale = true;
2735
+ }
2736
+ }
2737
+ catch (err) {
2738
+ console.error('[Enrichment] staleness check (flush) failed:', err);
2739
+ }
2740
+ }
2557
2741
  const markdown = tiptapToMarkdown(doc, title, metadata);
2558
2742
  atomicWriteFileSync(targetPath, markdown);
2559
2743
  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.17.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",
@@ -64,6 +64,7 @@
64
64
  "gray-matter": "^4.0.3",
65
65
  "lowlight": "^3.3.0",
66
66
  "markdown-it": "^14.1.1",
67
+ "markdown-it-footnote": "^4.0.0",
67
68
  "markdown-it-ins": "^4.0.0",
68
69
  "markdown-it-mark": "^4.0.0",
69
70
  "markdown-it-sub": "^2.0.0",
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.6"
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 |
@@ -281,6 +303,18 @@ When creating **two or more documents together** — a tweet thread saved as sep
281
303
  - `reply` / `quote` types still require `url`
282
304
  - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
283
305
 
306
+ ### Citations & footnotes
307
+
308
+ Long-form writing (especially academic-adjacent nonfiction) uses CommonMark / Pandoc footnote syntax:
309
+
310
+ - **Reference** (inline in prose): `text[^1]` — renders as a superscript chip
311
+ - **Definition** (anywhere in the markdown body): `[^1]: footnote text` — automatically corralled into a "Footnotes" section at end-of-doc on save
312
+ - **Mnemonic labels** allowed: `[^sapolsky2017]` survives round-trip on disk; the editor shows auto-sequential display numbers regardless
313
+
314
+ Just include the syntax in `populate_document` content or `write_to_pad` content — no special tool needed. The parser handles the tokenization, the editor handles the rendering, the serializer enforces the constrained end-of-doc shape.
315
+
316
+ **Scope is per-doc.** Each chapter has its own `[^1]` … `[^N]` numbering; cross-doc references aren't supported at the editor level. Full guide → `docs/footnotes.md`.
317
+
284
318
  ## Companion Skills (optional)
285
319
 
286
320
  For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill — 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
@@ -646,10 +680,7 @@ Then restart your Claude Code session (`/mcp` to reconnect).
646
680
 
647
681
  ### Restarting the MCP server
648
682
 
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.
683
+ 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
684
 
654
685
  **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
686