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.
- package/dist/client/assets/index-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/compact.js +28 -2
- package/dist/server/documents.js +234 -3
- package/dist/server/enrichment.js +125 -0
- package/dist/server/export-routes.js +2 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +153 -14
- package/dist/server/markdown-serialize.js +100 -17
- package/dist/server/mcp.js +291 -25
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +19 -44
- package/dist/server/pending-overlay.js +21 -4
- package/dist/server/state.js +225 -41
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +2 -1
- package/skill/SKILL.md +38 -7
- package/skill/agents/openwriter-enrichment-minion.md +177 -0
- package/skill/docs/enrichment.md +179 -0
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
package/dist/server/state.js
CHANGED
|
@@ -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.
|
|
20
|
-
* the disk is the source of truth, not
|
|
21
|
-
*
|
|
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
|
-
|
|
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:
|
|
30
|
-
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
|
-
//
|
|
297
|
-
//
|
|
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
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
// browser
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
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
|
-
//
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
142
|
-
| `get_item_context` | Get progressive disclosure context for a doc
|
|
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
|
-
|
|
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
|
|