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.
- package/dist/client/assets/index-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/server/documents.js
CHANGED
|
@@ -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 {
|
|
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 ===
|
|
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
|
-
|
|
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(
|
|
609
|
-
registerExternalDoc(
|
|
873
|
+
if (isExternalDoc(canonPath)) {
|
|
874
|
+
registerExternalDoc(canonPath);
|
|
610
875
|
}
|
|
611
876
|
// Check cache first — preserves stable node IDs
|
|
612
|
-
const cached = getCachedDocument(
|
|
877
|
+
const cached = getCachedDocument(canonPath);
|
|
613
878
|
if (cached) {
|
|
614
|
-
setActiveDocument(cached.document, cached.title,
|
|
615
|
-
const filename = isExternalDoc(
|
|
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(
|
|
883
|
+
const raw = readFileSync(canonPath, 'utf-8');
|
|
619
884
|
const parsed = markdownToTiptap(raw);
|
|
620
|
-
const mtime = new Date(statSync(
|
|
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 =
|
|
890
|
+
const stem = canonPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
|
|
626
891
|
if (stem)
|
|
627
892
|
title = stem;
|
|
628
893
|
}
|
|
629
|
-
const baseName =
|
|
630
|
-
setActiveDocument(parsed.document, title,
|
|
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(
|
|
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
|
|
701
|
-
|
|
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
|
+
}
|
package/dist/server/helpers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
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) {
|