openwriter 0.14.0 → 0.16.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.
Files changed (43) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/index-JMMJM_G_.js +212 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/comments.js +256 -0
  21. package/dist/server/documents.js +293 -20
  22. package/dist/server/enrichment.js +114 -0
  23. package/dist/server/helpers.js +63 -8
  24. package/dist/server/index.js +94 -40
  25. package/dist/server/install-skill.js +15 -0
  26. package/dist/server/logger.js +246 -0
  27. package/dist/server/markdown-parse.js +71 -14
  28. package/dist/server/markdown-serialize.js +136 -41
  29. package/dist/server/mcp.js +538 -99
  30. package/dist/server/node-blocks.js +22 -4
  31. package/dist/server/node-fingerprint.js +347 -73
  32. package/dist/server/node-matcher.js +76 -49
  33. package/dist/server/pending-overlay.js +862 -0
  34. package/dist/server/state.js +1178 -98
  35. package/dist/server/versions.js +18 -0
  36. package/dist/server/workspaces.js +42 -5
  37. package/dist/server/ws.js +194 -37
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +51 -21
  40. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  41. package/skill/docs/enrichment.md +179 -0
  42. package/dist/client/assets/index-BxI3DazW.js +0 -212
  43. package/dist/client/assets/index-OV13QtgQ.css +0 -1
@@ -9,11 +9,13 @@ import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
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
- import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
16
- import { renameMark } from './marks.js';
15
+ import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces, listWorkspaces, getWorkspace } from './workspaces.js';
16
+ import { collectAllFiles } from './workspace-tree.js';
17
+ import { renameComments } from './comments.js';
18
+ import { deleteOverlay, diagLog } from './pending-overlay.js';
17
19
  import { getDocId as getActiveDocId } from './state.js';
18
20
  function getDocOrderFile() { return join(getDataDir(), '_doc-order.json'); }
19
21
  /** Scan files for matching docId. Checks active doc first (free), then getDataDir(), then external docs. */
@@ -123,6 +125,18 @@ export function listDocuments() {
123
125
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
126
  ...(data.variantType ? { variantType: data.variantType } : {}),
125
127
  ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
128
+ // Tags ride along with the doc listing so the sidebar can populate its
129
+ // tag overlay from one HTTP round-trip instead of N. The server already
130
+ // has the parsed frontmatter in hand here; emitting tags is free.
131
+ ...(Array.isArray(data.tags) && data.tags.length > 0 ? { tags: data.tags } : {}),
132
+ // Enrichment fields — also free at this point since data is in hand.
133
+ // See brief 2026-05-18-frontmatter-enrichment-system.
134
+ ...(typeof data.logline === 'string' && data.logline ? { logline: data.logline } : {}),
135
+ ...(typeof data.domain === 'string' && data.domain ? { domain: data.domain } : {}),
136
+ ...(Array.isArray(data.concepts) && data.concepts.length > 0 ? { concepts: data.concepts } : {}),
137
+ ...(typeof data.docRole === 'string' && data.docRole ? { docRole: data.docRole } : {}),
138
+ ...(typeof data.status === 'string' && data.status ? { status: data.status } : {}),
139
+ ...(data.enrichmentStale === true ? { enrichmentStale: true } : {}),
126
140
  };
127
141
  }
