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.
@@ -273,41 +273,108 @@ export function countWords(nodes) {
273
273
  return 0;
274
274
  return text.split(/\s+/).length;
275
275
  }
276
- /** Truncate a doc at a top-level node boundary so the returned content
277
- * doesn't exceed `maxWords`. Returns the original doc unchanged if it
278
- * already fits. Always includes at least one top-level node so callers
279
- * never receive an empty body.
276
+ /** Read a region of a doc, truncated at a top-level node boundary so the
277
+ * returned content doesn't exceed `maxWords` (unless `force` is set).
280
278
  *
281
- * read_pad's contract: a fixed-window read, not a full-body read. Above
282
- * the cap, the agent gets the doc opening (most context-rich slice
283
- * title, intro, first few sections) plus the lastNodeId so `peek_doc`
284
- * can continue from the boundary, or `outline_doc` / `search_docs` can
285
- * jump elsewhere.
279
+ * Three modes:
280
+ * - **Default** (no slice, no force) first N words of the doc, capped at
281
+ * `maxWords`. read_pad's original contract: doc opening + continuation hint.
282
+ * - **Slice** (`{ from, to }`) — words in the percentile range. Snaps to top-level
283
+ * node boundaries so blocks aren't split mid-sentence. Still subject to
284
+ * `maxWords` unless `force` is set; an agent walking 10% chunks of a large
285
+ * doc gets predictable per-call cost.
286
+ * - **Force** — bypass the cap. Returns the full requested region (whole doc
287
+ * if no slice, the whole slice if sliced). Use for full-doc audits and
288
+ * rewrites where the agent has explicitly accepted the cost.
286
289
  *
287
290
  * Node-boundary truncation (never splits a top-level block) keeps the
288
291
  * returned slice structurally valid markdown — list items, blockquotes,
289
292
  * and code blocks stay intact. */
