openwriter 0.25.0 → 0.27.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-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +31 -11
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +251 -86
- package/dist/server/peek-outline.js +87 -17
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +60 -22
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
package/dist/server/state.js
CHANGED
|
@@ -16,7 +16,8 @@ import { matchNodes } from './node-matcher.js';
|
|
|
16
16
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
17
17
|
import { anyLegacyRaw } from './node-fingerprint.js';
|
|
18
18
|
import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
|
|
19
|
-
import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
19
|
+
import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, loadDocFromDisk, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
20
|
+
import { loadPendingMetadata, savePendingMetadata } from './pending-metadata.js';
|
|
20
21
|
import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
|
|
21
22
|
import { clearActivityBuffer } from './activity-log.js';
|
|
22
23
|
import { titleFromDoc, shouldAutoTitle } from './title-from-body.js';
|
|
@@ -84,6 +85,7 @@ const DEFAULT_DOC = {
|
|
|
84
85
|
let state = {
|
|
85
86
|
canonical: DEFAULT_DOC,
|
|
86
87
|
overlay: new Map(),
|
|
88
|
+
pendingMetadata: null,
|
|
87
89
|
document: DEFAULT_DOC,
|
|
88
90
|
title: 'Untitled',
|
|
89
91
|
metadata: { title: 'Untitled' },
|
|
@@ -543,6 +545,21 @@ export function getOverlay() {
|
|
|
543
545
|
export function getTitle() {
|
|
544
546
|
return state.title;
|
|
545
547
|
}
|
|
548
|
+
/** Snapshot of the active doc's pending metadata (title, etc.). Null if no
|
|
549
|
+
* metadata is staged. The sidebar / title bar / ReviewTab consult this to
|
|
550
|
+
* decide whether to render a metadata-pending decoration.
|
|
551
|
+
* adr: adr/pending-overlay-model.md */
|
|
552
|
+
export function getPendingMetadata() {
|
|
553
|
+
return state.pendingMetadata ? { ...state.pendingMetadata } : null;
|
|
554
|
+
}
|
|
555
|
+
/** Replace the active doc's pending metadata. Persists to the sidecar so the
|
|
556
|
+
* proposal survives a doc switch or restart. Pass null to clear. */
|
|
557
|
+
export function setPendingMetadata(meta) {
|
|
558
|
+
state.pendingMetadata = meta && Object.keys(meta).length > 0 ? meta : null;
|
|
559
|
+
if (state.docId) {
|
|
560
|
+
savePendingMetadata(state.docId, state.pendingMetadata);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
546
563
|
export function getFilePath() {
|
|
547
564
|
return state.filePath;
|
|
548
565
|
}
|
|
@@ -775,6 +792,13 @@ export function setMetadata(updates) {
|
|
|
775
792
|
state.metadata = merged;
|
|
776
793
|
if (updates.title)
|
|
777
794
|
state.title = updates.title;
|
|
795
|
+
// Same contract as updateDocument: any path that mutates persistent state
|
|
796
|
+
// MUST bump docVersion, otherwise writeToDisk's no-op gate
|
|
797
|
+
// (docVersion === lastSavedDocVersion) silently drops the write and the
|
|
798
|
+
// mutation never reaches disk. Metadata-only changes (mark-sent tweetUrl,
|
|
799
|
+
// schedule edits, autoAccept toggle) all flow through here.
|
|
800
|
+
// adr: adr/pending-overlay-model.md
|
|
801
|
+
bumpDocVersion();
|
|
778
802
|
// Auto-tag based on context metadata
|
|
779
803
|
const filename = state.filePath
|
|
780
804
|
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
@@ -1026,11 +1050,19 @@ export function cancelDebouncedSave() {
|
|
|
1026
1050
|
}
|
|
1027
1051
|
}
|
|
1028
1052
|
export function applyChanges(changes) {
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
//
|
|
1053
|
+
// Bump version BEFORE applying so new overlay entries created by
|
|
1054
|
+
// applyChangesToDocument's setPrimaryFromMerged → setOverlayFromEntries
|
|
1055
|
+
// pass are stamped with the post-bump version (the version we're about
|
|
1056
|
+
// to broadcast), not the pre-bump version. Without this, a stale
|
|
1057
|
+
// browser doc-update arriving with browserVersion == preBump satisfies
|
|
1058
|
+
// syncBrowserDocUpdate's `addedAtVersion > browserVersion` filter as
|
|
1059
|
+
// FALSE for entries just added by this call — preservedServerEntries
|
|
1060
|
+
// becomes 0, the overlay is wiped, and the agent's write silently
|
|
1061
|
+
// vanishes despite a success response. adr: adr/pending-overlay-model.md
|
|
1032
1062
|
const version = bumpDocVersion();
|
|
1033
1063
|
setAgentLockActive();
|
|
1064
|
+
// Apply to server-side document (source of truth)
|
|
1065
|
+
const processed = applyChangesToDocument(changes);
|
|
1034
1066
|
// Broadcast processed changes (with server-assigned IDs + version) to browser clients
|
|
1035
1067
|
for (const listener of listeners) {
|
|
1036
1068
|
listener(processed, version);
|
|
@@ -1466,6 +1498,9 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
|
|
|
1466
1498
|
// Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
|
|
1467
1499
|
// (sidecar present, legacy migration, no pending).
|
|
1468
1500
|
mergeOverlayOnLoad();
|
|
1501
|
+
// Pending metadata rehydration. Read the sidecar's `metadata:` slot so a
|
|
1502
|
+
// staged title rename survives doc-switch and restart. adr: adr/pending-overlay-model.md
|
|
1503
|
+
state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
|
|
1469
1504
|
// Subscribe the fs watcher to this doc so external writes (Edit tool,
|
|
1470
1505
|
// VSCode, scripts) trigger a unified reload + version bump + broadcast.
|
|
1471
1506
|
// adr: adr/active-doc-watcher.md
|
|
@@ -1649,6 +1684,7 @@ export function clearAllCaches() {
|
|
|
1649
1684
|
state = {
|
|
1650
1685
|
canonical: DEFAULT_DOC,
|
|
1651
1686
|
overlay: new Map(),
|
|
1687
|
+
pendingMetadata: null,
|
|
1652
1688
|
document: DEFAULT_DOC,
|
|
1653
1689
|
title: 'Untitled',
|
|
1654
1690
|
metadata: { title: 'Untitled' },
|
|
@@ -1750,6 +1786,12 @@ export function reloadActiveDocFromDisk() {
|
|
|
1750
1786
|
// sidecar (or legacy migration). Recompute merged via the helper.
|
|
1751
1787
|
state.canonical = canonical;
|
|
1752
1788
|
setOverlayFromEntries(entries);
|
|
1789
|
+
// Pending metadata rehydration — mirror what setActiveDocument does.
|
|
1790
|
+
// External-write reloads must keep state.pendingMetadata aligned with the
|
|
1791
|
+
// sidecar's metadata: slot, otherwise a staged title rename gets dropped
|
|
1792
|
+
// from in-memory state the moment fs.watch fires.
|
|
1793
|
+
// adr: adr/pending-overlay-model.md
|
|
1794
|
+
state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
|
|
1753
1795
|
// External writes change the body but leave disk frontmatter pointing at
|
|
1754
1796
|
// the previous save's fingerprints. If the user cuts/deletes a block before
|
|
1755
1797
|
// the next browser-driven save, the matcher graveyards with that stale
|
|
@@ -2355,6 +2397,12 @@ export function load() {
|
|
|
2355
2397
|
// source.
|
|
2356
2398
|
// adr: adr/pending-overlay-model.md
|
|
2357
2399
|
mergeOverlayOnLoad();
|
|
2400
|
+
// Pending metadata rehydration. Boot path bypasses setActiveDocument,
|
|
2401
|
+
// so the per-doc sidecar's `metadata:` slot must be loaded here too.
|
|
2402
|
+
// Without this, a server restart drops staged title renames from
|
|
2403
|
+
// in-memory state until the user switches docs.
|
|
2404
|
+
// adr: adr/pending-overlay-model.md
|
|
2405
|
+
state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
|
|
2358
2406
|
break;
|
|
2359
2407
|
}
|
|
2360
2408
|
catch {
|
|
@@ -2545,11 +2593,25 @@ export function addDocTag(filename, tag) {
|
|
|
2545
2593
|
if (!tags.includes(tag)) {
|
|
2546
2594
|
tags.push(tag);
|
|
2547
2595
|
state.metadata.tags = tags;
|
|
2548
|
-
//
|
|
2596
|
+
// Same docVersion contract as setMetadata: tag changes mutate
|
|
2597
|
+
// state.metadata, so they must bump or writeToDisk's no-op gate
|
|
2598
|
+
// silently drops the write.
|
|
2599
|
+
bumpDocVersion();
|
|
2600
|
+
// Preserve mtime — tag changes shouldn't affect sidebar sort order.
|
|
2601
|
+
// After rolling back disk mtime, we MUST also roll back state.loadedMtime
|
|
2602
|
+
// (which writeToDisk just stamped to the post-write disk mtime).
|
|
2603
|
+
// The fs.watch self-suppression contract is `diskMtime === loadedMtime`;
|
|
2604
|
+
// a divergence here will fire a phantom "external write detected"
|
|
2605
|
+
// reload banner when the watcher's debounced handler runs.
|
|
2549
2606
|
const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
|
|
2550
2607
|
save();
|
|
2551
|
-
if (mtime && state.filePath)
|
|
2608
|
+
if (mtime && state.filePath) {
|
|
2552
2609
|
safeRestoreMtime(state.filePath, mtime);
|
|
2610
|
+
try {
|
|
2611
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2612
|
+
}
|
|
2613
|
+
catch { /* best-effort */ }
|
|
2614
|
+
}
|
|
2553
2615
|
}
|
|
2554
2616
|
}
|
|
2555
2617
|
else {
|
|
@@ -2585,11 +2647,19 @@ export function removeDocTag(filename, tag) {
|
|
|
2585
2647
|
if (idx >= 0) {
|
|
2586
2648
|
tags.splice(idx, 1);
|
|
2587
2649
|
state.metadata.tags = tags.length > 0 ? tags : undefined;
|
|
2588
|
-
//
|
|
2650
|
+
// Same docVersion contract as addDocTag — mutation must bump or
|
|
2651
|
+
// writeToDisk's no-op gate silently drops the write.
|
|
2652
|
+
bumpDocVersion();
|
|
2653
|
+
// Same loadedMtime re-stamp as addDocTag — see comment there.
|
|
2589
2654
|
const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
|
|
2590
2655
|
save();
|
|
2591
|
-
if (mtime && state.filePath)
|
|
2656
|
+
if (mtime && state.filePath) {
|
|
2592
2657
|
safeRestoreMtime(state.filePath, mtime);
|
|
2658
|
+
try {
|
|
2659
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2660
|
+
}
|
|
2661
|
+
catch { /* best-effort */ }
|
|
2662
|
+
}
|
|
2593
2663
|
}
|
|
2594
2664
|
}
|
|
2595
2665
|
else {
|
|
@@ -2897,41 +2967,50 @@ function flushDocToFile(filename, doc, title, metadata) {
|
|
|
2897
2967
|
invalidateBacklinksCache();
|
|
2898
2968
|
}
|
|
2899
2969
|
export function populateDocumentFile(filename, doc) {
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2970
|
+
// Read the existing doc as the user-visible MERGED view — canonical body
|
|
2971
|
+
// plus any pre-existing sidecar overlay. Using the bare markdownToTiptap
|
|
2972
|
+
// here would silently drop pre-existing pending entries (the file's
|
|
2973
|
+
// sidecar lives outside the .md body), so a re-populate or a populate on
|
|
2974
|
+
// a doc with prior pending state would clobber the overlay on the
|
|
2975
|
+
// subsequent flushDocToFile. adr: adr/pending-overlay-model.md
|
|
2976
|
+
const loaded = loadDocFromDisk(filename);
|
|
2903
2977
|
// Skip pending tagging when the target doc effectively has autoAccept on —
|
|
2904
2978
|
// content commits directly as accepted.
|
|
2905
|
-
if (!isAutoAcceptActive(filename,
|
|
2979
|
+
if (!isAutoAcceptActive(filename, loaded.metadata)) {
|
|
2906
2980
|
markAllNodesAsPending(doc, 'insert');
|
|
2907
2981
|
}
|
|
2908
|
-
//
|
|
2909
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
//
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
|
|
2982
|
+
// Preserve any pre-existing real content (pending nodes from a prior
|
|
2983
|
+
// populate or write) that isn't in the incoming set — so a re-populate or
|
|
2984
|
+
// populate-on-top doesn't clobber prior agent proposals. flushDocToFile
|
|
2985
|
+
// writes `doc` directly and extracts its overlay wholesale, so what isn't
|
|
2986
|
+
// in `doc.content` after this step disappears from the next save.
|
|
2987
|
+
//
|
|
2988
|
+
// Empty paragraphs are explicitly NOT preserved. createDocumentFile mints
|
|
2989
|
+
// a trailing empty paragraph as a TipTap "doc must have at least one
|
|
2990
|
+
// node" stub; that stub is obsolete the moment populate provides real
|
|
2991
|
+
// content, and preserving it leaves a phantom empty paragraph at the end
|
|
2992
|
+
// of every populated doc forever. Mirrors the active-doc preserve in
|
|
2993
|
+
// mcp.ts:populate_document. adr: adr/pending-overlay-model.md
|
|
2994
|
+
if (loaded.document?.content?.length) {
|
|
2919
2995
|
const incomingIds = new Set(doc.content
|
|
2920
2996
|
.map((n) => n?.attrs?.id)
|
|
2921
2997
|
.filter((id) => typeof id === 'string'));
|
|
2922
|
-
const preserved =
|
|
2998
|
+
const preserved = loaded.document.content.filter((n) => {
|
|
2923
2999
|
const id = n?.attrs?.id;
|
|
2924
|
-
|
|
3000
|
+
if (!id || incomingIds.has(id))
|
|
3001
|
+
return false;
|
|
3002
|
+
const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
|
|
3003
|
+
return !isEmptyParagraph;
|
|
2925
3004
|
});
|
|
2926
3005
|
if (preserved.length > 0) {
|
|
2927
3006
|
doc.content = [...doc.content, ...preserved];
|
|
2928
3007
|
}
|
|
2929
3008
|
}
|
|
2930
|
-
flushDocToFile(filename, doc,
|
|
3009
|
+
flushDocToFile(filename, doc, loaded.title, loaded.metadata);
|
|
2931
3010
|
const pendingCount = countPending(doc.content);
|
|
2932
3011
|
const text = extractText(doc.content);
|
|
2933
3012
|
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
2934
|
-
return { title:
|
|
3013
|
+
return { title: loaded.title, wordCount, pendingCount };
|
|
2935
3014
|
}
|
|
2936
3015
|
/**
|
|
2937
3016
|
* Apply node changes to a non-active document file on disk.
|
|
@@ -2954,12 +3033,17 @@ export function applyChangesToFile(filename, changes) {
|
|
|
2954
3033
|
isTemp = cached.isTemp;
|
|
2955
3034
|
}
|
|
2956
3035
|
else {
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
doc
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
3036
|
+
// Cache miss — load the MERGED view (canonical + sidecar overlay). The
|
|
3037
|
+
// bare markdownToTiptap would give canonical-only, so pre-existing pending
|
|
3038
|
+
// entries would not be in `doc` when we apply the new changes. The
|
|
3039
|
+
// subsequent flushDocToFile then extracts the overlay from `doc` and
|
|
3040
|
+
// overwrites the sidecar — silently dropping every prior pending entry.
|
|
3041
|
+
// adr: adr/pending-overlay-model.md
|
|
3042
|
+
const loaded = loadDocFromDisk(filename);
|
|
3043
|
+
doc = loaded.document;
|
|
3044
|
+
title = loaded.title;
|
|
3045
|
+
metadata = loaded.metadata;
|
|
3046
|
+
docId = loaded.docId;
|
|
2963
3047
|
isTemp = false;
|
|
2964
3048
|
}
|
|
2965
3049
|
const autoAccept = isAutoAcceptActive(filename, metadata);
|
|
@@ -3003,12 +3087,15 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
|
|
|
3003
3087
|
isTemp = cached.isTemp;
|
|
3004
3088
|
}
|
|
3005
3089
|
else {
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3090
|
+
// Cache miss — load the merged view so a target node living in the
|
|
3091
|
+
// sidecar overlay (a pending insert the agent is now text-editing) is
|
|
3092
|
+
// findable. Bare markdownToTiptap would only show canonical, and the
|
|
3093
|
+
// findNode call below would fail. adr: adr/pending-overlay-model.md
|
|
3094
|
+
const loaded = loadDocFromDisk(filename);
|
|
3095
|
+
doc = loaded.document;
|
|
3096
|
+
title = loaded.title;
|
|
3097
|
+
metadata = loaded.metadata;
|
|
3098
|
+
docId = loaded.docId;
|
|
3012
3099
|
isTemp = false;
|
|
3013
3100
|
}
|
|
3014
3101
|
const found = findNode(doc.content, nodeId, doc.content);
|
package/dist/server/ws.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
5
|
import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
|
|
6
|
-
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments } from './documents.js';
|
|
6
|
+
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
import { canonicalizeIdentifier } from './helpers.js';
|
|
9
9
|
import { nodeTextPreview, diagLog } from './pending-overlay.js';
|
|
@@ -48,6 +48,29 @@ function debouncedBroadcastDocumentsChanged() {
|
|
|
48
48
|
broadcastDocumentsChanged();
|
|
49
49
|
}, 2100);
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a document-switched payload object that includes pendingMetadata
|
|
53
|
+
* (currently a staged title rename, if any) for the active doc. Every
|
|
54
|
+
* call site that emits a document-switched message — initial connect,
|
|
55
|
+
* request-document, tweet-thread HR resync, switchDocument result —
|
|
56
|
+
* must use this so the title-bar inline diff renders consistently
|
|
57
|
+
* regardless of which path delivered the doc state.
|
|
58
|
+
* adr: adr/pending-overlay-model.md
|
|
59
|
+
*/
|
|
60
|
+
function buildDocumentSwitchedPayload(document, title, filename, metadata) {
|
|
61
|
+
const docId = getDocId();
|
|
62
|
+
const pendingTitle = docId ? getPendingTitle(docId) : null;
|
|
63
|
+
const pendingMetadata = pendingTitle ? { title: { from: pendingTitle.from, to: pendingTitle.to } } : null;
|
|
64
|
+
return {
|
|
65
|
+
type: 'document-switched',
|
|
66
|
+
document,
|
|
67
|
+
title,
|
|
68
|
+
filename,
|
|
69
|
+
docId,
|
|
70
|
+
metadata,
|
|
71
|
+
pendingMetadata,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
51
74
|
export function setupWebSocket(server) {
|
|
52
75
|
const wss = new WebSocketServer({
|
|
53
76
|
server,
|
|
@@ -89,14 +112,7 @@ export function setupWebSocket(server) {
|
|
|
89
112
|
setAgentLockActive();
|
|
90
113
|
const filePath = getFilePath();
|
|
91
114
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
92
|
-
const msg = JSON.stringify(
|
|
93
|
-
type: 'document-switched',
|
|
94
|
-
document: getDocument(),
|
|
95
|
-
title: getTitle(),
|
|
96
|
-
filename,
|
|
97
|
-
docId: getDocId(),
|
|
98
|
-
metadata,
|
|
99
|
-
});
|
|
115
|
+
const msg = JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, metadata));
|
|
100
116
|
for (const ws of clients) {
|
|
101
117
|
if (ws.readyState === WebSocket.OPEN)
|
|
102
118
|
ws.send(msg);
|
|
@@ -210,14 +226,7 @@ export function setupWebSocket(server) {
|
|
|
210
226
|
const docOnConnect = getDocument();
|
|
211
227
|
const pendingOnConnect = pendingSummary(docOnConnect);
|
|
212
228
|
diagLog(`[WS] document-switched SEND on-connect docId=${getDocId()} v=${getDocVersion()} pending=[${pendingOnConnect}]`);
|
|
213
|
-
ws.send(JSON.stringify(
|
|
214
|
-
type: 'document-switched',
|
|
215
|
-
document: docOnConnect,
|
|
216
|
-
title: getTitle(),
|
|
217
|
-
filename,
|
|
218
|
-
docId: getDocId(),
|
|
219
|
-
metadata: getMetadata(),
|
|
220
|
-
}));
|
|
229
|
+
ws.send(JSON.stringify(buildDocumentSwitchedPayload(docOnConnect, getTitle(), filename, getMetadata())));
|
|
221
230
|
// Send pending docs info on connect
|
|
222
231
|
ws.send(JSON.stringify({
|
|
223
232
|
type: 'pending-docs-changed',
|
|
@@ -319,16 +328,21 @@ export function setupWebSocket(server) {
|
|
|
319
328
|
if (msg.type === 'request-document') {
|
|
320
329
|
const filePath = getFilePath();
|
|
321
330
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
322
|
-
ws.send(JSON.stringify(
|
|
323
|
-
type: 'document-switched',
|
|
324
|
-
document: getDocument(),
|
|
325
|
-
title: getTitle(),
|
|
326
|
-
filename,
|
|
327
|
-
docId: getDocId(),
|
|
328
|
-
metadata: getMetadata(),
|
|
329
|
-
}));
|
|
331
|
+
ws.send(JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, getMetadata())));
|
|
330
332
|
}
|
|
331
333
|
if (msg.type === 'title-update' && msg.title) {
|
|
334
|
+
// User typed directly in the title bar — this is "the user disposing."
|
|
335
|
+
// It always lands hot. If an agent had a pending title proposal, the
|
|
336
|
+
// user's direct edit supersedes it; clear the pending entry so the
|
|
337
|
+
// diff UI dismisses. adr: adr/pending-overlay-model.md
|
|
338
|
+
const activeDocId = getDocId();
|
|
339
|
+
if (activeDocId) {
|
|
340
|
+
const existing = getPendingTitle(activeDocId);
|
|
341
|
+
if (existing) {
|
|
342
|
+
rejectPendingTitle(activeDocId);
|
|
343
|
+
broadcastPendingMetadataChanged(activeDocId, null);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
332
346
|
setMetadata({ title: msg.title });
|
|
333
347
|
const promoted = promoteTempFile(msg.title);
|
|
334
348
|
if (promoted) {
|
|
@@ -341,6 +355,36 @@ export function setupWebSocket(server) {
|
|
|
341
355
|
debouncedBroadcastDocumentsChanged();
|
|
342
356
|
}
|
|
343
357
|
}
|
|
358
|
+
if (msg.type === 'accept-pending-title' && msg.docId) {
|
|
359
|
+
try {
|
|
360
|
+
const result = acceptPendingTitle(msg.docId);
|
|
361
|
+
broadcastPendingMetadataChanged(msg.docId, null);
|
|
362
|
+
if (result) {
|
|
363
|
+
// updateDocumentTitle (inside acceptPendingTitle) re-runs
|
|
364
|
+
// setActiveDocument when the doc is active, which clears state
|
|
365
|
+
// and rebroadcasts. We also explicitly fire title-changed +
|
|
366
|
+
// documents-changed to update sidebar + title bar.
|
|
367
|
+
if (result.filename === getActiveFilename()) {
|
|
368
|
+
broadcastTitleChanged(result.to);
|
|
369
|
+
}
|
|
370
|
+
broadcastDocumentsChanged();
|
|
371
|
+
broadcastPendingDocsChanged();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error('[WS] accept-pending-title failed:', err.message);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (msg.type === 'reject-pending-title' && msg.docId) {
|
|
379
|
+
try {
|
|
380
|
+
rejectPendingTitle(msg.docId);
|
|
381
|
+
broadcastPendingMetadataChanged(msg.docId, null);
|
|
382
|
+
broadcastPendingDocsChanged();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
console.error('[WS] reject-pending-title failed:', err.message);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
344
388
|
if (msg.type === 'save') {
|
|
345
389
|
save();
|
|
346
390
|
}
|
|
@@ -491,7 +535,7 @@ export function setupWebSocket(server) {
|
|
|
491
535
|
}
|
|
492
536
|
export function broadcastDocumentSwitched(document, title, filename, metadata) {
|
|
493
537
|
const resolvedMeta = metadata ?? getMetadata();
|
|
494
|
-
const msg = JSON.stringify(
|
|
538
|
+
const msg = JSON.stringify(buildDocumentSwitchedPayload(document, title, filename, resolvedMeta));
|
|
495
539
|
for (const ws of clients) {
|
|
496
540
|
if (ws.readyState === WebSocket.OPEN) {
|
|
497
541
|
ws.send(msg);
|
|
@@ -527,6 +571,21 @@ export function broadcastTitleChanged(title) {
|
|
|
527
571
|
ws.send(msg);
|
|
528
572
|
}
|
|
529
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* Broadcast a pending-metadata change for a specific docId. The client reads
|
|
576
|
+
* this and updates the title bar / sidebar to render the proposal-in-review
|
|
577
|
+
* decoration. Passing `pendingMetadata: null` signals "the proposal cleared"
|
|
578
|
+
* (either accepted, rejected, or matched canonical).
|
|
579
|
+
*
|
|
580
|
+
* adr: adr/pending-overlay-model.md
|
|
581
|
+
*/
|
|
582
|
+
export function broadcastPendingMetadataChanged(docId, pendingMetadata) {
|
|
583
|
+
const msg = JSON.stringify({ type: 'pending-metadata-changed', docId, pendingMetadata });
|
|
584
|
+
for (const ws of clients) {
|
|
585
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
586
|
+
ws.send(msg);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
530
589
|
// Debounced: coalesces rapid agent writes into a single broadcast.
|
|
531
590
|
let pendingDocsTimer = null;
|
|
532
591
|
const PENDING_DOCS_DEBOUNCE_MS = 500;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.16.3"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -73,6 +73,8 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Use the doc title as the link label for doc-level links. Use the beat label or a short description of the block for node-level bullets — never just "node" or a raw ID. When citing multiple nodes from the same doc, group them under one **Node level** header. When citing nodes across multiple docs, use a separate block per doc. The cost is one `get_doc_link` call per cited doc; the payoff is the user goes from "where is that?" to "right there" in one click.
|
|
76
|
+
|
|
77
|
+
**The URL must come from `get_doc_link`** — it returns a real `http://...` URL. Never invent a URL scheme like `docId:abc123` or hand-construct a path; the link will be dead.
|
|
76
78
|
8. **Orient by content first; pick by nodeId second.** Never call `peek_doc` or `get_nodes` with cold nodeIds. Node-targeting without prior content orientation is meaningless — IDs are byproducts of orientation, never the starting point. The two legitimate entry paths into a doc:
|
|
77
79
|
|
|
78
80
|
- **Content entry** — `search_docs(query, { docId })` returns matching nodes with their IDs inside the doc. Use when you know roughly what you're looking for.
|
|
@@ -85,10 +87,18 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
85
87
|
2. `browse_docs({ workspaceFile })` — concept-level shelf scan (~60 tokens per doc)
|
|
86
88
|
3. `outline_doc(docId)` — heading tree (~5 tokens per heading)
|
|
87
89
|
4. `search_docs(query, { docId })` — in-doc content search → matching nodeIds
|
|
88
|
-
5. `peek_doc(docId, target)` — windowed node read
|
|
89
|
-
6. `read_pad(docId)` — first ~2,000 words
|
|
90
|
+
5. `peek_doc(docId, target)` — windowed node read by nodeId
|
|
91
|
+
6. `read_pad(docId, ...)` — fixed-window word-position read (default: first ~2,000 words)
|
|
92
|
+
|
|
93
|
+
`read_pad` is a fixed-window tool by default but accepts two knobs for full control:
|
|
94
|
+
|
|
95
|
+
- **Default** — `read_pad({ docId })` returns the first ~2,000 words. Docs at or under the cap return in full.
|
|
96
|
+
- **Slice** — `read_pad({ docId, slice: { from: 0.5, to: 1 } })` reads a percentile range. `{from:0.5, to:1}` = back half, `{from:0.25, to:0.75}` = middle 50%, sequential `{from:0.0,to:0.1}` → `{from:0.1,to:0.2}` … = 10% chunks for whole-doc coverage at predictable per-call cost. Snaps to top-level node boundaries; subject to the cap unless `force` is set.
|
|
97
|
+
- **Force** — `read_pad({ docId, force: true })` bypasses the cap and returns the full requested region. Use for full-doc audits, rewrites, or anywhere you've explicitly accepted the cost.
|
|
90
98
|
|
|
91
|
-
|
|
99
|
+
Slice vs peek: peek anchors to a known nodeId (good for "read around this hit"); slice anchors to a word-position percentile (good for "give me the back half" or "walk this doc in 10% chunks"). Use the one that matches your intent — neither is strictly better.
|
|
100
|
+
|
|
101
|
+
When the cap kicks in, the response includes `lastNodeId` plus continuation hints for all four follow-up tools (read_pad slice, read_pad force, peek_doc, outline_doc).
|
|
92
102
|
|
|
93
103
|
**Implication for doc structure:** monolith docs (8k+ words in one file) push you up the ladder on every read. Splitting into chapters, sections, or topic-sized docs makes everything cheaper — outline_doc shows the whole shape, browse_docs returns concept-level summaries, and individual reads come back complete. The cap is friction designed to surface monoliths as the wrong unit for AI-assisted writing in this era.
|
|
94
104
|
|
|
@@ -139,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
139
149
|
|
|
140
150
|
| Tool | Key Params | Description |
|
|
141
151
|
|------|-----------|-------------|
|
|
142
|
-
| `read_pad` |
|
|
152
|
+
| `read_pad` | `docId`, `slice?` (`{from,to}` floats in [0,1]), `force?` (boolean) | Fixed-window word-position read. **Default:** first ~2,000 words; docs at or under the cap return in full. **`slice: {from, to}`** reads a percentile range (e.g. `{from: 0.5, to: 1}` = back half, `{from: 0.25, to: 0.75}` = middle 50%, sequential `{from: 0, to: 0.1}` → `{from: 0.1, to: 0.2}` … = 10% chunks at predictable per-call cost). Snaps to top-level node boundaries, subject to the cap unless `force` is set. **`force: true`** bypasses the cap entirely — returns the full requested region (whole doc, or whole slice). Use for full-doc audits and rewrites where you've accepted the cost. Truncated responses include `lastNodeId` + continuation hints for slice / force / peek_doc / outline_doc. |
|
|
143
153
|
| `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
|
|
144
154
|
| `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
|
|
145
155
|
| `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
|
|
@@ -275,7 +285,10 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
275
285
|
|
|
276
286
|
- Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
|
|
277
287
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
278
|
-
- Get fresh node IDs before editing.
|
|
288
|
+
- Get fresh node IDs before editing. Three patterns by edit scope:
|
|
289
|
+
- **Short doc broad edit** (≤ ~2,000 words): `read_pad({ docId })` returns the full body with all node IDs in one call.
|
|
290
|
+
- **Long doc broad edit**: either `read_pad({ docId, force: true })` for the whole body in one shot (cost acknowledged), or `read_pad({ docId, slice: {from, to} })` walking 10% chunks if the edit spans the whole doc but you want predictable per-call cost.
|
|
291
|
+
- **Surgical edit** (you already know the anchor from `outline_doc` / `search_docs` / deep-link click): `peek_doc({ around: anchor })` returns just the relevant region's current IDs — much cheaper than any read_pad form for targeted work.
|
|
279
292
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
280
293
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
281
294
|
- **`rewrite` preserves the target node's type.** Sending plain prose to rewrite a heading keeps it a heading; the same for list items and blockquotes. To intentionally change a node's type, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
|
|
@@ -388,7 +401,7 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
|
|
|
388
401
|
|
|
389
402
|
### Research (read-only, no edits coming)
|
|
390
403
|
|
|
391
|
-
When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not `read_pad`.
|
|
404
|
+
When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not a default `read_pad`.
|
|
392
405
|
|
|
393
406
|
```
|
|
394
407
|
1. search_docs({ query: "X" }) → ranked docs across workspace
|
|
@@ -398,29 +411,38 @@ When the user asks "find X in this doc", "what does Y argue", "show me the beat
|
|
|
398
411
|
Use underHeading to drill into one section.
|
|
399
412
|
3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
|
|
400
413
|
OR pick a heading nodeId from step 2.
|
|
401
|
-
4.
|
|
402
|
-
|
|
414
|
+
4. Read the region — pick by how wide:
|
|
415
|
+
- peek_doc({ docId, target: { around: nodeId, before, after } })
|
|
416
|
+
→ node-anchored window (small, surgical)
|
|
417
|
+
- read_pad({ docId, slice: { from: 0.4, to: 0.6 } })
|
|
418
|
+
→ percentile-anchored region (wider, contiguous)
|
|
419
|
+
- read_pad({ docId, force: true }) → whole body (you've accepted the cost)
|
|
403
420
|
```
|
|
404
421
|
|
|
405
|
-
|
|
422
|
+
**Pattern:** `search_docs` returns hits with node IDs *and* approximate doc positions. When the user wants the matched paragraph + a sentence either side, `peek_doc` around the node is right. When they want "the section that hit lives in" or "the back half of the doc that contains the hit" or "a contiguous region wider than peek's 100-node window," `read_pad` with a slice is the better call.
|
|
423
|
+
|
|
424
|
+
Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder (search + peek), ~3k tokens via search + read_pad slice for a 25% region, ~10k tokens for the full body via `read_pad force`. Match the read to the question.
|
|
406
425
|
|
|
407
426
|
### Single document (editing)
|
|
408
427
|
|
|
409
428
|
```
|
|
410
429
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
411
|
-
2. Orient on the doc:
|
|
412
|
-
- Short doc (≤ ~2,000 words): read_pad returns the full body
|
|
413
|
-
- Long doc
|
|
414
|
-
peek_doc({ around:
|
|
415
|
-
|
|
416
|
-
-
|
|
417
|
-
|
|
430
|
+
2. Orient on the doc — pick by edit shape:
|
|
431
|
+
- Short doc (≤ ~2,000 words): read_pad({ docId }) returns the full body
|
|
432
|
+
- Long doc, surgical edit (you know roughly what you're touching):
|
|
433
|
+
search_docs({ query, docId }) → peek_doc({ around: hitNodeId, before, after })
|
|
434
|
+
— fresh IDs for just the region you'll edit, ~500 tokens
|
|
435
|
+
- Long doc, broad edit on one section:
|
|
436
|
+
outline_doc({ docId }) → read_pad({ docId, slice: { from, to } })
|
|
437
|
+
where {from, to} bounds the section's percentile range
|
|
438
|
+
- Long doc, whole-body rewrite:
|
|
439
|
+
read_pad({ docId, force: true }) — explicit, cost acknowledged
|
|
418
440
|
3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
|
|
419
441
|
4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
|
|
420
442
|
5. Wait → user accepts/rejects in browser
|
|
421
443
|
```
|
|
422
444
|
|
|
423
|
-
`read_pad` always returns the doc opening up to ~2,000 words
|
|
445
|
+
`read_pad` always returns the doc opening up to ~2,000 words unless you pass `slice` or `force`. Never assume you got the whole body from a default `read_pad` — the truncation response tells you what's missing and gives you the exact slice/force/peek/outline calls to continue.
|
|
424
446
|
|
|
425
447
|
**For tweet/article docs:** step 3 gives you the parent tweet URL (in `tweetContext.url`) and mode (`reply`/`quote`/`tweet`). Use this URL with fxtwitter to read the parent tweet for free — never search externally for it.
|
|
426
448
|
|
|
@@ -428,13 +450,29 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
|
|
|
428
450
|
|
|
429
451
|
```
|
|
430
452
|
1. list_documents → see all docs with title + [docId] + wordCount
|
|
431
|
-
2. For each target doc, orient
|
|
432
|
-
-
|
|
433
|
-
- Long doc:
|
|
453
|
+
2. For each target doc, orient by wordCount:
|
|
454
|
+
- ≤ ~2,000 words: read_pad({ docId }) — full body in one call
|
|
455
|
+
- Long doc, targeted: search_docs({ query, docId }) → peek_doc({ around: hit })
|
|
456
|
+
- Long doc, sectional: outline_doc({ docId }) → read_pad({ docId, slice })
|
|
457
|
+
- Long doc, full pass: read_pad({ docId, force: true })
|
|
434
458
|
3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
|
|
435
459
|
```
|
|
436
460
|
|
|
437
|
-
The wordCount on `list_documents` tells you up-front which docs
|
|
461
|
+
The wordCount on `list_documents` tells you up-front which docs return in full from a default `read_pad` and which will truncate — use it to plan the read shape per doc. A 500-word doc is one round trip; an 8,000-word doc is search + peek for surgical work, outline + slice for sectional work, or force for the rare whole-body case.
|
|
462
|
+
|
|
463
|
+
### Reading patterns at a glance
|
|
464
|
+
|
|
465
|
+
| Intent | Best tool |
|
|
466
|
+
|--------|-----------|
|
|
467
|
+
| "What's in this doc?" | `outline_doc({ docId })` |
|
|
468
|
+
| "Find X in this doc" | `search_docs({ query, docId })` → `peek_doc({ around: hit })` |
|
|
469
|
+
| "Read around this node I already know" | `peek_doc({ around: anchor })` |
|
|
470
|
+
| "Read this specific region of the doc" | `read_pad({ docId, slice: { from, to } })` |
|
|
471
|
+
| "Walk the whole doc in predictable chunks" | `read_pad({ docId, slice })` × N sequential calls |
|
|
472
|
+
| "Give me everything" | `read_pad({ docId, force: true })` |
|
|
473
|
+
| "What's in this doc and what's it about" | `outline_doc` + frontmatter via `get_metadata` |
|
|
474
|
+
| "Which docs in the workspace talk about X" | `search_docs({ query })` (no docId) |
|
|
475
|
+
| "Scan the shelf — concept-level only" | `browse_docs({ workspaceFile })` |
|
|
438
476
|
|
|
439
477
|
### Creating new content (two-step)
|
|
440
478
|
|