128
142
  catch {
@@ -237,6 +251,211 @@ export function listArchivedDocuments() {
237
251
  files.sort((a, b) => new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime());
238
252
  return files;
239
253
  }
254
+ // ============================================================================
255
+ // ENRICHMENT — list dirty docs + crawl + surfacing helpers
256
+ // See brief 2026-05-18-frontmatter-enrichment-system.
257
+ // ============================================================================
258
+ /** One-line footer the high-frequency MCP discovery tools (list_documents,
259
+ * list_workspaces, get_workspace_structure) append when dirty docs exist.
260
+ * Constant pressure that doesn't require hook setup. The agent's openwriter
261
+ * skill teaches: see this footer → spawn the enrichment minion. */
262
+ export function enrichmentFooter() {
263
+ const count = listDirtyDocs().length;
264
+ if (count === 0)
265
+ return '';
266
+ return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} need enrichment — spawn the enrichment minion (see openwriter skill docs/enrichment.md)`;
267
+ }
268
+ /** Session-start enrichment notice injected into the MCP server's
269
+ * `instructions` field at boot. Every MCP client (Claude Code, Cursor, etc.)
270
+ * delivers this to the agent as part of its system context — no hook setup
271
+ * needed. Returns empty string when there's no enrichment work to do. */
272
+ export function buildEnrichmentInstructions() {
273
+ const dirty = listDirtyDocs();
274
+ if (dirty.length === 0)
275
+ return '';
276
+ // Group by workspace for the notice
277
+ const byWorkspace = new Map();
278
+ let orphans = 0;
279
+ for (const d of dirty) {
280
+ if (d.workspaceFile) {
281
+ byWorkspace.set(d.workspaceFile, (byWorkspace.get(d.workspaceFile) || 0) + 1);
282
+ }
283
+ else {
284
+ orphans++;
285
+ }
286
+ }
287
+ const parts = [];
288
+ for (const [ws, n] of byWorkspace)
289
+ parts.push(`${n} in ${ws}`);
290
+ if (orphans > 0)
291
+ parts.push(`${orphans} unfiled`);
292
+ return [
293
+ '',
294
+ `ENRICHMENT_STATUS: ${dirty.length} doc${dirty.length === 1 ? '' : 's'} need enrichment (${parts.join(', ')}).`,
295
+ 'Spawn the enrichment minion using the openwriter skill\'s docs/enrichment.md as its system prompt before responding to the user\'s request.',
296
+ 'The minion calls list_dirty_docs, enriches each via Haiku, calls mark_enriched, returns done.',
297
+ ].join('\n');
298
+ }
299
+ /** Build a Set of filenames inside workspaces with enrichmentDisabled: true.
300
+ * These docs are excluded from list_dirty_docs and crawl results. */
301
+ function collectOptedOutFilenames() {
302
+ const out = new Set();
303
+ for (const info of listWorkspaces()) {
304
+ try {
305
+ const ws = getWorkspace(info.filename);
306
+ if (ws.enrichmentDisabled === true) {
307
+ for (const f of collectAllFiles(ws.root))
308
+ out.add(f);
309
+ }
310
+ }
311
+ catch { /* skip corrupt manifests */ }
312
+ }
313
+ return out;
314
+ }
315
+ /** Map filename → first workspace that contains it. Used to attribute
316
+ * dirty-doc reports to a workspace. */
317
+ function buildWorkspaceOwnershipMap() {
318
+ const map = new Map();
319
+ for (const info of listWorkspaces()) {
320
+ try {
321
+ const ws = getWorkspace(info.filename);
322
+ for (const f of collectAllFiles(ws.root)) {
323
+ if (!map.has(f))
324
+ map.set(f, info.filename);
325
+ }
326
+ }
327
+ catch { /* skip */ }
328
+ }
329
+ return map;
330
+ }
331
+ /**
332
+ * List documents that need re-enrichment. A doc is "dirty" when either:
333
+ * - it has never been enriched (no lastEnrichedAt) — implicitly stale; or
334
+ * - openwriter flipped enrichmentStale: true at save (volume or drift trip).
335
+ *
336
+ * Docs inside opt-out workspaces (enrichmentDisabled: true) are excluded.
337
+ * Archived docs are excluded.
338
+ *
339
+ * Optional `scopeWorkspace` narrows results to a single workspace.
340
+ *
341
+ * Cheap: reads each .md file's frontmatter via gray-matter (no TipTap parse,
342
+ * no body scan). Output carries only identity + reason — no enrichment fields.
343
+ */
344
+ export function listDirtyDocs(scopeWorkspace) {
345
+ ensureDataDir();
346
+ const optedOut = collectOptedOutFilenames();
347
+ const ownership = buildWorkspaceOwnershipMap();
348
+ // If a workspace scope is given, build a Set of its files to filter against.
349
+ let scopeFiles = null;
350
+ if (scopeWorkspace) {
351
+ try {
352
+ const ws = getWorkspace(scopeWorkspace);
353
+ scopeFiles = new Set(collectAllFiles(ws.root));
354
+ }
355
+ catch {
356
+ // Unknown workspace → return empty rather than throw
357
+ return [];
358
+ }
359
+ }
360
+ const out = [];
361
+ for (const f of readdirSync(getDataDir()).filter((f) => f.endsWith('.md'))) {
362
+ if (optedOut.has(f))
363
+ continue;
364
+ if (scopeFiles && !scopeFiles.has(f))
365
+ continue;
366
+ try {
367
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
368
+ const { data } = matter(raw);
369
+ if (data.archivedAt)
370
+ continue; // archived docs don't participate
371
+ const explicitStale = data.enrichmentStale === true;
372
+ const implicitStale = !data.lastEnrichedAt;
373
+ if (!explicitStale && !implicitStale)
374
+ continue;
375
+ out.push({
376
+ docId: data.docId || '',
377
+ filename: f,
378
+ title: data.title || f.replace(/\.md$/, ''),
379
+ ...(ownership.get(f) ? { workspaceFile: ownership.get(f) } : {}),
380
+ reason: explicitStale ? 'stale_flag' : 'never_enriched',
381
+ ...(typeof data.lastEnrichedAt === 'string' ? { lastEnrichedAt: data.lastEnrichedAt } : {}),
382
+ });
383
+ }
384
+ catch { /* skip unreadable */ }
385
+ }
386
+ return out;
387
+ }
388
+ /**
389
+ * Bulk-read primitive for agents building working sets. Returns enriched
390
+ * fields per doc, filtered by criteria. No bodies, no nodes/graveyard, no
391
+ * pending overlay state.
392
+ *
393
+ * Filters compose with AND semantics — a doc must match every supplied
394
+ * criterion. Empty filter object returns every non-archived doc with its
395
+ * enrichment fields (whatever's present in frontmatter).
396
+ *
397
+ * Optimization: one disk pass, one gray-matter parse per file.
398
+ */
399
+ export function crawlDocs(filter = {}) {
400
+ ensureDataDir();
401
+ // If a workspace scope is given, prebuild a set of its filenames.
402
+ let scopeFiles = null;
403
+ if (filter.workspaceFile) {
404
+ try {
405
+ const ws = getWorkspace(filter.workspaceFile);
406
+ scopeFiles = new Set(collectAllFiles(ws.root));
407
+ }
408
+ catch {
409
+ return [];
410
+ }
411
+ }
412
+ const out = [];
413
+ for (const f of readdirSync(getDataDir()).filter((f) => f.endsWith('.md'))) {
414
+ if (scopeFiles && !scopeFiles.has(f))
415
+ continue;
416
+ try {
417
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
418
+ const { data, content } = matter(raw);
419
+ if (data.archivedAt)
420
+ continue;
421
+ // Apply filters
422
+ if (filter.domain && data.domain !== filter.domain)
423
+ continue;
424
+ if (filter.docRole && data.docRole !== filter.docRole)
425
+ continue;
426
+ if (filter.hasLogline === true && !data.logline)
427
+ continue;
428
+ if (filter.hasLogline === false && data.logline)
429
+ continue;
430
+ if (filter.tags && filter.tags.length > 0) {
431
+ const docTags = Array.isArray(data.tags) ? data.tags : [];
432
+ if (!filter.tags.every((t) => docTags.includes(t)))
433
+ continue;
434
+ }
435
+ if (filter.concepts && filter.concepts.length > 0) {
436
+ const docConcepts = Array.isArray(data.concepts) ? data.concepts : [];
437
+ if (!filter.concepts.every((c) => docConcepts.includes(c)))
438
+ continue;
439
+ }
440
+ const trimmed = content.trim();
441
+ out.push({
442
+ docId: data.docId || '',
443
+ filename: f,
444
+ title: data.title || f.replace(/\.md$/, ''),
445
+ wordCount: trimmed ? trimmed.split(/\s+/).length : 0,
446
+ ...(typeof data.logline === 'string' && data.logline ? { logline: data.logline } : {}),
447
+ ...(typeof data.domain === 'string' && data.domain ? { domain: data.domain } : {}),
448
+ ...(Array.isArray(data.tags) && data.tags.length > 0 ? { tags: data.tags } : {}),
449
+ ...(Array.isArray(data.concepts) && data.concepts.length > 0 ? { concepts: data.concepts } : {}),
450
+ ...(typeof data.docRole === 'string' && data.docRole ? { docRole: data.docRole } : {}),
451
+ ...(typeof data.status === 'string' && data.status ? { status: data.status } : {}),
452
+ ...(data.enrichmentStale === true ? { enrichmentStale: true } : {}),
453
+ });
454
+ }
455
+ catch { /* skip */ }
456
+ }
457
+ return out;
458
+ }
240
459
  export function archiveDocument(filename) {
241
460
  ensureDataDir();
242
461
  const targetPath = resolveDocPath(filename);
@@ -380,15 +599,21 @@ export function searchDocuments(query, includeArchived = false) {
380
599
  return results;
381
600
  }
382
601
  export function switchDocument(filename) {
602
+ const tStart = performance.now();
603
+ const prevFilename = getActiveFilename();
383
604
  // No-op if already on this document — avoids save/reload cycle that can clear editor content
384
- if (filename === getActiveFilename()) {
605
+ if (filename === prevFilename) {
606
+ diagLog(`[Switch] NOOP ${filename} (${(performance.now() - tStart).toFixed(1)}ms)`);
385
607
  return { document: getDocument(), title: getTitle(), filename };
386
608
  }
387
609
  // Cancel any pending debounced save, then save current doc immediately.
388
610
  cancelDebouncedSave();
611
+ const tSaveStart = performance.now();
389
612
  save();
613
+ const tSaveEnd = performance.now();
390
614
  // Cache current doc before switching (preserves node IDs)
391
615
  cacheActiveDocument();
616
+ const tCacheEnd = performance.now();
392
617
  // Reset version counter — new document starts a fresh version lineage
393
618
  resetDocVersion();
394
619
  // Read target from disk — markdownToTiptap rehydrates pending state
@@ -404,15 +629,22 @@ export function switchDocument(filename) {
404
629
  const cached = getCachedDocument(targetPath);
405
630
  if (cached) {
406
631
  setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
632
+ const tEnd = performance.now();
633
+ diagLog(`[Switch] ${prevFilename} → ${filename} CACHE-HIT total=${(tEnd - tStart).toFixed(1)}ms save=${(tSaveEnd - tSaveStart).toFixed(1)}ms cache=${(tCacheEnd - tSaveEnd).toFixed(1)}ms setActive=${(tEnd - tCacheEnd).toFixed(1)}ms`);
407
634
  return { document: getDocument(), title: getTitle(), filename };
408
635
  }
636
+ const tReadStart = performance.now();
409
637
  const raw = readFileSync(targetPath, 'utf-8');
638
+ const tReadEnd = performance.now();
410
639
  const parsed = markdownToTiptap(raw);
640
+ const tParseEnd = performance.now();
411
641
  const mtime = new Date(statSync(targetPath).mtimeMs);
412
642
  // Ensure docId exists on loaded doc metadata (lazy migration)
413
643
  ensureDocId(parsed.metadata);
414
644
  const baseName = targetPath.split(/[/\\]/).pop() || '';
415
645
  setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
646
+ const tEnd = performance.now();
647
+ diagLog(`[Switch] ${prevFilename} → ${filename} CACHE-MISS total=${(tEnd - tStart).toFixed(1)}ms save=${(tSaveEnd - tSaveStart).toFixed(1)}ms cache=${(tCacheEnd - tSaveEnd).toFixed(1)}ms read=${(tReadEnd - tReadStart).toFixed(1)}ms parse=${(tParseEnd - tReadEnd).toFixed(1)}ms setActive=${(tEnd - tParseEnd).toFixed(1)}ms`);
416
648
  return { document: getDocument(), title: getTitle(), filename };
417
649
  }
418
650
  export function createDocument(title, content, path) {
@@ -518,10 +750,17 @@ export function createDocumentFile(title, path, extraMeta) {
518
750
  filename = filePath.split(/[/\\]/).pop();
519
751
  }
520
752
  const newDoc = { type: 'doc', content: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }] };
521
- const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true, ...extraMeta };
753
+ // No `agentCreated: true` in metadata stub status is in-memory only.
754
+ // adr: adr/agent-stub-model.md
755
+ const metadata = { title: docTitle, docId: generateNodeId(), ...extraMeta };
522
756
  const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
523
757
  ensureDataDir();
524
758
  atomicWriteFileSync(filePath, markdown);
759
+ // Mark this filename as a fresh agent stub. Process-lifetime only — any
760
+ // accepted content via subsequent save graduates it out of the set, and a
761
+ // server restart naturally forgets stub status (a stub that survives a
762
+ // restart is by definition no longer fresh).
763
+ markAsAgentStub(filename);
525
764
  // Prepend to doc order so new docs appear at top and stay put after edits
526
765
  const order = readDocOrder();
527
766
  const fn = filePath.split(/[/\\]/).pop();
@@ -536,6 +775,9 @@ export async function deleteDocument(filename) {
536
775
  const targetPath = resolveDocPath(filename);
537
776
  // Invalidate cache for deleted doc
538
777
  invalidateDocCache(targetPath);
778
+ // Remove stub status for the deleted filename so a future recreate with
779
+ // the same name doesn't inherit the prior stub flag.
780
+ unmarkAgentStub(filename);
539
781
  // Unregister if external
540
782
  if (isExternalDoc(filename)) {
541
783
  unregisterExternalDoc(targetPath);
@@ -545,9 +787,25 @@ export async function deleteDocument(filename) {
545
787
  throw new Error('Cannot delete the only document');
546
788
  }
547
789
  const isDeletingActive = targetPath === getFilePath();
790
+ // Read docId BEFORE deleting the file so we can retire its overlay sidecar
791
+ // in lockstep. The sidecar's lifecycle is bound to the docId's existence in
792
+ // the workspace; delete retires the docId, archive does not.
793
+ // adr: adr/pending-overlay-model.md
794
+ let docIdToRetire = '';
795
+ if (existsSync(targetPath)) {
796
+ try {
797
+ const raw = readFileSync(targetPath, 'utf-8');
798
+ const { data } = matter(raw);
799
+ if (typeof data?.docId === 'string')
800
+ docIdToRetire = data.docId;
801
+ }
802
+ catch { /* best-effort */ }
803
+ }
548
804
  if (!isExternalDoc(filename) && existsSync(targetPath)) {
549
805
  await trash(targetPath);
550
806
  }
807
+ if (docIdToRetire)
808
+ deleteOverlay(docIdToRetire);
551
809
  if (isDeletingActive) {
552
810
  const remaining = readdirSync(getDataDir())
553
811
  .filter((f) => f.endsWith('.md'))
@@ -594,42 +852,49 @@ export function updateDocumentTitle(filename, newTitle) {
594
852
  setActiveDocument(getDocument(), newTitle, filePath, baseName.startsWith(TEMP_PREFIX), undefined, metadata);
595
853
  }
596
854
  }
597
- /** Open an existing file from any path. Saves current doc, registers as external, sets as active. */
855
+ /** Open an existing file from any path. Saves current doc, registers as external, sets as active.
856
+ *
857
+ * Canonicalizes the input path at the boundary so opening the same physical
858
+ * file via different spellings (forward/back slash, drive-letter case,
859
+ * symlink) hits the same doc identity — same cache slot, same watcher
860
+ * subscription, same pending overlay.
861
+ * adr: adr/path-canonicalization.md */
598
862
  export function openFile(fullPath) {
599
863
  if (!existsSync(fullPath)) {
600
864
  throw new Error(`File not found: ${fullPath}`);
601
865
  }
866
+ const canonPath = canonicalizePath(fullPath);
602
867
  // Cancel any pending debounced save, then save current doc immediately
603
868
  cancelDebouncedSave();
604
869
  save();
605
870
  // Cache current doc before switching
606
871
  cacheActiveDocument();
607
872
  // Register as external if not in getDataDir()
608
- if (isExternalDoc(fullPath)) {
609
- registerExternalDoc(fullPath);
873
+ if (isExternalDoc(canonPath)) {
874
+ registerExternalDoc(canonPath);
610
875
  }
611
876
  // Check cache first — preserves stable node IDs
612
- const cached = getCachedDocument(fullPath);
877
+ const cached = getCachedDocument(canonPath);
613
878
  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() || '');
879
+ setActiveDocument(cached.document, cached.title, canonPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
880
+ const filename = isExternalDoc(canonPath) ? canonPath : (canonPath.split(/[/\\]/).pop() || '');
616
881
  return { document: getDocument(), title: getTitle(), filename };
617
882
  }
618
- const raw = readFileSync(fullPath, 'utf-8');
883
+ const raw = readFileSync(canonPath, 'utf-8');
619
884
  const parsed = markdownToTiptap(raw);
620
- const mtime = new Date(statSync(fullPath).mtimeMs);
885
+ const mtime = new Date(statSync(canonPath).mtimeMs);
621
886
  ensureDocId(parsed.metadata);
622
887
  // Title fallback: use filename stem instead of "Untitled" for files without a title
623
888
  let title = parsed.title;
624
889
  if (title === 'Untitled') {
625
- const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
890
+ const stem = canonPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
626
891
  if (stem)
627
892
  title = stem;
628
893
  }
629
- const baseName = fullPath.split(/[/\\]/).pop() || '';
630
- setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
894
+ const baseName = canonPath.split(/[/\\]/).pop() || '';
895
+ setActiveDocument(parsed.document, title, canonPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
631
896
  // Use full path as filename for external docs, basename for getDataDir() docs
632
- const filename = isExternalDoc(fullPath) ? fullPath : baseName;
897
+ const filename = isExternalDoc(canonPath) ? canonPath : baseName;
633
898
  return { document: getDocument(), title: getTitle(), filename };
634
899
  }
635
900
  export function duplicateDocument(filename) {
@@ -695,10 +960,18 @@ export function promoteTempFile(newTitle) {
695
960
  // Invalidate old caches
696
961
  removePendingCacheEntry(oldFilename);
697
962
  invalidateDocCache(oldPath);
963
+ // Carry the agent-stub flag across the rename (if the doc was still a
964
+ // fresh stub when renamed — uncommon but possible). The Set is keyed by
965
+ // filename, so we must transfer the entry to the new key.
966
+ // adr: adr/agent-stub-model.md
967
+ if (isAgentStub(oldFilename)) {
968
+ unmarkAgentStub(oldFilename);
969
+ markAsAgentStub(newFilename);
970
+ }
698
971
  // Update workspace references
699
972
  renameDocInAllWorkspaces(oldFilename, newFilename, newTitle);
700
- // Rename marks sidecar
701
- renameMark(oldFilename, newFilename);
973
+ // Rename comments sidecar
974
+ renameComments(oldFilename, newFilename);
702
975
  return newFilename;
703
976
  }
704
977
  // ============================================================================
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Frontmatter enrichment staleness detection.
3
+ *
4
+ * The matcher already splits every block into sentences and hashes each one on
5
+ * every save (see node-fingerprint.ts). We reuse that machinery here — no new
6
+ * algorithm, no new splitter. Save-time staleness is a small tag-on after the
7
+ * matcher: harvest the current sentence-hash set + char count, compare against
8
+ * the at-enrichment baseline stored in frontmatter, set `enrichmentStale: true`
9
+ * when either threshold trips.
10
+ *
11
+ * Volume ratio captures growth and shrinkage symmetrically. Jaccard distance
12
+ * over the sentence-hash set captures rewrites at constant length. Either
13
+ * tripping flags the doc.
14
+ *
15
+ * OpenWriter owns "is this doc stale". The agent clears the flag via
16
+ * mark_enriched (Phase 4). Both sides read the same field, never compute it
17
+ * independently.
18
+ *
19
+ * See brief: 2026-05-18-frontmatter-enrichment-system.
20
+ */
21
+ import { splitSentences, simpleHash } from './node-fingerprint.js';
22
+ /** Volume-ratio threshold above which a doc is flagged stale by size delta. */
23
+ export const DEFAULT_ENRICHMENT_VOLUME_THRESHOLD = 1.5;
24
+ /** Jaccard-distance threshold above which a doc is flagged stale by drift. */
25
+ export const DEFAULT_ENRICHMENT_DRIFT_THRESHOLD = 0.3;
26
+ /**
27
+ * Flatten every block's per-sentence hashes into one sorted unique set.
28
+ * Sorted so the on-disk representation is stable across saves (no spurious
29
+ * frontmatter diffs from set-order drift). Unique so duplicate sentences
30
+ * in the same doc don't double-count in the Jaccard math.
31
+ */
32
+ export function harvestSentenceHashes(blocks) {
33
+ const set = new Set();
34
+ for (const block of blocks) {
35
+ const sentences = splitSentences(block.text || '');
36
+ for (const s of sentences) {
37
+ set.add(simpleHash(s.text + s.terminator));
38
+ }
39
+ }
40
+ return Array.from(set).sort();
41
+ }
42
+ /** Total char count across all blocks' text — the volume signal. */
43
+ export function harvestCharCount(blocks) {
44
+ let n = 0;
45
+ for (const b of blocks)
46
+ n += (b.text || '').length;
47
+ return n;
48
+ }
49
+ /**
50
+ * Symmetric size delta. Returns 1 when sizes match, grows toward infinity as
51
+ * they diverge in either direction. Handles zero-size docs safely.
52
+ */
53
+ export function volumeRatio(current, baseline) {
54
+ if (current === 0 && baseline === 0)
55
+ return 1;
56
+ if (current === 0 || baseline === 0)
57
+ return Infinity;
58
+ return Math.max(current, baseline) / Math.min(current, baseline);
59
+ }
60
+ /**
61
+ * Jaccard distance over two sentence-hash sets. 0 = identical, 1 = disjoint.
62
+ * (union - intersection) / union. Empty-vs-empty returns 0.
63
+ */
64
+ export function jaccardDistance(a, b) {
65
+ if (a.length === 0 && b.length === 0)
66
+ return 0;
67
+ const setA = new Set(a);
68
+ const setB = new Set(b);
69
+ let intersection = 0;
70
+ for (const x of setA)
71
+ if (setB.has(x))
72
+ intersection++;
73
+ const union = setA.size + setB.size - intersection;
74
+ return union === 0 ? 0 : (union - intersection) / union;
75
+ }
76
+ /**
77
+ * Compute staleness for a single doc given current matcher-derived signals
78
+ * and the at-enrichment baseline stored in its frontmatter.
79
+ *
80
+ * Returns true when:
81
+ * - the doc has never been enriched (no lastEnrichedAt) — brief: "absent flag = stale"
82
+ * - volumeRatio trips its threshold
83
+ * - Jaccard drift trips its threshold
84
+ *
85
+ * Thresholds: doc-level overrides first, then global defaults. Workspace-level
86
+ * overrides (per the brief) will be layered in when the surfacing handlers
87
+ * (Phase 6) get a workspace pointer — for now the doc carries no workspace
88
+ * reference in writeToDisk's scope.
89
+ */
90
+ export function isEnrichmentStale(currentSentenceHashes, currentCharCount, metadata, workspaceOverrides) {
91
+ // Never enriched → stale by default. New docs land here.
92
+ if (!metadata.lastEnrichedAt)
93
+ return true;
94
+ const baselineHashes = Array.isArray(metadata.lastEnrichedSentences)
95
+ ? metadata.lastEnrichedSentences
96
+ : [];
97
+ const baselineChars = typeof metadata.lastEnrichedCharCount === 'number'
98
+ ? metadata.lastEnrichedCharCount
99
+ : 0;
100
+ const volTh = pickThreshold(metadata.enrichmentVolumeThreshold, workspaceOverrides?.volume, DEFAULT_ENRICHMENT_VOLUME_THRESHOLD);
101
+ const driftTh = pickThreshold(metadata.enrichmentDriftThreshold, workspaceOverrides?.drift, DEFAULT_ENRICHMENT_DRIFT_THRESHOLD);
102
+ if (volumeRatio(currentCharCount, baselineChars) >= volTh)
103
+ return true;
104
+ if (jaccardDistance(currentSentenceHashes, baselineHashes) >= driftTh)
105
+ return true;
106
+ return false;
107
+ }
108
+ function pickThreshold(docLevel, wsLevel, fallback) {
109
+ if (typeof docLevel === 'number' && docLevel > 0)
110
+ return docLevel;
111
+ if (typeof wsLevel === 'number' && wsLevel > 0)
112
+ return wsLevel;
113
+ return fallback;
114
+ }
@@ -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) {