openwriter 0.13.0 → 0.15.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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-BlLnLdoc.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-OV13QtgQ.css">
13
+ <script type="module" crossorigin src="/assets/index-B5MXw2pg.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-B3iORmCT.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Comments: sidecar JSON storage for inline user feedback (formerly "agent marks").
3
+ * Each document gets a sidecar file at DATA_DIR/_marks/{filename}.json.
4
+ * Storage directory name `_marks/` is retained for backwards compatibility with
5
+ * existing user data; the public vocabulary is "comment" everywhere else.
6
+ */
7
+ import { join } from 'path';
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
9
+ import { randomUUID } from 'crypto';
10
+ import { getDataDir, ensureDataDir } from './helpers.js';
11
+ function isResolved(c) {
12
+ return typeof c.resolvedAt === 'string' && c.resolvedAt.length > 0;
13
+ }
14
+ function getCommentsDir() { return join(getDataDir(), '_marks'); }
15
+ function ensureCommentsDir() {
16
+ ensureDataDir();
17
+ if (!existsSync(getCommentsDir()))
18
+ mkdirSync(getCommentsDir(), { recursive: true });
19
+ }
20
+ function commentFilePath(filename) {
21
+ const safe = filename.replace(/[/\\]/g, '_');
22
+ return join(getCommentsDir(), `${safe}.json`);
23
+ }
24
+ function readCommentFile(filename) {
25
+ const path = commentFilePath(filename);
26
+ if (!existsSync(path))
27
+ return { marks: [] };
28
+ try {
29
+ return JSON.parse(readFileSync(path, 'utf-8'));
30
+ }
31
+ catch {
32
+ return { marks: [] };
33
+ }
34
+ }
35
+ function writeCommentFile(filename, data) {
36
+ ensureCommentsDir();
37
+ const path = commentFilePath(filename);
38
+ if (data.marks.length === 0) {
39
+ if (existsSync(path))
40
+ unlinkSync(path);
41
+ return;
42
+ }
43
+ writeFileSync(path, JSON.stringify(data, null, 2));
44
+ }
45
+ export function addComment(filename, text, note, nodeId, nodeIds) {
46
+ const data = readCommentFile(filename);
47
+ const comment = {
48
+ id: randomUUID().slice(0, 8),
49
+ text,
50
+ note,
51
+ nodeId,
52
+ ...(nodeIds && nodeIds.length > 1 ? { nodeIds } : {}),
53
+ createdAt: new Date().toISOString(),
54
+ };
55
+ data.marks.push(comment);
56
+ writeCommentFile(filename, data);
57
+ return comment;
58
+ }
59
+ export function getComments(filename, opts = {}) {
60
+ const keep = (list) => opts.includeResolved ? list : list.filter((c) => !isResolved(c));
61
+ if (filename) {
62
+ const data = readCommentFile(filename);
63
+ const list = keep(data.marks);
64
+ if (list.length === 0)
65
+ return {};
66
+ return { [filename]: list };
67
+ }
68
+ ensureCommentsDir();
69
+ const result = {};
70
+ try {
71
+ const files = readdirSync(getCommentsDir());
72
+ for (const file of files) {
73
+ if (!file.endsWith('.json'))
74
+ continue;
75
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
76
+ const path = join(getCommentsDir(), file);
77
+ try {
78
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
79
+ const list = keep(data.marks);
80
+ if (list.length > 0)
81
+ result[docFilename] = list;
82
+ }
83
+ catch { /* skip corrupt files */ }
84
+ }
85
+ }
86
+ catch { /* dir doesn't exist yet */ }
87
+ return result;
88
+ }
89
+ export function getCommentCount(filename) {
90
+ return readCommentFile(filename).marks.filter((c) => !isResolved(c)).length;
91
+ }
92
+ /** Count unresolved comments across all documents, optionally excluding one filename. */
93
+ export function getGlobalCommentSummary(excludeFilename) {
94
+ ensureCommentsDir();
95
+ let totalComments = 0;
96
+ let docCount = 0;
97
+ try {
98
+ const files = readdirSync(getCommentsDir());
99
+ for (const file of files) {
100
+ if (!file.endsWith('.json'))
101
+ continue;
102
+ if (excludeFilename) {
103
+ const safe = excludeFilename.replace(/[/\\]/g, '_');
104
+ if (file === `${safe}.json`)
105
+ continue;
106
+ }
107
+ const path = join(getCommentsDir(), file);
108
+ try {
109
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
110
+ const unresolved = data.marks.filter((c) => !isResolved(c));
111
+ if (unresolved.length > 0) {
112
+ totalComments += unresolved.length;
113
+ docCount++;
114
+ }
115
+ }
116
+ catch { /* skip */ }
117
+ }
118
+ }
119
+ catch { /* dir doesn't exist */ }
120
+ return { totalComments, docCount };
121
+ }
122
+ export function editComment(filename, id, note) {
123
+ const data = readCommentFile(filename);
124
+ const comment = data.marks.find((m) => m.id === id);
125
+ if (!comment)
126
+ return null;
127
+ comment.note = note;
128
+ writeCommentFile(filename, data);
129
+ return comment;
130
+ }
131
+ /** Mark comments as resolved (state change, NOT deletion). The records stay
132
+ * on disk but get filtered out of normal `getComments` listings — so the
133
+ * decoration disappears in the browser without losing the history. */
134
+ export function resolveComments(ids) {
135
+ const idSet = new Set(ids);
136
+ const resolved = [];
137
+ const now = new Date().toISOString();
138
+ ensureCommentsDir();
139
+ try {
140
+ const files = readdirSync(getCommentsDir());
141
+ for (const file of files) {
142
+ if (!file.endsWith('.json'))
143
+ continue;
144
+ const filePath = join(getCommentsDir(), file);
145
+ try {
146
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
147
+ let changed = false;
148
+ for (const c of data.marks) {
149
+ if (idSet.has(c.id) && !isResolved(c)) {
150
+ c.resolvedAt = now;
151
+ resolved.push(c.id);
152
+ changed = true;
153
+ }
154
+ }
155
+ if (changed) {
156
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
157
+ writeCommentFile(docFilename, data);
158
+ }
159
+ }
160
+ catch { /* skip */ }
161
+ }
162
+ }
163
+ catch { /* dir doesn't exist */ }
164
+ return resolved;
165
+ }
166
+ /** Clear the resolved state on comments. Inverse of resolveComments. */
167
+ export function unresolveComments(ids) {
168
+ const idSet = new Set(ids);
169
+ const cleared = [];
170
+ ensureCommentsDir();
171
+ try {
172
+ const files = readdirSync(getCommentsDir());
173
+ for (const file of files) {
174
+ if (!file.endsWith('.json'))
175
+ continue;
176
+ const filePath = join(getCommentsDir(), file);
177
+ try {
178
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
179
+ let changed = false;
180
+ for (const c of data.marks) {
181
+ if (idSet.has(c.id) && isResolved(c)) {
182
+ delete c.resolvedAt;
183
+ cleared.push(c.id);
184
+ changed = true;
185
+ }
186
+ }
187
+ if (changed) {
188
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
189
+ writeCommentFile(docFilename, data);
190
+ }
191
+ }
192
+ catch { /* skip */ }
193
+ }
194
+ }
195
+ catch { /* dir doesn't exist */ }
196
+ return cleared;
197
+ }
198
+ /** Permanently remove comments from the sidecar. Distinct from resolveComments —
199
+ * resolve is a state change ("addressed, archive it"), delete is the destructive
200
+ * "this record never should have existed" path. */
201
+ export function deleteComments(ids) {
202
+ const idSet = new Set(ids);
203
+ const deleted = [];
204
+ ensureCommentsDir();
205
+ try {
206
+ const files = readdirSync(getCommentsDir());
207
+ for (const file of files) {
208
+ if (!file.endsWith('.json'))
209
+ continue;
210
+ const filePath = join(getCommentsDir(), file);
211
+ try {
212
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
213
+ const before = data.marks.length;
214
+ data.marks = data.marks.filter((m) => {
215
+ if (idSet.has(m.id)) {
216
+ deleted.push(m.id);
217
+ return false;
218
+ }
219
+ return true;
220
+ });
221
+ if (data.marks.length !== before) {
222
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
223
+ writeCommentFile(docFilename, data);
224
+ }
225
+ }
226
+ catch { /* skip */ }
227
+ }
228
+ }
229
+ catch { /* dir doesn't exist */ }
230
+ return deleted;
231
+ }
232
+ export function pruneStaleComments(filename, validNodeIds) {
233
+ const data = readCommentFile(filename);
234
+ if (data.marks.length === 0)
235
+ return 0;
236
+ const validSet = new Set(validNodeIds);
237
+ const before = data.marks.length;
238
+ data.marks = data.marks.filter((m) => {
239
+ if (m.nodeIds && m.nodeIds.length > 0) {
240
+ return m.nodeIds.some((id) => validSet.has(id));
241
+ }
242
+ return validSet.has(m.nodeId);
243
+ });
244
+ const pruned = before - data.marks.length;
245
+ if (pruned > 0)
246
+ writeCommentFile(filename, data);
247
+ return pruned;
248
+ }
249
+ /** Rename a comment sidecar file when a document is renamed. */
250
+ export function renameComments(oldFilename, newFilename) {
251
+ const oldPath = commentFilePath(oldFilename);
252
+ if (!existsSync(oldPath))
253
+ return;
254
+ const newPath = commentFilePath(newFilename);
255
+ renameSync(oldPath, newPath);
256
+ }
@@ -7,13 +7,14 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSy
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
- import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
+ import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
- import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
13
- import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
12
+ import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, markAsAgentStub, unmarkAgentStub, isAgentStub, } from './state.js';
13
+ import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath } from './helpers.js';
14
14
  import { ensureDocId } from './versions.js';