290
- export function truncateRead(doc, maxWords) {
293
+ export function truncateRead(doc, options = {}) {
294
+ const maxWords = options.maxWords ?? 2000;
295
+ const force = !!options.force;
296
+ const sliceIn = options.slice ?? null;
297
+ // Clamp/validate slice — clamp to [0,1], silently fix swapped or degenerate inputs.
298
+ let slice = null;
299
+ if (sliceIn) {
300
+ const from = Math.max(0, Math.min(1, sliceIn.from));
301
+ const to = Math.max(0, Math.min(1, sliceIn.to));
302
+ slice = from < to ? { from, to } : { from: 0, to: 1 };
303
+ }
291
304
  const content = doc.content || [];
292
305
  const totalWords = countWords(content);
293
306
  const lastTopId = (n) => n?.attrs?.id ?? null;
294
- if (totalWords <= maxWords) {
307
+ // Empty doc nothing to slice or truncate.
308
+ if (content.length === 0) {
295
309
  return {
296
310
  doc,
297
311
  truncated: false,
312
+ totalWords: 0,
313
+ returnedWords: 0,
314
+ firstNodeId: null,
315
+ lastNodeId: null,
316
+ remaining: 0,
317
+ slice,
318
+ forced: force,
319
+ };
320
+ }
321
+ // Step 1: pick the candidate node range based on slice (or all nodes if no slice).
322
+ // Word-position-based: walk top-level nodes, snap to boundaries that contain
323
+ // the slice's word range. A node is included if any of its words fall inside
324
+ // [fromWord, toWord) — rounds outward, agent always gets whole blocks.
325
+ let candidateStart = 0;
326
+ let candidateEnd = content.length;
327
+ let candidateTotalWords = totalWords;
328
+ if (slice) {
329
+ const fromWord = Math.floor(slice.from * totalWords);
330
+ const toWord = Math.ceil(slice.to * totalWords);
331
+ let cum = 0;
332
+ let startIdx = -1;
333
+ let endIdx = content.length;
334
+ for (let i = 0; i < content.length; i++) {
335
+ const w = countWords([content[i]]);
336
+ const nodeStart = cum;
337
+ const nodeEnd = cum + w;
338
+ // Include node if its word span overlaps [fromWord, toWord).
339
+ if (startIdx === -1 && nodeEnd > fromWord)
340
+ startIdx = i;
341
+ if (startIdx !== -1 && nodeStart >= toWord) {
342
+ endIdx = i;
343
+ break;
344
+ }
345
+ cum += w;
346
+ }
347
+ if (startIdx === -1)
348
+ startIdx = content.length - 1;
349
+ candidateStart = startIdx;
350
+ candidateEnd = endIdx;
351
+ candidateTotalWords = countWords(content.slice(candidateStart, candidateEnd));
352
+ }
353
+ // Step 2: apply maxWords cap to the candidate range (unless forced).
354
+ const candidate = content.slice(candidateStart, candidateEnd);
355
+ if (force || candidateTotalWords <= maxWords) {
356
+ const firstNodeId = lastTopId(candidate[0]);
357
+ const lastNodeId = lastTopId(candidate[candidate.length - 1]);
358
+ return {
359
+ doc: { ...doc, content: candidate },
360
+ truncated: false,
298
361
  totalWords,
299
- returnedWords: totalWords,
300
- lastNodeId: lastTopId(content[content.length - 1]),
362
+ returnedWords: candidateTotalWords,
363
+ firstNodeId,
364
+ lastNodeId,
301
365
  remaining: 0,
366
+ slice,
367
+ forced: force,
302
368
  };
303
369
  }
370
+ // Step 3: candidate exceeds cap — truncate from the start of the candidate
371
+ // range, including whole top-level blocks until adding the next would exceed
372
+ // the cap. Always include at least one node.
304
373
  const included = [];
305
374
  let words = 0;
306
375
  let lastNodeId = null;
307
- for (const n of content) {
376
+ for (const n of candidate) {
308
377
  const w = countWords([n]);
309
- // Always include at least one node — even if it alone exceeds the cap,
310
- // an empty body would be a worse failure mode than a slightly oversize one.
311
378
  if (included.length > 0 && words + w > maxWords)
312
379
  break;
313
380
  included.push(n);
@@ -320,8 +387,11 @@ export function truncateRead(doc, maxWords) {
320
387
  truncated: true,
321
388
  totalWords,
322
389
  returnedWords: words,
390
+ firstNodeId: lastTopId(included[0]),
323
391
  lastNodeId,
324
- remaining: totalWords - words,
392
+ remaining: candidateTotalWords - words,
393
+ slice,
394
+ forced: false,
325
395
  };
326
396
  }
327
397
  /** Find blocks whose text matches the query inside one doc. Returns up to
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pending metadata — sibling of pending-overlay.ts for frontmatter-level
3
+ * staging. Where the overlay holds proposed BLOCK changes (insert / rewrite
4
+ * / delete on TipTap nodes), this module holds proposed METADATA changes —
5
+ * for now just the document title.
6
+ *
7
+ * Architectural model:
8
+ * - Pending metadata lives in the SAME sidecar file as the block overlay
9
+ * (_pending/{docId}.json), under a top-level `metadata:` key. The
10
+ * pending-overlay module owns the `entries:` key; this module owns the
11
+ * `metadata:` key. Each preserves the other's slot when it writes.
12
+ * - The canonical disk .md file's frontmatter is NEVER modified by a
13
+ * pending stage — the title only changes on disk after the user accepts.
14
+ * Reject discards the proposal; nothing on disk moves.
15
+ * - The active document's pending metadata is mirrored into state.ts as
16
+ * `state.pendingMetadata` so WS broadcasts and getters expose it without
17
+ * a disk read on every poll.
18
+ *
19
+ * Why a separate module from pending-overlay.ts:
20
+ * - The overlay model is keyed by nodeId and assumes a TipTap tree. Title
21
+ * is not in the tree — it's a YAML frontmatter field. Forcing it into a
22
+ * fake "node" would corrupt the overlay invariants (nodeId stability,
23
+ * splitMergedDoc tree-walk, applyOverlayPure idempotency).
24
+ * - Future metadata-staging (tags, status, custom frontmatter fields) lands
25
+ * here without further churn to the overlay code path.
26
+ *
27
+ * Scope (phase 1): document title only. tag_doc / untag_doc / set_metadata
28
+ * for non-title fields / mark_enriched still write hot. Workspace and
29
+ * container renames also still write hot — they live in workspace manifests,
30
+ * not per-doc sidecars, and need a separate decision.
31
+ *
32
+ * adr: adr/pending-overlay-model.md
33
+ */
34
+ import { readSidecarRaw, writeSidecarRaw } from './pending-overlay.js';
35
+ import { logger } from './logger.js';
36
+ // ============================================================================
37
+ // SIDECAR I/O
38
+ // ============================================================================
39
+ /** Read the pending-metadata slot from the per-doc sidecar. Returns null if
40
+ * the sidecar is missing or has no metadata slot. */
41
+ export function loadPendingMetadata(docId) {
42
+ if (!docId)
43
+ return null;
44
+ const raw = readSidecarRaw(docId);
45
+ if (!raw?.metadata || Object.keys(raw.metadata).length === 0)
46
+ return null;
47
+ return raw.metadata;
48
+ }
49
+ /** Persist the pending-metadata slot. Preserves the sidecar's existing
50
+ * `entries:` slot. Passing null (or an empty object) clears the metadata
51
+ * and may delete the sidecar entirely if entries are also empty. */
52
+ export function savePendingMetadata(docId, meta) {
53
+ if (!docId)
54
+ return;
55
+ const raw = readSidecarRaw(docId);
56
+ const entries = Array.isArray(raw?.entries) ? raw.entries : [];
57
+ const cleaned = meta && Object.keys(meta).length > 0 ? meta : null;
58
+ writeSidecarRaw(docId, { entries, metadata: cleaned || undefined });
59
+ if (cleaned?.title) {
60
+ logger.info('overlay', 'meta-stage', `docId=${docId} field=title from="${cleaned.title.from}" to="${cleaned.title.to}"`);
61
+ }
62
+ else {
63
+ logger.info('overlay', 'meta-clear', `docId=${docId}`);
64
+ }
65
+ }
@@ -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
- atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: dedupedEntries }, null, 2));
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
- /** Persist enabled/config state to ~/.openwriter/config.json. */
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 pluginsState = {};
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
- enabled: managed.enabled,
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
  }