openwriter 0.19.0 → 0.20.1
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/skill/SKILL.md +29 -12
- package/skill/agents/openwriter-enrichment-minion.md +46 -82
- package/skill/docs/enrichment.md +30 -29
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.1",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.10.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -31,12 +31,12 @@ 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:
|
|
34
|
+
5. **Dispatch the enrichment minion when openwriter says so.** OpenWriter surfaces stale enrichment two ways: (a) `ENRICHMENT_STATUS: ...` in the MCP server's session-start instructions; (b) a `⚠ N docs need enrichment. Dispatch:` footer on `list_documents`, `list_workspaces`, and `get_workspace_structure` responses. Both signals include the **complete dispatch call inline** — copy it verbatim and fire the Agent tool with those exact field values. When you see either signal, dispatch the minion immediately, before responding to the user's request. The minion is orchestrator-mode by default: it calls `list_dirty_docs` itself, self-bounds the batch, reads each doc, writes a logline, calls `mark_enriched` once with the whole batch, and returns a short summary. **v0.19.0 schema:** the minion writes ONE field — `logline`. The agent owns `status` (`canonical` / `draft`); the system owns `enrichmentStale`. The legacy fields `domain`, `concepts`, and `docRole` were dropped. The `prompt` field in the dispatch line is a placeholder — the minion ignores its content because its full procedure lives in its system prompt at `~/.claude/agents/openwriter-enrichment-minion.md`.
|
|
35
35
|
|
|
36
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
37
|
|
|
38
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
|
|
39
|
+
- **First time in a session, medium batch (5 < N ≤ 20):** brief explanation on first surface: "OpenWriter just refreshed loglines on 12 docs in the background. Now, ..." Sets expectations once; subsequent runs can stay silent.
|
|
40
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
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
42
|
|
|
@@ -150,8 +150,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
150
150
|
| `list_workspaces` | List all workspaces with title and doc count |
|
|
151
151
|
| `create_workspace` | Create a new workspace |
|
|
152
152
|
| `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
|
|
153
|
-
| `get_workspace_structure` | Get full workspace tree: containers, docs, enrichment (logline
|
|
154
|
-
| `get_item_context` | Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline,
|
|
153
|
+
| `get_workspace_structure` | Get full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), 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, status, enrichmentStale) |
|
|
155
155
|
| `update_workspace_context` | Update workspace context (characters, settings, rules) |
|
|
156
156
|
|
|
157
157
|
### Workspace Organization
|
|
@@ -165,15 +165,29 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
165
165
|
| `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
|
|
166
166
|
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
167
167
|
|
|
168
|
-
### Enrichment (
|
|
168
|
+
### Enrichment (three-field schema — v0.19.0)
|
|
169
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
|
|
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 logline.
|
|
171
|
+
|
|
172
|
+
**The three-field schema** — each field has exactly one owner:
|
|
173
|
+
|
|
174
|
+
| Field | Owner | Set how |
|
|
175
|
+
|-------|-------|---------|
|
|
176
|
+
| `logline` | LLM (minion) | `mark_enriched({ docs: [{ docId, logline }] })` |
|
|
177
|
+
| `status` (`canonical` / `draft`) | Agent | `create_document({ status })` on create; `set_metadata({ status })` on lifecycle change |
|
|
178
|
+
| `enrichmentStale` | System | OpenWriter sets on save; minion clears on `mark_enriched` |
|
|
179
|
+
|
|
180
|
+
**Lifecycle convention for `status`:**
|
|
181
|
+
- Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
|
|
182
|
+
- Flip to `canonical` when the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth).
|
|
183
|
+
- Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
|
|
184
|
+
- The common crawl pattern is `crawl({ status: "canonical" })` — that's the trusted-shelf query.
|
|
171
185
|
|
|
172
186
|
| Tool | Key Params | Description |
|
|
173
187
|
|------|-----------|-------------|
|
|
174
188
|
| `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
|
|
176
|
-
| `crawl` | `workspaceFile?`, `
|
|
189
|
+
| `mark_enriched` | `docs: [{docId, logline}]` | Stamp one or more docs as freshly enriched. **Strict schema** — passing `domain` / `concepts` / `docRole` / `status` fails validation. OpenWriter auto-computes baselines (`lastEnrichedAt`, `lastEnrichedCharCount`, `lastEnrichedSentences`), clears `enrichmentStale`, and retires legacy fields from frontmatter. The minion calls this once at the end of its run with the full batch. |
|
|
190
|
+
| `crawl` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies. v0.19.0 dropped `domain` / `concepts` / `docRole` filters (their fields had no authority discipline); `status` is the replacement axis for the common load-bearing-vs-working query. |
|
|
177
191
|
|
|
178
192
|
### Comments
|
|
179
193
|
|
|
@@ -275,9 +289,12 @@ create_document({
|
|
|
275
289
|
|
|
276
290
|
- **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
|
|
277
291
|
- **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
|
|
278
|
-
-
|
|
292
|
+
- **`afterId`** (string, optional) — docId (8-char hex) or containerId to place the new doc immediately after. Omit and the doc lands at the **bottom** of its parent (the default since 0.18.0, matching the ascending-order convention: oldest at top, newest at bottom). Use `afterId` when you need surgical placement — e.g. inserting a new chapter doc immediately after the chapter's Beats doc.
|
|
293
|
+
- All three are optional — omit `workspace` for standalone docs outside any workspace.
|
|
294
|
+
|
|
295
|
+
This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace. The default-bottom landing also eliminates the need for a follow-up `move_item` pass to fix sidebar order after every create — the doc lands in convention position the first time.
|
|
279
296
|
|
|
280
|
-
|
|
297
|
+
`create_container` accepts the same `afterId` parameter with identical semantics — new containers default to the bottom of their parent and can be precisely placed via `afterId`. The Drafts sub-container that goes under every chapter container, for example, can be created with `afterId` set to the chapter's Research Notes docId so it lands at the very bottom in one call.
|
|
281
298
|
|
|
282
299
|
### Batched Creation (multiple docs at once)
|
|
283
300
|
|
|
@@ -299,7 +316,7 @@ When creating **two or more documents together** — a tweet thread saved as sep
|
|
|
299
316
|
**Rules:**
|
|
300
317
|
- Each write in the batch gets its own sidebar spinner keyed to its filename — a spinner only clears when you `populate_document` that specific `docId`
|
|
301
318
|
- Spinners persist across app refreshes (server-side registry)
|
|
302
|
-
- Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`
|
|
319
|
+
- Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`/`afterId`
|
|
303
320
|
- `reply` / `quote` types still require `url`
|
|
304
321
|
- For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
|
|
305
322
|
|