15
15
  import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
16
- import { renameMark } from './marks.js';
16
+ import { renameComments } from './comments.js';
17
+ import { deleteOverlay } from './pending-overlay.js';
17
18
  import { getDocId as getActiveDocId } from './state.js';
18
19
  function getDocOrderFile() { return join(getDataDir(), '_doc-order.json'); }
19
20
  /** Scan files for matching docId. Checks active doc first (free), then getDataDir(), then external docs. */
@@ -122,7 +123,7 @@ export function listDocuments() {
122
123
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
123
124
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
125
  ...(data.variantType ? { variantType: data.variantType } : {}),
125
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
126
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
126
127
  };
127
128
  }
128
129
  catch {
@@ -163,7 +164,7 @@ export function listDocuments() {
163
164
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
164
165
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
165
166
  ...(data.variantType ? { variantType: data.variantType } : {}),
166
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
167
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
167
168
  });
168
169
  }
169
170
  catch { /* skip unreadable external files */ }
@@ -276,7 +277,7 @@ export function archiveDocument(filename) {
276
277
  const next = remaining[0];
277
278
  const raw = readFileSync(next.path, 'utf-8');
278
279
  const parsed = markdownToTiptap(raw);
279
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
280
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
280
281
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
281
282
  }
282
283
  }
