openwriter 0.26.0 → 0.27.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-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/dist/index.d.ts +5 -9
- package/dist/plugins/authors-voice/dist/index.js +17 -130
- 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/autoplug-enroll.js +71 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +49 -13
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +215 -78
- 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 +49 -19
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync } from 'fs';
|
|
35
35
|
import { join } from 'path';
|
|
36
|
-
import { getDataDir, atomicWriteFileSync } from './helpers.js';
|
|
36
|
+
import { getDataDir, atomicWriteFileSync, resolveDocPath } from './helpers.js';
|
|
37
|
+
import { markdownToTiptap } from './markdown-parse.js';
|
|
37
38
|
// ============================================================================
|
|
38
39
|
// DIAGNOSTIC HELPERS — node-text preview + entry summary for log readability
|
|
39
40
|
// ============================================================================
|
|
@@ -107,6 +108,52 @@ export function loadOverlay(docId) {
|
|
|
107
108
|
return [];
|
|
108
109
|
}
|
|
109
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Read the entire sidecar JSON object (raw). Used by pending-metadata.ts so
|
|
113
|
+
* it can read the `metadata:` slot without re-implementing file I/O. Returns
|
|
114
|
+
* null when the sidecar is missing or unreadable.
|
|
115
|
+
*/
|
|
116
|
+
export function readSidecarRaw(docId) {
|
|
117
|
+
if (!docId)
|
|
118
|
+
return null;
|
|
119
|
+
const path = getSidecarPath(docId);
|
|
120
|
+
if (!existsSync(path))
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
const raw = readFileSync(path, 'utf-8');
|
|
124
|
+
return JSON.parse(raw);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Atomically write the sidecar with the full JSON object. Both pending-overlay
|
|
132
|
+
* (entries) and pending-metadata (metadata slot) share this single file, so
|
|
133
|
+
* each must preserve the other's slot on every write. This is the low-level
|
|
134
|
+
* primitive both use.
|
|
135
|
+
*
|
|
136
|
+
* If the resulting object has no entries AND no metadata, the sidecar is
|
|
137
|
+
* deleted (absence = "nothing pending for this doc").
|
|
138
|
+
*/
|
|
139
|
+
export function writeSidecarRaw(docId, payload) {
|
|
140
|
+
if (!docId)
|
|
141
|
+
return;
|
|
142
|
+
const hasEntries = Array.isArray(payload.entries) && payload.entries.length > 0;
|
|
143
|
+
const hasMeta = !!payload.metadata && Object.keys(payload.metadata).length > 0;
|
|
144
|
+
if (!hasEntries && !hasMeta) {
|
|
145
|
+
deleteOverlay(docId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ensurePendingDir();
|
|
149
|
+
const path = getSidecarPath(docId);
|
|
150
|
+
const out = { version: 1 };
|
|
151
|
+
if (hasEntries)
|
|
152
|
+
out.entries = payload.entries;
|
|
153
|
+
if (hasMeta)
|
|
154
|
+
out.metadata = payload.metadata;
|
|
155
|
+
atomicWriteFileSync(path, JSON.stringify(out, null, 2));
|
|
156
|
+
}
|
|
110
157
|
export function saveOverlay(docId, entries) {
|
|
111
158
|
if (!docId)
|
|
112
159
|
return;
|
|
@@ -156,7 +203,22 @@ export function saveOverlay(docId, entries) {
|
|
|
156
203
|
if (!newById.has(e.nodeId))
|
|
157
204
|
changes.push(`-${entrySummary(e)}`);
|
|
158
205
|
}
|
|
206
|
+
// Preserve the sidecar's `metadata` slot (used by pending-metadata.ts) across
|
|
207
|
+
// entries-only writes. Without this read-modify-write, saving entries would
|
|
208
|
+
// clobber any pending title-rename staged for the same doc.
|
|
209
|
+
// adr: adr/pending-overlay-model.md
|
|
210
|
+
const prevRaw = readSidecarRaw(docId);
|
|
211
|
+
const prevMetadata = prevRaw?.metadata && Object.keys(prevRaw.metadata).length > 0 ? prevRaw.metadata : null;
|
|
159
212
|
if (dedupedEntries.length === 0) {
|
|
213
|
+
if (prevMetadata) {
|
|
214
|
+
// Entries empty but metadata still pending — keep the sidecar alive with
|
|
215
|
+
// metadata only.
|
|
216
|
+
writeSidecarRaw(docId, { metadata: prevMetadata });
|
|
217
|
+
if (prevEntries.length > 0) {
|
|
218
|
+
diagLog(`[Overlay] SAVE docId=${docId} entries=0 (was ${prevEntries.length}) metadata preserved`);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
160
222
|
if (prevEntries.length > 0) {
|
|
161
223
|
diagLog(`[Overlay] SAVE docId=${docId} → DELETE (was ${prevEntries.length} entries)`);
|
|
162
224
|
}
|
|
@@ -165,7 +227,10 @@ export function saveOverlay(docId, entries) {
|
|
|
165
227
|
}
|
|
166
228
|
ensurePendingDir();
|
|
167
229
|
const path = getSidecarPath(docId);
|
|
168
|
-
|
|
230
|
+
const out = { version: 1, entries: dedupedEntries };
|
|
231
|
+
if (prevMetadata)
|
|
232
|
+
out.metadata = prevMetadata;
|
|
233
|
+
atomicWriteFileSync(path, JSON.stringify(out, null, 2));
|
|
169
234
|
if (changes.length > 0) {
|
|
170
235
|
diagLog(`[Overlay] SAVE docId=${docId} entries=${dedupedEntries.length} changes=[${changes.join(' | ')}]`);
|
|
171
236
|
}
|
|
@@ -558,6 +623,24 @@ export function applyOverlay(canonical, entries) {
|
|
|
558
623
|
* adr: adr/pending-overlay-model.md
|
|
559
624
|
*/
|
|
560
625
|
export function applyOverlayPure(canonical, entries) {
|
|
626
|
+
// Strip the parser-fallback / createDocumentFile-stub empty paragraph
|
|
627
|
+
// before merging. markdownToTiptap mints a placeholder empty paragraph
|
|
628
|
+
// whenever the disk body has no block content (TipTap requires at least
|
|
629
|
+
// one node), and createDocumentFile mints the same shape for fresh empty
|
|
630
|
+
// docs. Both surface as a trailing empty `[p:...]` in the merged view
|
|
631
|
+
// whenever the doc's real content lives only in the overlay. We strip
|
|
632
|
+
// ONLY when canonical is exactly that single empty-paragraph shape AND
|
|
633
|
+
// no overlay entry anchors to it — so steady-state docs with real
|
|
634
|
+
// canonical content (or where an overlay entry references the empty
|
|
635
|
+
// paragraph deliberately, like a pending-delete on a user-emptied node)
|
|
636
|
+
// are untouched. adr: adr/pending-overlay-model.md
|
|
637
|
+
if (entries.length > 0 && isFallbackEmptyCanonical(canonical)) {
|
|
638
|
+
const fallbackId = canonical.content[0]?.attrs?.id;
|
|
639
|
+
const referenced = entries.some((e) => e.nodeId === fallbackId || e.afterNodeId === fallbackId || e.parentNodeId === fallbackId);
|
|
640
|
+
if (!referenced) {
|
|
641
|
+
canonical = { ...canonical, content: [] };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
561
644
|
const merged = canonical ? JSON.parse(JSON.stringify(canonical)) : { type: 'doc', content: [] };
|
|
562
645
|
if (entries.length === 0)
|
|
563
646
|
return merged;
|
|
@@ -860,3 +943,69 @@ export function migrateLegacyPending(doc, legacyPending) {
|
|
|
860
943
|
walk(doc?.content || [], null);
|
|
861
944
|
return entries;
|
|
862
945
|
}
|
|
946
|
+
// ============================================================================
|
|
947
|
+
// MERGED-VIEW DOC LOADER
|
|
948
|
+
// ============================================================================
|
|
949
|
+
/**
|
|
950
|
+
* Load a doc from disk WITH its sidecar overlay applied — returns the
|
|
951
|
+
* user-visible merged view. This is the canonical reader for any code path
|
|
952
|
+
* that surfaces a non-active doc to the user (MCP read tools, browser HTTP
|
|
953
|
+
* fetches, anything that says "give me the doc").
|
|
954
|
+
*
|
|
955
|
+
* Why this exists. fb666e6 (May 2026) split persistence into two surfaces:
|
|
956
|
+
* the .md body holds canonical content, the per-docId sidecar at
|
|
957
|
+
* `_pending/{docId}.json` holds the pending overlay. Writes were updated
|
|
958
|
+
* to handle both halves symmetrically; reads weren't. The bare
|
|
959
|
+
* `markdownToTiptap` returns canonical-only — anyone calling it directly
|
|
960
|
+
* and treating the result as "the doc" silently drops the user's pending
|
|
961
|
+
* content. This function closes that asymmetry: there is one entry point
|
|
962
|
+
* for "load the doc," and it always materializes the full merged view.
|
|
963
|
+
*
|
|
964
|
+
* Callers that explicitly want canonical-only (the save-time matcher, the
|
|
965
|
+
* on-disk identity persistence path, sync-check roundtripping) continue to
|
|
966
|
+
* call `markdownToTiptap` directly. Those callsites are internal to the
|
|
967
|
+
* persistence layer and deliberately operate on the pre-overlay shape.
|
|
968
|
+
*
|
|
969
|
+
* Throws if the file doesn't exist (matches resolveDocTarget's existing
|
|
970
|
+
* behavior; callers that want soft-failure should check existsSync first).
|
|
971
|
+
*
|
|
972
|
+
* adr: adr/pending-overlay-model.md
|
|
973
|
+
*/
|
|
974
|
+
export function loadDocFromDisk(filename) {
|
|
975
|
+
const targetPath = resolveDocPath(filename);
|
|
976
|
+
if (!existsSync(targetPath)) {
|
|
977
|
+
throw new Error(`Document file not found: ${filename}`);
|
|
978
|
+
}
|
|
979
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
980
|
+
const parsed = markdownToTiptap(raw);
|
|
981
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string')
|
|
982
|
+
? parsed.metadata.docId
|
|
983
|
+
: '';
|
|
984
|
+
let document = parsed.document;
|
|
985
|
+
if (docId) {
|
|
986
|
+
const overlayEntries = loadOverlay(docId);
|
|
987
|
+
if (overlayEntries.length > 0) {
|
|
988
|
+
// applyOverlayPure handles the parser-fallback / stub-empty strip
|
|
989
|
+
// itself — see the comment block at its top for why.
|
|
990
|
+
document = applyOverlayPure(parsed.document, overlayEntries);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
document,
|
|
995
|
+
title: parsed.title,
|
|
996
|
+
metadata: parsed.metadata,
|
|
997
|
+
docId,
|
|
998
|
+
rawFrontmatter: parsed.rawFrontmatter,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/** True when a parsed doc's canonical body is `[{ paragraph with no content }]`
|
|
1002
|
+
* — the markdownToTiptap fallback shape that signals "disk body had no
|
|
1003
|
+
* block-level content." Genuine user empty paragraphs in a doc with other
|
|
1004
|
+
* real content don't trip this (length check is strict); only the parser
|
|
1005
|
+
* fallback's single-node case matches. */
|
|
1006
|
+
function isFallbackEmptyCanonical(canonical) {
|
|
1007
|
+
if (!canonical?.content || canonical.content.length !== 1)
|
|
1008
|
+
return false;
|
|
1009
|
+
const node = canonical.content[0];
|
|
1010
|
+
return node?.type === 'paragraph' && (!node.content || node.content.length === 0);
|
|
1011
|
+
}
|
|
@@ -146,12 +146,27 @@ export class PluginManager {
|
|
|
146
146
|
}
|
|
147
147
|
return resolved;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Persist enabled/config state to ~/.openwriter/config.json.
|
|
151
|
+
*
|
|
152
|
+
* IMPORTANT: plugins can store arbitrary nested data on their own slot
|
|
153
|
+
* (e.g. the github plugin stores `blogSites: [...]`). This writer
|
|
154
|
+
* preserves any such keys by merging into the existing on-disk slot
|
|
155
|
+
* rather than rebuilding the slot from scratch. Without this preserve
|
|
156
|
+
* step, every plugin enable/disable/config edit would silently drop
|
|
157
|
+
* blogSites and any other plugin-owned data.
|
|
158
|
+
*
|
|
159
|
+
* adr: adr/plugin-slot-nested-data.md
|
|
160
|
+
*/
|
|
150
161
|
savePluginState() {
|
|
151
|
-
const
|
|
162
|
+
const current = readConfig();
|
|
163
|
+
const existing = (current.plugins || {});
|
|
164
|
+
const pluginsState = { ...existing };
|
|
152
165
|
for (const [name, managed] of this.plugins) {
|
|
166
|
+
const prior = (existing[name] || {});
|
|
153
167
|
pluginsState[name] = {
|
|
154
|
-
|
|
168
|
+
...prior, // preserve blogSites + any other plugin-owned data
|
|
169
|
+
enabled: managed.enabled, // overwrite managed fields
|
|
155
170
|
config: managed.config,
|
|
156
171
|
};
|
|
157
172
|
}
|
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);
|