openwriter 0.19.0 → 0.20.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-BZ7LCzrR.js → index-B1-K-j46.js} +52 -52
- package/dist/client/index.html +1 -1
- package/dist/server/backlinks.js +148 -108
- package/dist/server/index.js +30 -5
- package/dist/server/mcp.js +75 -109
- package/dist/server/state.js +51 -17
- package/package.json +1 -1
package/dist/server/mcp.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
|
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
15
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
16
16
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
17
|
-
import {
|
|
17
|
+
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
18
18
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
19
19
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
20
20
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
@@ -567,6 +567,28 @@ export const TOOL_REGISTRY = [
|
|
|
567
567
|
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
568
568
|
markAllNodesAsPending(doc, 'insert');
|
|
569
569
|
}
|
|
570
|
+
// Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
|
|
571
|
+
// updateDocument(doc) overwrites state.canonical wholesale — without this
|
|
572
|
+
// merge, the create_document → populate_document sequence loses the stub's
|
|
573
|
+
// auto-generated trailing paragraph from canonical. When the browser later
|
|
574
|
+
// accepts the inserts and sends a doc-update with its TipTap-rendered tree
|
|
575
|
+
// (which also has a trailing empty paragraph, but a different ID), the
|
|
576
|
+
// save-time matcher classifies the stub's original trailing as deleted →
|
|
577
|
+
// graveyard, while the freshly added inserts have no previousNodes match.
|
|
578
|
+
// Cascading state corruption observed in live test 2026-05-22.
|
|
579
|
+
const existingCanonical = getCanonical();
|
|
580
|
+
if (existingCanonical?.content?.length) {
|
|
581
|
+
const incomingIds = new Set(doc.content
|
|
582
|
+
.map((n) => n?.attrs?.id)
|
|
583
|
+
.filter((id) => typeof id === 'string'));
|
|
584
|
+
const preserved = existingCanonical.content.filter((n) => {
|
|
585
|
+
const id = n?.attrs?.id;
|
|
586
|
+
return id && !incomingIds.has(id);
|
|
587
|
+
});
|
|
588
|
+
if (preserved.length > 0) {
|
|
589
|
+
doc.content = [...doc.content, ...preserved];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
570
592
|
updateDocument(doc);
|
|
571
593
|
updatePendingCacheForActiveDoc();
|
|
572
594
|
save();
|
|
@@ -1687,115 +1709,69 @@ export const TOOL_REGISTRY = [
|
|
|
1687
1709
|
},
|
|
1688
1710
|
{
|
|
1689
1711
|
name: 'link_to',
|
|
1690
|
-
description: '
|
|
1712
|
+
description: 'Declare a structural doc-to-doc connection. Writes target_doc_id into the source doc\'s `references:` frontmatter array. Body markdown is NEVER mutated — this is metadata, not prose. Idempotent: calling twice with the same source/target is a no-op. The inbound list on the target is computed live from the inverse of every doc\'s references — no stored derived field. v0.20.0 breaking change: dropped `text`, `target_node_id`, and `quote` parameters; connections are structural, not anchored to prose. Legacy prose `doc:` links continue to render and auto-populate `references` on save (backward compat).',
|
|
1691
1713
|
schema: {
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
|
|
1695
|
-
target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
|
|
1696
|
-
quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
|
|
1714
|
+
source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc declaring the connection.'),
|
|
1715
|
+
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the connection points AT.'),
|
|
1697
1716
|
},
|
|
1698
|
-
handler: async ({
|
|
1717
|
+
handler: async ({ source_doc_id, target_doc_id }) => {
|
|
1699
1718
|
const sourceFilename = resolveDocId(source_doc_id);
|
|
1700
1719
|
if (!sourceFilename) {
|
|
1701
1720
|
return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
|
|
1702
1721
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
// Load
|
|
1711
|
-
// otherwise. Explicit source dispatch prevents the active-doc race where
|
|
1712
|
-
// a user click in the browser silently changes which doc gets edited.
|
|
1722
|
+
const targetFilename = resolveDocId(target_doc_id);
|
|
1723
|
+
if (!targetFilename) {
|
|
1724
|
+
return { content: [{ type: 'text', text: `target_doc_id "${target_doc_id}" not found. Use list_documents or search_docs to find the right docId.` }] };
|
|
1725
|
+
}
|
|
1726
|
+
if (source_doc_id === target_doc_id) {
|
|
1727
|
+
return { content: [{ type: 'text', text: `Cannot link a document to itself (source_doc_id and target_doc_id are both "${source_doc_id}").` }] };
|
|
1728
|
+
}
|
|
1729
|
+
// Load source's current references (active doc → in-memory; otherwise → disk).
|
|
1713
1730
|
const sourceIsActive = sourceFilename === getActiveFilename();
|
|
1714
|
-
let
|
|
1731
|
+
let currentReferences;
|
|
1715
1732
|
if (sourceIsActive) {
|
|
1716
|
-
|
|
1733
|
+
const meta = getMetadata();
|
|
1734
|
+
currentReferences = Array.isArray(meta?.references) ? [...meta.references] : [];
|
|
1717
1735
|
}
|
|
1718
1736
|
else {
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
sourceDoc = cached.document;
|
|
1722
|
-
}
|
|
1723
|
-
else {
|
|
1724
|
-
try {
|
|
1725
|
-
const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
|
|
1726
|
-
sourceDoc = markdownToTiptap(raw).document;
|
|
1727
|
-
}
|
|
1728
|
-
catch (err) {
|
|
1729
|
-
return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1737
|
+
const fm = readFrontmatter(sourceFilename);
|
|
1738
|
+
currentReferences = Array.isArray(fm?.data?.references) ? [...fm.data.references] : [];
|
|
1732
1739
|
}
|
|
1733
|
-
//
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
for (const child of nodeContent) {
|
|
1743
|
-
if (child.type !== 'text' || !child.text)
|
|
1744
|
-
continue;
|
|
1745
|
-
const marks = child.marks || [];
|
|
1746
|
-
const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
|
|
1747
|
-
if (hasMatchingLink)
|
|
1748
|
-
linkedText += child.text;
|
|
1749
|
-
}
|
|
1750
|
-
return linkedText.includes(text);
|
|
1740
|
+
// Idempotent: target already declared → no-op.
|
|
1741
|
+
if (currentReferences.includes(target_doc_id)) {
|
|
1742
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1743
|
+
success: true,
|
|
1744
|
+
sourceDocId: source_doc_id,
|
|
1745
|
+
targetDocId: target_doc_id,
|
|
1746
|
+
alreadyReferenced: true,
|
|
1747
|
+
references: currentReferences,
|
|
1748
|
+
}) }] };
|
|
1751
1749
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
if (node.attrs?.id && blockText.includes(text)) {
|
|
1761
|
-
totalOccurrences++;
|
|
1762
|
-
if (isTextAlreadyLinked(node.content)) {
|
|
1763
|
-
alreadyLinkedOccurrences++;
|
|
1764
|
-
walk(node.content);
|
|
1765
|
-
continue;
|
|
1766
|
-
}
|
|
1767
|
-
sourceNodeId = node.attrs.id;
|
|
1768
|
-
return;
|
|
1769
|
-
}
|
|
1770
|
-
walk(node.content);
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1750
|
+
// Append + dedup via Set round-trip.
|
|
1751
|
+
const newReferences = Array.from(new Set([...currentReferences, target_doc_id]));
|
|
1752
|
+
if (sourceIsActive) {
|
|
1753
|
+
// Active doc: mutate state.metadata and let save() persist the frontmatter.
|
|
1754
|
+
// save()'s writeToDisk path invalidates the backlinks cache.
|
|
1755
|
+
setMetadata({ references: newReferences });
|
|
1756
|
+
save();
|
|
1757
|
+
broadcastMetadataChanged(getMetadata());
|
|
1773
1758
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1759
|
+
else {
|
|
1760
|
+
// Non-active doc: write frontmatter directly, preserving body verbatim.
|
|
1761
|
+
const fm = readFrontmatter(sourceFilename);
|
|
1762
|
+
if (!fm) {
|
|
1763
|
+
return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}" frontmatter.` }] };
|
|
1778
1764
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
// happens to be foregrounded in the browser.
|
|
1784
|
-
const editResult = sourceIsActive
|
|
1785
|
-
? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
|
|
1786
|
-
: applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
|
|
1787
|
-
if (!editResult.success) {
|
|
1788
|
-
return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
|
|
1765
|
+
const merged = { ...fm.data, references: newReferences };
|
|
1766
|
+
writeFrontmatter(sourceFilename, merged);
|
|
1767
|
+
invalidateDocCache(resolveDocPath(sourceFilename));
|
|
1768
|
+
invalidateBacklinksCache();
|
|
1789
1769
|
}
|
|
1790
|
-
if (sourceIsActive)
|
|
1791
|
-
save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
|
|
1792
1770
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1793
1771
|
success: true,
|
|
1794
1772
|
sourceDocId: source_doc_id,
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
href,
|
|
1798
|
-
...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
|
|
1773
|
+
targetDocId: target_doc_id,
|
|
1774
|
+
references: newReferences,
|
|
1799
1775
|
}) }] };
|
|
1800
1776
|
},
|
|
1801
1777
|
},
|
|
@@ -1847,7 +1823,7 @@ export const TOOL_REGISTRY = [
|
|
|
1847
1823
|
},
|
|
1848
1824
|
{
|
|
1849
1825
|
name: 'get_graph',
|
|
1850
|
-
description: 'Return forward
|
|
1826
|
+
description: 'Return forward + inbound connections for a doc — the crawl primitive for cross-doc context retrieval. Forward connections read from the doc\'s `references:` frontmatter (structural data, v0.20). Inbound computed live by scanning every doc\'s references for entries pointing at this one (no stored derived field). Optional depth walks neighbors recursively (cap 3). v0.20.0 breaking change: edges are doc-to-doc; per-paragraph granularity (from_node/to_node/anchor text) was dropped — that was an artifact of the prose-link model.',
|
|
1851
1827
|
schema: {
|
|
1852
1828
|
docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
|
|
1853
1829
|
depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
|
|
@@ -1867,28 +1843,18 @@ export const TOOL_REGISTRY = [
|
|
|
1867
1843
|
catch {
|
|
1868
1844
|
return;
|
|
1869
1845
|
}
|
|
1870
|
-
const forward =
|
|
1871
|
-
const backlinks =
|
|
1846
|
+
const forward = Array.isArray(target.metadata.references) ? target.metadata.references : [];
|
|
1847
|
+
const backlinks = computeBacklinksFor(id);
|
|
1872
1848
|
nodes.push({
|
|
1873
1849
|
docId: id,
|
|
1874
1850
|
title: target.title,
|
|
1875
|
-
forward: forward.map((
|
|
1876
|
-
|
|
1877
|
-
from_node: l.from_node,
|
|
1878
|
-
to_doc: l.to_doc,
|
|
1879
|
-
...(l.to_node ? { to_node: l.to_node } : {}),
|
|
1880
|
-
})),
|
|
1881
|
-
backlinks: backlinks.map((b) => ({
|
|
1882
|
-
text: b.text,
|
|
1883
|
-
from_doc: b.from_doc,
|
|
1884
|
-
from_node: b.from_node,
|
|
1885
|
-
...(b.to_node ? { to_node: b.to_node } : {}),
|
|
1886
|
-
})),
|
|
1851
|
+
forward: forward.map((to_doc) => ({ to_doc })),
|
|
1852
|
+
backlinks: backlinks.map((b) => ({ from_doc: b.from_doc })),
|
|
1887
1853
|
});
|
|
1888
1854
|
if (hopsLeft > 0) {
|
|
1889
1855
|
const neighbors = new Set();
|
|
1890
|
-
for (const
|
|
1891
|
-
neighbors.add(
|
|
1856
|
+
for (const to_doc of forward)
|
|
1857
|
+
neighbors.add(to_doc);
|
|
1892
1858
|
for (const b of backlinks)
|
|
1893
1859
|
neighbors.add(b.from_doc);
|
|
1894
1860
|
for (const n of neighbors) {
|
package/dist/server/state.js
CHANGED
|
@@ -10,7 +10,7 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
|
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
|
-
import {
|
|
13
|
+
import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
|
|
14
14
|
import { isAutoAcceptInheritedForDoc } from './workspaces.js';
|
|
15
15
|
import { matchNodes } from './node-matcher.js';
|
|
16
16
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
@@ -434,6 +434,15 @@ function stripLegacyAgentCreated(metadata) {
|
|
|
434
434
|
if (metadata && 'agentCreated' in metadata)
|
|
435
435
|
delete metadata.agentCreated;
|
|
436
436
|
}
|
|
437
|
+
/** Strip the legacy `backlinks` field. v0.19 stored derived inbound edges in
|
|
438
|
+
* frontmatter; v0.20 computes them live from the inverse of `references`
|
|
439
|
+
* across the workspace. Any save that visits a doc with the stale field drops
|
|
440
|
+
* it — lazy migration. No data loss because the new `references` field on
|
|
441
|
+
* source docs is the authoritative inbound source; we can always recompute. */
|
|
442
|
+
function stripLegacyBacklinks(metadata) {
|
|
443
|
+
if (metadata && 'backlinks' in metadata)
|
|
444
|
+
delete metadata.backlinks;
|
|
445
|
+
}
|
|
437
446
|
function persistExternalDocs() {
|
|
438
447
|
try {
|
|
439
448
|
atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
|
|
@@ -1990,16 +1999,6 @@ function writeToDisk() {
|
|
|
1990
1999
|
return;
|
|
1991
2000
|
}
|
|
1992
2001
|
ensureDataDir();
|
|
1993
|
-
// Capture old forward links BEFORE we overwrite the file — needed by the
|
|
1994
|
-
// backlinks engine to know which target docs to refresh when source changes.
|
|
1995
|
-
// Skip for external docs (they don't participate in the doc graph).
|
|
1996
|
-
let oldForwardLinks = [];
|
|
1997
|
-
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
1998
|
-
try {
|
|
1999
|
-
oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
|
|
2000
|
-
}
|
|
2001
|
-
catch { /* best-effort */ }
|
|
2002
|
-
}
|
|
2003
2002
|
// Stub graduation: once the doc contains accepted content, it's no longer
|
|
2004
2003
|
// a fresh stub. Remove it from the in-memory stub registry so reject-all
|
|
2005
2004
|
// can never trigger the cleanup-delete on it.
|
|
@@ -2009,8 +2008,10 @@ function writeToDisk() {
|
|
|
2009
2008
|
}
|
|
2010
2009
|
// Defensive: never serialize `agentCreated` to disk. The field is dead;
|
|
2011
2010
|
// any code reading it would be the bug, not the field's presence.
|
|
2012
|
-
if (state.metadata)
|
|
2011
|
+
if (state.metadata) {
|
|
2013
2012
|
stripLegacyAgentCreated(state.metadata);
|
|
2013
|
+
stripLegacyBacklinks(state.metadata);
|
|
2014
|
+
}
|
|
2014
2015
|
let markdown;
|
|
2015
2016
|
if (isExternalDoc(state.filePath)) {
|
|
2016
2017
|
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
|
|
@@ -2204,16 +2205,27 @@ function writeToDisk() {
|
|
|
2204
2205
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
2205
2206
|
}
|
|
2206
2207
|
catch { /* ignore */ }
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2208
|
+
// Auto-sync references from prose: legacy `doc:` prose links still render
|
|
2209
|
+
// (PadLink extension), but the graph/crawl/backlinks-panel read the
|
|
2210
|
+
// structural `references:` field. After every save, scan the body for
|
|
2211
|
+
// prose links and merge their targets into references — backward compat
|
|
2212
|
+
// without forcing rewrites. Then invalidate the live-backlinks cache so
|
|
2213
|
+
// the next /api/backlinks/:docId call sees the fresh inverse.
|
|
2214
|
+
// Best-effort — never blocks the save it follows.
|
|
2209
2215
|
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
2210
2216
|
try {
|
|
2211
|
-
const
|
|
2212
|
-
|
|
2217
|
+
const sync = syncReferencesFromProse(state.docId, state.document, state.metadata || {});
|
|
2218
|
+
if (sync && state.metadata) {
|
|
2219
|
+
state.metadata.references = sync.newReferences;
|
|
2220
|
+
// Second tiny write: re-persist frontmatter only (body already on disk).
|
|
2221
|
+
const filename = state.filePath.split(/[/\\]/).pop() || '';
|
|
2222
|
+
writeFrontmatter(filename, state.metadata);
|
|
2223
|
+
}
|
|
2213
2224
|
}
|
|
2214
2225
|
catch (err) {
|
|
2215
|
-
console.error('[State]
|
|
2226
|
+
console.error('[State] references auto-sync failed:', err);
|
|
2216
2227
|
}
|
|
2228
|
+
invalidateBacklinksCache();
|
|
2217
2229
|
}
|
|
2218
2230
|
}
|
|
2219
2231
|
export function save() {
|
|
@@ -2756,6 +2768,28 @@ export function populateDocumentFile(filename, doc) {
|
|
|
2756
2768
|
if (!isAutoAcceptActive(filename, parsed.metadata)) {
|
|
2757
2769
|
markAllNodesAsPending(doc, 'insert');
|
|
2758
2770
|
}
|
|
2771
|
+
// Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
|
|
2772
|
+
// flushDocToFile writes `doc` directly — it does NOT merge with the existing
|
|
2773
|
+
// parsed.document on disk. Without this merge step, the stub's auto-generated
|
|
2774
|
+
// trailing paragraph falls out of canonical, the matcher's `previousNodes`
|
|
2775
|
+
// for any subsequent save no longer references it, and a follow-up Accept All
|
|
2776
|
+
// doc-update can find itself with no matching previousNodes to anchor against.
|
|
2777
|
+
// Cascading: the matcher classifies the newly accepted inserts as deletions
|
|
2778
|
+
// (orphaned from the empty previousNodes set), they go to graveyard, the disk
|
|
2779
|
+
// body ends up empty.
|
|
2780
|
+
// Mirrors the active-doc fix in mcp.ts:populate_document.
|
|
2781
|
+
if (parsed.document?.content?.length) {
|
|
2782
|
+
const incomingIds = new Set(doc.content
|
|
2783
|
+
.map((n) => n?.attrs?.id)
|
|
2784
|
+
.filter((id) => typeof id === 'string'));
|
|
2785
|
+
const preserved = parsed.document.content.filter((n) => {
|
|
2786
|
+
const id = n?.attrs?.id;
|
|
2787
|
+
return id && !incomingIds.has(id);
|
|
2788
|
+
});
|
|
2789
|
+
if (preserved.length > 0) {
|
|
2790
|
+
doc.content = [...doc.content, ...preserved];
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2759
2793
|
flushDocToFile(filename, doc, parsed.title, parsed.metadata);
|
|
2760
2794
|
const pendingCount = countPending(doc.content);
|
|
2761
2795
|
const text = extractText(doc.content);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|