@@ -471,7 +472,7 @@ export function createDocument(title, content, path) {
471
472
  const metadata = { title: docTitle, docId: generateNodeId() };
472
473
  setActiveDocument(newDoc, docTitle, filePath, isTemp, undefined, metadata);
473
474
  // Write doc to disk
474
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
475
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
475
476
  ensureDataDir();
476
477
  atomicWriteFileSync(filePath, markdown);
477
478
  // Prepend to doc order so new docs appear at top and stay put after edits
@@ -518,10 +519,17 @@ export function createDocumentFile(title, path, extraMeta) {
518
519
  filename = filePath.split(/[/\\]/).pop();
519
520
  }
520
521
  const newDoc = { type: 'doc', content: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }] };
521
- const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true, ...extraMeta };
522
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
522
+ // No `agentCreated: true` in metadata stub status is in-memory only.
523
+ // adr: adr/agent-stub-model.md
524
+ const metadata = { title: docTitle, docId: generateNodeId(), ...extraMeta };
525
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
523
526
  ensureDataDir();
524
527
  atomicWriteFileSync(filePath, markdown);
528
+ // Mark this filename as a fresh agent stub. Process-lifetime only — any
529
+ // accepted content via subsequent save graduates it out of the set, and a
530
+ // server restart naturally forgets stub status (a stub that survives a
531
+ // restart is by definition no longer fresh).
532
+ markAsAgentStub(filename);
525
533
  // Prepend to doc order so new docs appear at top and stay put after edits
