openwriter 0.1.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.
@@ -0,0 +1,749 @@
1
+ /**
2
+ * File-backed document state for OpenWriter.
3
+ * Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
4
+ * Title lives in frontmatter metadata. Filenames are stable identifiers.
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
7
+ import { join } from 'path';
8
+ import matter from 'gray-matter';
9
+ import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
+ import { applyTextEditsToNode } from './text-edit.js';
11
+ import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, isExternalDoc } from './helpers.js';
12
+ import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
+ const DEFAULT_DOC = {
14
+ type: 'doc',
15
+ content: [{ type: 'paragraph', content: [] }],
16
+ };
17
+ let state = {
18
+ document: DEFAULT_DOC,
19
+ title: 'Untitled',
20
+ metadata: { title: 'Untitled' },
21
+ filePath: '',
22
+ isTemp: true,
23
+ lastModified: new Date(),
24
+ docId: '',
25
+ };
26
+ const listeners = new Set();
27
+ // ============================================================================
28
+ // EXTERNAL DOCUMENT REGISTRY
29
+ // ============================================================================
30
+ const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
31
+ const externalDocs = new Set();
32
+ function persistExternalDocs() {
33
+ try {
34
+ writeFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]), 'utf-8');
35
+ }
36
+ catch { /* best-effort */ }
37
+ }
38
+ function loadExternalDocs() {
39
+ try {
40
+ if (existsSync(EXTERNAL_DOCS_FILE)) {
41
+ const paths = JSON.parse(readFileSync(EXTERNAL_DOCS_FILE, 'utf-8'));
42
+ for (const p of paths) {
43
+ if (existsSync(p))
44
+ externalDocs.add(p);
45
+ }
46
+ }
47
+ }
48
+ catch { /* corrupt file — start fresh */ }
49
+ }
50
+ export function registerExternalDoc(fullPath) {
51
+ externalDocs.add(fullPath);
52
+ persistExternalDocs();
53
+ }
54
+ export function unregisterExternalDoc(fullPath) {
55
+ externalDocs.delete(fullPath);
56
+ persistExternalDocs();
57
+ }
58
+ export function getExternalDocs() {
59
+ return [...externalDocs];
60
+ }
61
+ function isDocEmpty(doc) {
62
+ if (!doc.content || doc.content.length === 0)
63
+ return true;
64
+ if (doc.content.length === 1) {
65
+ const node = doc.content[0];
66
+ if (!node.content || node.content.length === 0)
67
+ return true;
68
+ if (node.content.length === 1 && !node.content[0].text?.trim())
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ // ============================================================================
74
+ // GETTERS
75
+ // ============================================================================
76
+ export function getDocument() {
77
+ return state.document;
78
+ }
79
+ export function getTitle() {
80
+ return state.title;
81
+ }
82
+ export function getFilePath() {
83
+ return state.filePath;
84
+ }
85
+ export function getDocId() {
86
+ return state.docId;
87
+ }
88
+ export function getPlainText() {
89
+ return extractText(state.document.content);
90
+ }
91
+ function extractText(nodes) {
92
+ if (!nodes)
93
+ return '';
94
+ return nodes
95
+ .map((node) => {
96
+ if (node.text)
97
+ return node.text;
98
+ if (node.content)
99
+ return extractText(node.content);
100
+ return '';
101
+ })
102
+ .join('\n');
103
+ }
104
+ export function getWordCount() {
105
+ const text = getPlainText();
106
+ return text.trim() ? text.trim().split(/\s+/).length : 0;
107
+ }
108
+ export function getPendingChangeCount() {
109
+ let count = 0;
110
+ function scan(nodes) {
111
+ if (!nodes)
112
+ return;
113
+ for (const node of nodes) {
114
+ if (node.attrs?.pendingStatus)
115
+ count++;
116
+ if (node.content)
117
+ scan(node.content);
118
+ }
119
+ }
120
+ scan(state.document.content);
121
+ return count;
122
+ }
123
+ export function getNodesByIds(ids) {
124
+ const result = [];
125
+ function scan(nodes) {
126
+ if (!nodes)
127
+ return;
128
+ for (const node of nodes) {
129
+ if (node.attrs?.id && ids.includes(node.attrs.id)) {
130
+ result.push(node);
131
+ }
132
+ if (node.content)
133
+ scan(node.content);
134
+ }
135
+ }
136
+ scan(state.document.content);
137
+ return result;
138
+ }
139
+ export function getMetadata() {
140
+ return state.metadata;
141
+ }
142
+ export function setMetadata(updates) {
143
+ state.metadata = { ...state.metadata, ...updates };
144
+ if (updates.title)
145
+ state.title = updates.title;
146
+ }
147
+ export function getStatus() {
148
+ return {
149
+ title: state.title,
150
+ wordCount: getWordCount(),
151
+ pendingChanges: getPendingChangeCount(),
152
+ lastModified: state.lastModified.toISOString(),
153
+ };
154
+ }
155
+ // ============================================================================
156
+ // SETTERS
157
+ // ============================================================================
158
+ export function updateDocument(doc) {
159
+ // Preserve pending attrs that the browser doesn't track in its document model.
160
+ // Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
161
+ // Without this, browser overwrites server state and pending info is lost on next save.
162
+ if (hasPendingChanges()) {
163
+ transferPendingAttrs(state.document, doc);
164
+ }
165
+ state.document = doc;
166
+ state.lastModified = new Date();
167
+ }
168
+ /**
169
+ * Transfer pending attrs from source doc to target doc by matching node IDs.
170
+ * Copies pendingStatus, pendingOriginalContent, and pendingTextEdits.
171
+ */
172
+ function transferPendingAttrs(source, target) {
173
+ // Build a map of nodeId → pending attrs from source
174
+ const pendingMap = new Map();
175
+ function collectPending(nodes) {
176
+ if (!nodes)
177
+ return;
178
+ for (const node of nodes) {
179
+ if (node.attrs?.pendingStatus && node.attrs?.id) {
180
+ pendingMap.set(node.attrs.id, {
181
+ status: node.attrs.pendingStatus,
182
+ original: node.attrs.pendingOriginalContent,
183
+ textEdits: node.attrs.pendingTextEdits,
184
+ });
185
+ }
186
+ if (node.content)
187
+ collectPending(node.content);
188
+ }
189
+ }
190
+ collectPending(source.content);
191
+ // Apply pending attrs to matching nodes in target
192
+ function applyPending(nodes) {
193
+ if (!nodes)
194
+ return;
195
+ for (const node of nodes) {
196
+ if (node.attrs?.id && pendingMap.has(node.attrs.id)) {
197
+ const p = pendingMap.get(node.attrs.id);
198
+ node.attrs.pendingStatus = p.status;
199
+ if (p.original)
200
+ node.attrs.pendingOriginalContent = p.original;
201
+ if (p.textEdits)
202
+ node.attrs.pendingTextEdits = p.textEdits;
203
+ }
204
+ if (node.content)
205
+ applyPending(node.content);
206
+ }
207
+ }
208
+ applyPending(target.content);
209
+ }
210
+ // ============================================================================
211
+ // AGENT WRITE LOCK
212
+ // ============================================================================
213
+ const AGENT_LOCK_MS = 5000; // Block browser doc-updates for 5s after agent write
214
+ let lastAgentWriteTime = 0;
215
+ /** Set the agent write lock (called after agent changes). */
216
+ function setAgentLock() {
217
+ lastAgentWriteTime = Date.now();
218
+ }
219
+ /** Check if the agent write lock is active. */
220
+ export function isAgentLocked() {
221
+ return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
222
+ }
223
+ // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
224
+ let saveTimer = null;
225
+ const SAVE_DEBOUNCE_MS = 500;
226
+ function debouncedSave() {
227
+ if (saveTimer)
228
+ clearTimeout(saveTimer);
229
+ saveTimer = setTimeout(() => {
230
+ saveTimer = null;
231
+ save();
232
+ }, SAVE_DEBOUNCE_MS);
233
+ }
234
+ /** Cancel any pending debounced save. Call before doc switch (which does its own save). */
235
+ export function cancelDebouncedSave() {
236
+ if (saveTimer) {
237
+ clearTimeout(saveTimer);
238
+ saveTimer = null;
239
+ }
240
+ }
241
+ export function applyChanges(changes) {
242
+ // Apply to server-side document (source of truth)
243
+ const processed = applyChangesToDocument(changes);
244
+ // Lock browser doc-updates to prevent stale state overwrite
245
+ setAgentLock();
246
+ // Broadcast processed changes (with server-assigned IDs) to browser clients
247
+ for (const listener of listeners) {
248
+ listener(processed);
249
+ }
250
+ // Debounced save — coalesces rapid agent writes into a single disk write
251
+ debouncedSave();
252
+ return processed.length;
253
+ }
254
+ export function onChanges(listener) {
255
+ listeners.add(listener);
256
+ return () => listeners.delete(listener);
257
+ }
258
+ // ============================================================================
259
+ // SERVER-SIDE DOCUMENT MUTATIONS
260
+ // ============================================================================
261
+ // generateNodeId imported from helpers.ts
262
+ /**
263
+ * Find a node by ID in the document tree.
264
+ * Returns the parent array and index for in-place mutation.
265
+ */
266
+ function findNodeInDoc(nodes, id) {
267
+ for (let i = 0; i < nodes.length; i++) {
268
+ if (nodes[i].attrs?.id === id) {
269
+ return { parent: nodes, index: i };
270
+ }
271
+ if (nodes[i].content && Array.isArray(nodes[i].content)) {
272
+ const result = findNodeInDoc(nodes[i].content, id);
273
+ if (result)
274
+ return result;
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ /**
280
+ * Apply changes to server-side document and return processed changes
281
+ * with server-assigned IDs for broadcast to browsers.
282
+ */
283
+ function applyChangesToDocument(changes) {
284
+ const processed = [];
285
+ for (const change of changes) {
286
+ if (change.operation === 'rewrite' && change.nodeId && change.content) {
287
+ const found = findNodeInDoc(state.document.content, change.nodeId);
288
+ if (!found)
289
+ continue;
290
+ const contentArray = Array.isArray(change.content) ? change.content : [change.content];
291
+ const originalNode = JSON.parse(JSON.stringify(found.parent[found.index]));
292
+ // Only store original on first rewrite (preserve baseline for reject)
293
+ const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
294
+ // First node replaces the target (rewrite)
295
+ const firstNode = {
296
+ ...contentArray[0],
297
+ attrs: {
298
+ ...contentArray[0].attrs,
299
+ id: change.nodeId,
300
+ pendingStatus: 'rewrite',
301
+ pendingOriginalContent: existingOriginal || originalNode,
302
+ },
303
+ };
304
+ // Additional nodes get inserted after as pending inserts
305
+ const extraNodes = contentArray.slice(1).map((node) => ({
306
+ ...node,
307
+ attrs: {
308
+ ...node.attrs,
309
+ id: node.attrs?.id || generateNodeId(),
310
+ pendingStatus: 'insert',
311
+ },
312
+ }));
313
+ found.parent.splice(found.index, 1, firstNode, ...extraNodes);
314
+ processed.push({
315
+ ...change,
316
+ content: [firstNode, ...extraNodes],
317
+ });
318
+ }
319
+ else if (change.operation === 'insert' && change.content) {
320
+ const contentArray = Array.isArray(change.content) ? change.content : [change.content];
321
+ // Assign IDs to all new nodes before broadcast
322
+ const contentWithIds = contentArray.map((node, i) => ({
323
+ ...node,
324
+ attrs: {
325
+ ...node.attrs,
326
+ id: node.attrs?.id || (change.nodeId && !change.afterNodeId && i === 0 ? change.nodeId : generateNodeId()),
327
+ pendingStatus: 'insert',
328
+ },
329
+ }));
330
+ if (change.nodeId && !change.afterNodeId) {
331
+ // Replace empty node
332
+ const found = findNodeInDoc(state.document.content, change.nodeId);
333
+ if (!found)
334
+ continue;
335
+ found.parent.splice(found.index, 1, ...contentWithIds);
336
+ }
337
+ else if (change.afterNodeId) {
338
+ const found = findNodeInDoc(state.document.content, change.afterNodeId);
339
+ if (!found)
340
+ continue;
341
+ found.parent.splice(found.index + 1, 0, ...contentWithIds);
342
+ }
343
+ else {
344
+ continue;
345
+ }
346
+ // Broadcast with server-assigned IDs so browser uses the same IDs
347
+ processed.push({
348
+ ...change,
349
+ content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
350
+ });
351
+ }
352
+ else if (change.operation === 'delete' && change.nodeId) {
353
+ const found = findNodeInDoc(state.document.content, change.nodeId);
354
+ if (!found)
355
+ continue;
356
+ found.parent[found.index] = {
357
+ ...found.parent[found.index],
358
+ attrs: {
359
+ ...found.parent[found.index].attrs,
360
+ pendingStatus: 'delete',
361
+ },
362
+ };
363
+ processed.push(change);
364
+ }
365
+ }
366
+ if (processed.length > 0) {
367
+ state.lastModified = new Date();
368
+ }
369
+ return processed;
370
+ }
371
+ /**
372
+ * Apply fine-grained text edits to a node. Resolves text matches,
373
+ * produces a modified node, and routes through applyChanges as a rewrite.
374
+ */
375
+ export function applyTextEdits(nodeId, edits) {
376
+ const found = findNodeInDoc(state.document.content, nodeId);
377
+ if (!found)
378
+ return { success: false, error: `Node ${nodeId} not found` };
379
+ const originalNode = found.parent[found.index];
380
+ const result = applyTextEditsToNode(originalNode, edits);
381
+ if (!result)
382
+ return { success: false, error: 'No edits matched' };
383
+ // Store inline edit ranges for fine-grained decoration
384
+ result.node.attrs = {
385
+ ...result.node.attrs,
386
+ pendingTextEdits: result.textEdits,
387
+ };
388
+ // Route through applyChanges as a rewrite so it goes through the normal pipeline
389
+ applyChanges([{
390
+ operation: 'rewrite',
391
+ nodeId,
392
+ content: result.node,
393
+ }]);
394
+ return { success: true };
395
+ }
396
+ /** Set the active document state. Used by documents.ts for multi-doc operations. */
397
+ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata) {
398
+ state.document = doc;
399
+ state.title = title;
400
+ state.metadata = metadata || { title };
401
+ state.filePath = filePath;
402
+ state.isTemp = isTemp;
403
+ state.lastModified = lastModified || new Date();
404
+ state.docId = ensureDocId(state.metadata);
405
+ }
406
+ // ============================================================================
407
+ // PENDING DOCUMENT STORE OPERATIONS
408
+ // ============================================================================
409
+ /** Check if a document (or the current doc) has any pending changes. */
410
+ export function hasPendingChanges(doc) {
411
+ const target = doc || state.document;
412
+ function scan(nodes) {
413
+ if (!nodes)
414
+ return false;
415
+ for (const node of nodes) {
416
+ if (node.attrs?.pendingStatus)
417
+ return true;
418
+ if (node.content && scan(node.content))
419
+ return true;
420
+ }
421
+ return false;
422
+ }
423
+ return scan(target.content);
424
+ }
425
+ /** Strip all pending attrs from the current document (after browser resolves all changes). */
426
+ export function stripPendingAttrs() {
427
+ function strip(nodes) {
428
+ if (!nodes)
429
+ return;
430
+ for (const node of nodes) {
431
+ if (node.attrs?.pendingStatus) {
432
+ delete node.attrs.pendingStatus;
433
+ delete node.attrs.pendingOriginalContent;
434
+ delete node.attrs.pendingTextEdits;
435
+ }
436
+ if (node.content)
437
+ strip(node.content);
438
+ }
439
+ }
440
+ strip(state.document.content);
441
+ }
442
+ /**
443
+ * Mark leaf block nodes as pending. Only marks text-containing blocks
444
+ * (paragraph, heading, codeBlock, horizontalRule) — NOT container nodes
445
+ * (bulletList, orderedList, listItem, blockquote) whose children get marked instead.
446
+ * This prevents overlapping decorations and ensures accept/reject acts on visible blocks.
447
+ */
448
+ export function markAllNodesAsPending(doc, status) {
449
+ function mark(nodes) {
450
+ if (!nodes)
451
+ return;
452
+ for (const node of nodes) {
453
+ if (node.type && LEAF_BLOCK_TYPES.has(node.type)) {
454
+ node.attrs = { ...node.attrs, pendingStatus: status };
455
+ if (!node.attrs.id) {
456
+ node.attrs.id = generateNodeId();
457
+ }
458
+ // Don't recurse into leaf blocks — prevents overlapping decorations
459
+ // (e.g. table marked + its inner paragraphs also marked)
460
+ }
461
+ else if (node.content) {
462
+ // Recurse into container children to mark nested leaf blocks (e.g. paragraphs inside listItems)
463
+ mark(node.content);
464
+ }
465
+ }
466
+ }
467
+ mark(doc.content);
468
+ }
469
+ /** Get filenames of all docs with pending changes (disk scan + external docs + current in-memory doc). */
470
+ export function getPendingDocFilenames() {
471
+ const filenames = [];
472
+ try {
473
+ const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
474
+ for (const f of files) {
475
+ try {
476
+ const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
477
+ const { data } = matter(raw);
478
+ if (data.pending && Object.keys(data.pending).length > 0) {
479
+ filenames.push(f);
480
+ }
481
+ }
482
+ catch { /* skip unreadable files */ }
483
+ }
484
+ }
485
+ catch { /* ignore */ }
486
+ // Scan external docs for pending frontmatter
487
+ for (const extPath of externalDocs) {
488
+ try {
489
+ if (!existsSync(extPath))
490
+ continue;
491
+ const raw = readFileSync(extPath, 'utf-8');
492
+ const { data } = matter(raw);
493
+ if (data.pending && Object.keys(data.pending).length > 0) {
494
+ filenames.push(extPath);
495
+ }
496
+ }
497
+ catch { /* skip unreadable files */ }
498
+ }
499
+ // Check current in-memory doc (may have unsaved pending state)
500
+ const currentFilename = state.filePath
501
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
502
+ : '';
503
+ if (currentFilename && hasPendingChanges() && !filenames.includes(currentFilename)) {
504
+ filenames.push(currentFilename);
505
+ }
506
+ return filenames;
507
+ }
508
+ /** Get pending change counts per filename (disk scan + external docs + current in-memory doc). */
509
+ export function getPendingDocCounts() {
510
+ const counts = {};
511
+ try {
512
+ const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
513
+ for (const f of files) {
514
+ try {
515
+ const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
516
+ const { data } = matter(raw);
517
+ if (data.pending && Object.keys(data.pending).length > 0) {
518
+ counts[f] = Object.keys(data.pending).length;
519
+ }
520
+ }
521
+ catch { /* skip unreadable files */ }
522
+ }
523
+ }
524
+ catch { /* ignore */ }
525
+ // Scan external docs
526
+ for (const extPath of externalDocs) {
527
+ try {
528
+ if (!existsSync(extPath))
529
+ continue;
530
+ const raw = readFileSync(extPath, 'utf-8');
531
+ const { data } = matter(raw);
532
+ if (data.pending && Object.keys(data.pending).length > 0) {
533
+ counts[extPath] = Object.keys(data.pending).length;
534
+ }
535
+ }
536
+ catch { /* skip unreadable files */ }
537
+ }
538
+ // Current in-memory doc may have unsaved pending state
539
+ const currentFilename = state.filePath
540
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
541
+ : '';
542
+ if (currentFilename && hasPendingChanges()) {
543
+ counts[currentFilename] = getPendingChangeCount();
544
+ }
545
+ return counts;
546
+ }
547
+ // ============================================================================
548
+ // PERSISTENCE
549
+ // ============================================================================
550
+ function writeToDisk() {
551
+ ensureDataDir();
552
+ const markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
553
+ if (existsSync(state.filePath)) {
554
+ // Skip write if content is identical (prevents phantom git changes on doc switch)
555
+ try {
556
+ const existing = readFileSync(state.filePath, 'utf-8');
557
+ if (existing === markdown)
558
+ return;
559
+ }
560
+ catch { /* read failed, proceed with write */ }
561
+ // Safety: don't overwrite a file with substantial content using near-empty content.
562
+ // Prevents save cascades where empty editor state destroys chapter files.
563
+ // Exception: docs with pending changes may legitimately be smaller (agent replaced content).
564
+ if (!hasPendingChanges()) {
565
+ try {
566
+ const existingSize = statSync(state.filePath).size;
567
+ if (existingSize > 200 && markdown.length < existingSize * 0.1) {
568
+ console.error(`[State] BLOCKED destructive save: ${markdown.length} bytes would replace ${existingSize} bytes in ${state.filePath}`);
569
+ return;
570
+ }
571
+ }
572
+ catch { /* stat failed, proceed with save */ }
573
+ }
574
+ }
575
+ writeFileSync(state.filePath, markdown, 'utf-8');
576
+ // Best-effort version snapshot — never blocks saves
577
+ try {
578
+ snapshotIfNeeded(state.docId, state.filePath);
579
+ }
580
+ catch { /* ignore */ }
581
+ }
582
+ export function save() {
583
+ if (!state.filePath) {
584
+ // First save — assign a file path
585
+ ensureDataDir();
586
+ if (state.title === 'Untitled') {
587
+ state.filePath = tempFilePath();
588
+ state.isTemp = true;
589
+ }
590
+ else {
591
+ state.filePath = filePathForTitle(state.title);
592
+ state.isTemp = false;
593
+ }
594
+ }
595
+ writeToDisk();
596
+ }
597
+ export function load() {
598
+ ensureDataDir();
599
+ // Restore external document registry from disk
600
+ loadExternalDocs();
601
+ // Migrate any .sw.json files to .md
602
+ migrateSwJsonFiles();
603
+ // Clean up empty temp files from previous sessions
604
+ cleanupEmptyTempFiles();
605
+ // Find most recently modified .md file
606
+ const files = readdirSync(DATA_DIR)
607
+ .filter((f) => f.endsWith('.md'))
608
+ .map((f) => {
609
+ const fullPath = join(DATA_DIR, f);
610
+ const stat = statSync(fullPath);
611
+ return { name: f, path: fullPath, mtime: stat.mtimeMs };
612
+ })
613
+ .sort((a, b) => b.mtime - a.mtime);
614
+ if (files.length === 0) {
615
+ // No existing docs — start fresh with temp file
616
+ state.filePath = tempFilePath();
617
+ state.isTemp = true;
618
+ return;
619
+ }
620
+ // Open the most recent file
621
+ const latest = files[0];
622
+ try {
623
+ const raw = readFileSync(latest.path, 'utf-8');
624
+ const parsed = markdownToTiptap(raw);
625
+ state.document = parsed.document;
626
+ state.title = parsed.title;
627
+ state.metadata = parsed.metadata;
628
+ state.lastModified = new Date(statSync(latest.path).mtimeMs);
629
+ state.filePath = latest.path;
630
+ state.isTemp = latest.name.startsWith(TEMP_PREFIX);
631
+ // Lazy docId migration: assign if missing, save to persist
632
+ const hadDocId = !!state.metadata.docId;
633
+ state.docId = ensureDocId(state.metadata);
634
+ if (!hadDocId) {
635
+ const md = tiptapToMarkdown(state.document, state.title, state.metadata);
636
+ writeFileSync(state.filePath, md, 'utf-8');
637
+ }
638
+ }
639
+ catch {
640
+ // Corrupt file — start fresh
641
+ state.filePath = tempFilePath();
642
+ state.isTemp = true;
643
+ }
644
+ // Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
645
+ setAgentLock();
646
+ }
647
+ /** Migrate legacy .sw.json files to .md format */
648
+ function migrateSwJsonFiles() {
649
+ try {
650
+ const jsonFiles = readdirSync(DATA_DIR).filter((f) => f.endsWith('.sw.json'));
651
+ for (const f of jsonFiles) {
652
+ const jsonPath = join(DATA_DIR, f);
653
+ const mdName = f.replace(/\.sw\.json$/, '.md');
654
+ const mdPath = join(DATA_DIR, mdName);
655
+ // Skip if .md already exists
656
+ if (existsSync(mdPath)) {
657
+ try {
658
+ unlinkSync(jsonPath);
659
+ }
660
+ catch { /* ignore */ }
661
+ continue;
662
+ }
663
+ try {
664
+ const raw = readFileSync(jsonPath, 'utf-8');
665
+ const data = JSON.parse(raw);
666
+ if (data.document) {
667
+ const title = data.title || 'Untitled';
668
+ const markdown = tiptapToMarkdown(data.document, title);
669
+ writeFileSync(mdPath, markdown, 'utf-8');
670
+ console.log(`[State] Migrated ${f} → ${mdName}`);
671
+ }
672
+ unlinkSync(jsonPath);
673
+ }
674
+ catch {
675
+ // Corrupt JSON file — delete it
676
+ try {
677
+ unlinkSync(jsonPath);
678
+ }
679
+ catch { /* ignore */ }
680
+ }
681
+ }
682
+ }
683
+ catch { /* ignore errors during migration */ }
684
+ }
685
+ /** Collect all filenames referenced by any workspace manifest. */
686
+ function getWorkspaceReferencedFiles() {
687
+ const referenced = new Set();
688
+ try {
689
+ const wsDir = join(DATA_DIR, '_workspaces');
690
+ if (!existsSync(wsDir))
691
+ return referenced;
692
+ const manifests = readdirSync(wsDir).filter((f) => f.endsWith('.json'));
693
+ for (const m of manifests) {
694
+ try {
695
+ const raw = readFileSync(join(wsDir, m), 'utf-8');
696
+ const ws = JSON.parse(raw);
697
+ // Recursively collect doc files from root tree
698
+ const collect = (nodes) => {
699
+ for (const n of nodes) {
700
+ if (n.type === 'doc' && n.file)
701
+ referenced.add(n.file);
702
+ if (n.type === 'container' && Array.isArray(n.items))
703
+ collect(n.items);
704
+ }
705
+ };
706
+ if (Array.isArray(ws.root))
707
+ collect(ws.root);
708
+ else if (Array.isArray(ws.items)) {
709
+ // v1 format
710
+ for (const item of ws.items) {
711
+ if (item.file)
712
+ referenced.add(item.file);
713
+ }
714
+ }
715
+ }
716
+ catch { /* skip corrupt manifests */ }
717
+ }
718
+ }
719
+ catch { /* ignore */ }
720
+ return referenced;
721
+ }
722
+ /** Remove temp files that are empty (from abandoned sessions) */
723
+ function cleanupEmptyTempFiles() {
724
+ try {
725
+ const wsRefs = getWorkspaceReferencedFiles();
726
+ const files = readdirSync(DATA_DIR).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
727
+ for (const f of files) {
728
+ // Never delete temp files that are referenced by a workspace
729
+ if (wsRefs.has(f))
730
+ continue;
731
+ const fullPath = join(DATA_DIR, f);
732
+ try {
733
+ const raw = readFileSync(fullPath, 'utf-8');
734
+ const parsed = markdownToTiptap(raw);
735
+ if (isDocEmpty(parsed.document)) {
736
+ unlinkSync(fullPath);
737
+ }
738
+ }
739
+ catch {
740
+ // Corrupt temp file — delete it (but only if not workspace-referenced)
741
+ try {
742
+ unlinkSync(fullPath);
743
+ }
744
+ catch { /* ignore */ }
745
+ }
746
+ }
747
+ }
748
+ catch { /* ignore errors during cleanup */ }
749
+ }