526
534
  const order = readDocOrder();
527
535
  const fn = filePath.split(/[/\\]/).pop();
@@ -536,6 +544,9 @@ export async function deleteDocument(filename) {
536
544
  const targetPath = resolveDocPath(filename);
537
545
  // Invalidate cache for deleted doc
538
546
  invalidateDocCache(targetPath);
547
+ // Remove stub status for the deleted filename so a future recreate with
548
+ // the same name doesn't inherit the prior stub flag.
549
+ unmarkAgentStub(filename);
539
550
  // Unregister if external
540
551
  if (isExternalDoc(filename)) {
541
552
  unregisterExternalDoc(targetPath);
@@ -545,9 +556,25 @@ export async function deleteDocument(filename) {
545
556
  throw new Error('Cannot delete the only document');
546
557
  }
547
558
  const isDeletingActive = targetPath === getFilePath();
559
+ // Read docId BEFORE deleting the file so we can retire its overlay sidecar
560
+ // in lockstep. The sidecar's lifecycle is bound to the docId's existence in
561
+ // the workspace; delete retires the docId, archive does not.
562
+ // adr: adr/pending-overlay-model.md
563
+ let docIdToRetire = '';
564
+ if (existsSync(targetPath)) {
565
+ try {
566
+ const raw = readFileSync(targetPath, 'utf-8');
567
+ const { data } = matter(raw);
568
+ if (typeof data?.docId === 'string')
569
+ docIdToRetire = data.docId;
570
+ }
571
+ catch { /* best-effort */ }
572
+ }
548
573
  if (!isExternalDoc(filename) && existsSync(targetPath)) {
549
574
  await trash(targetPath);
550
575
  }
576
+ if (docIdToRetire)
577
+ deleteOverlay(docIdToRetire);
551
578
  if (isDeletingActive) {
552
579
  const remaining = readdirSync(getDataDir())
553
580
  .filter((f) => f.endsWith('.md'))
@@ -557,7 +584,7 @@ export async function deleteDocument(filename) {
557
584
  const next = remaining[0];
558
585
  const raw = readFileSync(next.path, 'utf-8');
559
586
  const parsed = markdownToTiptap(raw);
560
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
587
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
561
588
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
562
589
  }
563
590
  }
@@ -574,7 +601,7 @@ export function reloadDocument() {
574
601
  const raw = readFileSync(filePath, 'utf-8');
575
602
  const parsed = markdownToTiptap(raw);
576
603
  const mtime = new Date(statSync(filePath).mtimeMs);
577
- setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
604
+ setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata, undefined);
578
605
  return { document: getDocument(), title: getTitle(), filename };
579
606
  }
580
607
  export function updateDocumentTitle(filename, newTitle) {
@@ -586,7 +613,7 @@ export function updateDocumentTitle(filename, newTitle) {
586
613
  const raw = readFileSync(filePath, 'utf-8');
587
614
  const parsed = markdownToTiptap(raw);
588
615
  const metadata = { ...parsed.metadata, title: newTitle };
589
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
616
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
590
617
  atomicWriteFileSync(filePath, markdown);
591
618
  // Update state if this is the active document
592
619
  const baseName = filePath.split(/[/\\]/).pop() || '';
@@ -594,42 +621,49 @@ export function updateDocumentTitle(filename, newTitle) {
594
621
  setActiveDocument(getDocument(), newTitle, filePath, baseName.startsWith(TEMP_PREFIX), undefined, metadata);
595
622
  }
596
623
  }
597
- /** Open an existing file from any path. Saves current doc, registers as external, sets as active. */
624
+ /** Open an existing file from any path. Saves current doc, registers as external, sets as active.
625
+ *
626
+ * Canonicalizes the input path at the boundary so opening the same physical
627
+ * file via different spellings (forward/back slash, drive-letter case,
628
+ * symlink) hits the same doc identity — same cache slot, same watcher
629
+ * subscription, same pending overlay.
630
+ * adr: adr/path-canonicalization.md */
598
631
  export function openFile(fullPath) {
599
632
  if (!existsSync(fullPath)) {
600
633
  throw new Error(`File not found: ${fullPath}`);
601
634
  }
635
+ const canonPath = canonicalizePath(fullPath);
602
636
  // Cancel any pending debounced save, then save current doc immediately
603
637
  cancelDebouncedSave();
604
638
  save();
605
639
  // Cache current doc before switching
606
640
  cacheActiveDocument();
607
641
  // Register as external if not in getDataDir()
608
- if (isExternalDoc(fullPath)) {
609
- registerExternalDoc(fullPath);
642
+ if (isExternalDoc(canonPath)) {
643
+ registerExternalDoc(canonPath);
610
644
  }
611
645
  // Check cache first — preserves stable node IDs
612
- const cached = getCachedDocument(fullPath);
646
+ const cached = getCachedDocument(canonPath);
613
647
  if (cached) {
614
- setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
615
- const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
648
+ setActiveDocument(cached.document, cached.title, canonPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
649
+ const filename = isExternalDoc(canonPath) ? canonPath : (canonPath.split(/[/\\]/).pop() || '');
616
650
  return { document: getDocument(), title: getTitle(), filename };
617
651
  }
618
- const raw = readFileSync(fullPath, 'utf-8');
652
+ const raw = readFileSync(canonPath, 'utf-8');
619
653
  const parsed = markdownToTiptap(raw);
620
- const mtime = new Date(statSync(fullPath).mtimeMs);
654
+ const mtime = new Date(statSync(canonPath).mtimeMs);
621
655
  ensureDocId(parsed.metadata);
622
656
  // Title fallback: use filename stem instead of "Untitled" for files without a title
623
657
  let title = parsed.title;
624
658
  if (title === 'Untitled') {
625
- const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
659
+ const stem = canonPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
626
660
  if (stem)
627
661
  title = stem;
628
662
  }
629
- const baseName = fullPath.split(/[/\\]/).pop() || '';
630
- setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
663
+ const baseName = canonPath.split(/[/\\]/).pop() || '';
664
+ setActiveDocument(parsed.document, title, canonPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
631
665
  // Use full path as filename for external docs, basename for getDataDir() docs
632
- const filename = isExternalDoc(fullPath) ? fullPath : baseName;
666
+ const filename = isExternalDoc(canonPath) ? canonPath : baseName;
633
667
  return { document: getDocument(), title: getTitle(), filename };
634
668
  }
635
669
  export function duplicateDocument(filename) {
@@ -654,7 +688,7 @@ export function duplicateDocument(filename) {
654
688
  }
655
689
  const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
656
690
  setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
657
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
691
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
658
692
  ensureDataDir();
659
693
  atomicWriteFileSync(filePath, markdown);
660
694
  const newFilename = filePath.split(/[/\\]/).pop();
@@ -695,10 +729,18 @@ export function promoteTempFile(newTitle) {
695
729
  // Invalidate old caches
696
730
  removePendingCacheEntry(oldFilename);
697
731
  invalidateDocCache(oldPath);
732
+ // Carry the agent-stub flag across the rename (if the doc was still a
733
+ // fresh stub when renamed — uncommon but possible). The Set is keyed by
734
+ // filename, so we must transfer the entry to the new key.
735
+ // adr: adr/agent-stub-model.md
736
+ if (isAgentStub(oldFilename)) {
737
+ unmarkAgentStub(oldFilename);
738
+ markAsAgentStub(newFilename);
739
+ }
698
740
  // Update workspace references
699
741
  renameDocInAllWorkspaces(oldFilename, newFilename, newTitle);
700
- // Rename marks sidecar
701
- renameMark(oldFilename, newFilename);
742
+ // Rename comments sidecar
743
+ renameComments(oldFilename, newFilename);
702
744
  return newFilename;
703
745
  }
704
746
  // ============================================================================
@@ -788,7 +830,7 @@ function resolveDocFile(filePath, action) {
788
830
  if (count === 0)
789
831
  return 0;
790
832
  // Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
791
- const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
833
+ const { markdown: newRaw } = tiptapToMarkdownChecked(doc, parsed.title, parsed.metadata);
792
834
  atomicWriteFileSync(filePath, newRaw);
793
835
  return count;
794
836
  }
@@ -2,7 +2,7 @@
2
2
  * Shared constants and utility functions for OpenWriter server.
3
3
  * Both state.ts and documents.ts import from here to avoid duplication.
4
4
  */
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync, realpathSync } from 'fs';
6
6
  import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { randomUUID } from 'crypto';
@@ -63,7 +63,36 @@ export function filePathForTitle(title) {
63
63
  export function tempFilePath() {
64
64
  return join(getDataDir(), `${TEMP_PREFIX}${randomUUID()}.md`);
65
65
  }
66
- // ---- Path resolution for external documents ----
66
+ /**
67
+ * Produce one canonical representation per physical file. Idempotent:
68
+ * `canonicalizePath(canonicalizePath(p)) === canonicalizePath(p)`.
69
+ *
70
+ * On Windows: resolves separator direction (`/` vs `\`), drive-letter
71
+ * case (`c:` vs `C:`), 8.3 short names, and symlinks — whenever the
72
+ * file exists. `realpathSync.native` is the OS asking itself "what's
73
+ * the real path of this thing?", which is the only authoritative
74
+ * answer.
75
+ *
76
+ * On Unix: resolves symlinks and normalizes relative segments.
77
+ *
78
+ * Falls back to `path.resolve` (absolute path with platform separators)
79
+ * when the file doesn't exist yet. That's a weaker form — it won't
80
+ * catch drive-letter case mismatches on a path to a not-yet-created
81
+ * file — but every openwriter identity boundary hits an existing file,
82
+ * so the fallback is a safety net rather than a primary path.
83
+ *
84
+ * adr: adr/path-canonicalization.md
85
+ */
86
+ export function canonicalizePath(p) {
87
+ if (!p)
88
+ return p;
89
+ try {
90
+ return realpathSync.native(p);
91
+ }
92
+ catch {
93
+ return resolve(p);
94
+ }
95
+ }
67
96
  /** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
68
97
  export function resolveDocPath(filename) {
69
98
  const dataDir = getDataDir();
@@ -78,13 +107,39 @@ export function resolveDocPath(filename) {
78
107
  }
79
108
  return resolved;
80
109
  }
81
- /** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
110
+ /**
111
+ * Canonicalize a doc identifier that might be a bare basename (internal
112
+ * doc) or an absolute path (external doc). Basenames pass through
113
+ * untouched; absolute paths route through `canonicalizePath`. Use this
114
+ * at WebSocket and HTTP boundaries where browser-sent identifiers can
115
+ * be either form and must compare equal against server-side
116
+ * `getActiveFilename()` regardless of how they were spelled.
117
+ *
118
+ * adr: adr/path-canonicalization.md
119
+ */
120
+ export function canonicalizeIdentifier(id) {
121
+ if (!id)
122
+ return id;
123
+ return isAbsolute(id) ? canonicalizePath(id) : id;
124
+ }
125
+ /**
126
+ * Returns true if filename is a full path pointing outside DATA_DIR.
127
+ *
128
+ * Canonicalizes both sides of the comparison so that mixed separators,
129
+ * drive-letter case, and symlink-resolved variants of the same file all
130
+ * classify consistently. The pre-canonicalization version compared raw
131
+ * strings via `startsWith`, which let `C:/Users/.../data-dir/foo.md`
132
+ * be classified as external on Windows because `getDataDir()` returns
133
+ * `C:\Users\...\data-dir` (different separators).
134
+ *
135
+ * adr: adr/path-canonicalization.md
136
+ */
82
137
  export function isExternalDoc(filename) {
83
- if (isAbsolute(filename) || /[/\\]/.test(filename)) {
84
- const resolved = isAbsolute(filename) ? filename : filename;
85
- return !resolved.startsWith(getDataDir());
86
- }
87
- return false;
138
+ if (!isAbsolute(filename) && !/[/\\]/.test(filename))
139
+ return false;
140
+ const canonFile = canonicalizePath(filename);
141
+ const canonDataDir = canonicalizePath(getDataDir());
142
+ return canonFile !== canonDataDir && !canonFile.startsWith(canonDataDir + sep);
88
143
  }
89
144
  /** Extract basename from a path, or return as-is if already a basename. */
90
145
  export function getDocBasename(filename) {