openwriter 0.2.2 → 0.3.1

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.
@@ -3,16 +3,23 @@
3
3
  * Uses compact wire format for token efficiency.
4
4
  * Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
5
5
  */
6
+ import { join } from 'path';
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
8
+ import { randomUUID } from 'crypto';
6
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
11
  import { z } from 'zod';
9
- import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
12
+ import { DATA_DIR, ensureDataDir } from './helpers.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, getDocId, getFilePath, } from './state.js';
10
14
  import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
11
- import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
15
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
12
16
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc } from './workspaces.js';
13
17
  import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
14
18
  import { importGoogleDoc } from './gdoc-import.js';
15
19
  import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
20
+ import { getUpdateInfo } from './update-check.js';
21
+ import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
22
+ import { markdownToTiptap } from './markdown.js';
16
23
  export const TOOL_REGISTRY = [
17
24
  {
18
25
  name: 'read_pad',
@@ -44,7 +51,7 @@ export const TOOL_REGISTRY = [
44
51
  return resolved;
45
52
  });
46
53
  const { count: appliedCount, lastNodeId } = applyChanges(processed);
47
- broadcastPendingDocsChanged();
54
+ // broadcastPendingDocsChanged() already fires via onChanges listener in ws.ts
48
55
  return {
49
56
  content: [{
50
57
  type: 'text',
@@ -63,7 +70,10 @@ export const TOOL_REGISTRY = [
63
70
  description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
64
71
  schema: {},
65
72
  handler: async () => {
66
- return { content: [{ type: 'text', text: JSON.stringify(getStatus()) }] };
73
+ const status = getStatus();
74
+ const latestVersion = getUpdateInfo();
75
+ const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
76
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
67
77
  },
68
78
  },
69
79
  {
@@ -106,14 +116,15 @@ export const TOOL_REGISTRY = [
106
116
  },
107
117
  {
108
118
  name: 'create_document',
109
- description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. Shows a sidebar spinner that persists until populate_document is called — always call populate_document next to add content. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
119
+ description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
110
120
  schema: {
111
121
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
112
122
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
113
123
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
114
124
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
125
+ empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
115
126
  },
116
- handler: async ({ title, path, workspace, container }) => {
127
+ handler: async ({ title, path, workspace, container, empty }) => {
117
128
  // Resolve workspace/container up front so spinner renders in the right place
118
129
  let wsTarget;
119
130
  if (workspace) {
@@ -126,24 +137,40 @@ export const TOOL_REGISTRY = [
126
137
  wsTarget = { wsFilename: ws.filename, containerId };
127
138
  broadcastWorkspacesChanged(); // Browser sees container structure before spinner
128
139
  }
129
- broadcastWritingStarted(title || 'Untitled', wsTarget);
130
- // Yield so the browser receives and renders the placeholder before heavy work
131
- await new Promise((resolve) => setTimeout(resolve, 200));
140
+ if (!empty) {
141
+ broadcastWritingStarted(title || 'Untitled', wsTarget);
142
+ // Yield so the browser receives and renders the spinner before heavy work
143
+ await new Promise((resolve) => setTimeout(resolve, 200));
144
+ }
132
145
  try {
133
146
  // Lock browser doc-updates: prevents race where browser sends a doc-update
134
147
  // for the previous document but server has already switched active doc.
135
148
  setAgentLock();
136
149
  const result = createDocument(title, undefined, path);
137
- setMetadata({ agentCreated: true });
138
- save(); // Persist agentCreated flag to frontmatter
139
- // Auto-add to workspace if specified (defer sidebar broadcasts to populate_document
140
- // so the real doc entry doesn't appear alongside the spinner placeholder)
150
+ // Auto-add to workspace if specified
141
151
  let wsInfo = '';
142
152
  if (wsTarget) {
143
153
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
144
154
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
145
155
  }
146
- // Spinner persists until populate_document is called
156
+ if (empty) {
157
+ // Immediate switch — no spinner, no populate_document needed
158
+ save();
159
+ broadcastDocumentsChanged();
160
+ broadcastWorkspacesChanged();
161
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
162
+ return {
163
+ content: [{
164
+ type: 'text',
165
+ text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
166
+ }],
167
+ };
168
+ }
169
+ // Two-step flow: spinner persists until populate_document is called
170
+ setMetadata({ agentCreated: true });
171
+ save(); // Persist agentCreated flag to frontmatter
172
+ broadcastDocumentsChanged();
173
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
147
174
  return {
148
175
  content: [{
149
176
  type: 'text',
@@ -152,7 +179,8 @@ export const TOOL_REGISTRY = [
152
179
  };
153
180
  }
154
181
  catch (err) {
155
- broadcastWritingFinished();
182
+ if (!empty)
183
+ broadcastWritingFinished();
156
184
  throw err;
157
185
  }
158
186
  },
@@ -181,6 +209,7 @@ export const TOOL_REGISTRY = [
181
209
  setAgentLock(); // Block browser doc-updates during population
182
210
  markAllNodesAsPending(doc, 'insert');
183
211
  updateDocument(doc);
212
+ updatePendingCacheForActiveDoc();
184
213
  save();
185
214
  // Broadcast sidebar updates first (deferred from create_document) so the doc
186
215
  // entry and spinner removal arrive in the same render cycle
@@ -270,6 +299,7 @@ export const TOOL_REGISTRY = [
270
299
  for (const key of removed)
271
300
  delete meta[key];
272
301
  save();
302
+ broadcastMetadataChanged(getMetadata());
273
303
  if (cleaned.title) {
274
304
  broadcastTitleChanged(cleaned.title);
275
305
  broadcastDocumentsChanged();
@@ -475,10 +505,10 @@ export const TOOL_REGISTRY = [
475
505
  },
476
506
  {
477
507
  name: 'import_gdoc',
478
- description: 'Import a Google Doc into OpenWriter. Accepts raw Google Doc JSON (from Google Docs API). If the doc has multiple HEADING_1 sections, splits into chapter files and creates a book manifest. Otherwise imports as a single document.',
508
+ description: 'Import a structured Google Doc into OpenWriter. Pass the raw JSON from the Google Docs API (the object with body.content). Converts headings, bold/italic, links, lists, and tables to markdown. Docs with 2+ HEADING_1 sections auto-split into chapter files with a workspace and "Chapters" container. Single-section docs become one file.',
479
509
  schema: {
480
- document: z.any().describe('Raw Google Doc JSON object (must have body.content)'),
481
- title: z.string().optional().describe('Book title. Defaults to the Google Doc title.'),
510
+ document: z.any().describe('Raw Google Doc JSON object from the Docs API (must have body.content)'),
511
+ title: z.string().optional().describe('Book/document title. Defaults to the Google Doc title.'),
482
512
  },
483
513
  handler: async ({ document, title }) => {
484
514
  const result = importGoogleDoc(document, title);
@@ -492,8 +522,141 @@ export const TOOL_REGISTRY = [
492
522
  return { content: [{ type: 'text', text }] };
493
523
  },
494
524
  },
525
+ {
526
+ name: 'generate_image',
527
+ description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
528
+ schema: {
529
+ prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
530
+ aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
531
+ set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
532
+ },
533
+ handler: async ({ prompt, aspect_ratio, set_cover }) => {
534
+ const apiKey = process.env.GEMINI_API_KEY;
535
+ if (!apiKey) {
536
+ return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
537
+ }
538
+ const { GoogleGenAI } = await import('@google/genai');
539
+ const ai = new GoogleGenAI({ apiKey });
540
+ const response = await ai.models.generateImages({
541
+ model: 'imagen-4.0-generate-001',
542
+ prompt,
543
+ config: {
544
+ numberOfImages: 1,
545
+ aspectRatio: (aspect_ratio || '16:9'),
546
+ },
547
+ });
548
+ const image = response.generatedImages?.[0];
549
+ if (!image?.image?.imageBytes) {
550
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
551
+ }
552
+ // Save to ~/.openwriter/_images/
553
+ ensureDataDir();
554
+ const imagesDir = join(DATA_DIR, '_images');
555
+ if (!existsSync(imagesDir))
556
+ mkdirSync(imagesDir, { recursive: true });
557
+ const filename = `${randomUUID().slice(0, 8)}.png`;
558
+ const filePath = join(imagesDir, filename);
559
+ writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
560
+ const src = `/_images/${filename}`;
561
+ // Optionally set as article cover + append to carousel history
562
+ if (set_cover) {
563
+ const meta = getMetadata();
564
+ const articleContext = meta.articleContext || {};
565
+ let existing = Array.isArray(articleContext.coverImages) ? articleContext.coverImages : [];
566
+ // Seed with current coverImage if array is empty (first carousel entry)
567
+ if (existing.length === 0 && articleContext.coverImage) {
568
+ existing = [articleContext.coverImage];
569
+ }
570
+ existing.push(src);
571
+ articleContext.coverImage = src;
572
+ articleContext.coverImages = existing;
573
+ setMetadata({ articleContext });
574
+ save();
575
+ broadcastMetadataChanged(getMetadata());
576
+ }
577
+ return {
578
+ content: [{
579
+ type: 'text',
580
+ text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
581
+ }],
582
+ };
583
+ },
584
+ },
585
+ {
586
+ name: 'list_versions',
587
+ description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
588
+ schema: {},
589
+ handler: async () => {
590
+ const docId = getDocId();
591
+ if (!docId)
592
+ return { content: [{ type: 'text', text: 'Error: No active document.' }] };
593
+ const versions = listVersions(docId);
594
+ if (versions.length === 0)
595
+ return { content: [{ type: 'text', text: 'No versions found for this document.' }] };
596
+ const lines = versions.map((v, i) => ` ${i + 1}. ${v.date} ts:${v.timestamp} ${v.wordCount.toLocaleString()} words ${(v.size / 1024).toFixed(1)}KB`);
597
+ return { content: [{ type: 'text', text: `versions (${versions.length}):\n${lines.join('\n')}` }] };
598
+ },
599
+ },
600
+ {
601
+ name: 'create_checkpoint',
602
+ description: 'Force a version snapshot of the active document right now. Use before risky operations as a safety net.',
603
+ schema: {},
604
+ handler: async () => {
605
+ const docId = getDocId();
606
+ const filePath = getFilePath();
607
+ if (!docId || !filePath)
608
+ return { content: [{ type: 'text', text: 'Error: No active document.' }] };
609
+ forceSnapshot(docId, filePath);
610
+ return { content: [{ type: 'text', text: `Checkpoint created at ${new Date().toISOString()}` }] };
611
+ },
612
+ },
613
+ {
614
+ name: 'restore_version',
615
+ description: 'Restore the active document to a previous version by timestamp. Automatically creates a safety checkpoint of the current state first. Get timestamps from list_versions.',
616
+ schema: {
617
+ timestamp: z.number().describe('Version timestamp to restore (from list_versions)'),
618
+ },
619
+ handler: async ({ timestamp }) => {
620
+ const docId = getDocId();
621
+ const filePath = getFilePath();
622
+ if (!docId || !filePath)
623
+ return { content: [{ type: 'text', text: 'Error: No active document.' }] };
624
+ // Safety net: snapshot current state before restoring
625
+ try {
626
+ forceSnapshot(docId, filePath);
627
+ }
628
+ catch { /* best effort */ }
629
+ const parsed = restoreVersion(docId, timestamp);
630
+ if (!parsed)
631
+ return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
632
+ updateDocument(parsed.document);
633
+ save();
634
+ const filename = filePath.split(/[/\\]/).pop() || '';
635
+ broadcastDocumentSwitched(parsed.document, parsed.title, filename);
636
+ return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
637
+ },
638
+ },
639
+ {
640
+ name: 'reload_from_disk',
641
+ description: 'Re-read the active document from its file on disk. Use when the file was modified externally and the editor needs to pick up changes. Does NOT rescan the full document list.',
642
+ schema: {},
643
+ handler: async () => {
644
+ const filePath = getFilePath();
645
+ if (!filePath)
646
+ return { content: [{ type: 'text', text: 'Error: No active document.' }] };
647
+ if (!existsSync(filePath))
648
+ return { content: [{ type: 'text', text: `Error: File not found: ${filePath}` }] };
649
+ const markdown = readFileSync(filePath, 'utf-8');
650
+ const parsed = markdownToTiptap(markdown);
651
+ updateDocument(parsed.document);
652
+ save();
653
+ const filename = filePath.split(/[/\\]/).pop() || '';
654
+ broadcastDocumentSwitched(parsed.document, parsed.title, filename);
655
+ return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
656
+ },
657
+ },
495
658
  ];
496
- /** Register MCP tools from plugins. Call before startMcpServer(). */
659
+ /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
497
660
  export function registerPluginTools(tools) {
498
661
  for (const tool of tools) {
499
662
  TOOL_REGISTRY.push({
@@ -518,7 +681,7 @@ export function removePluginTools(names) {
518
681
  }
519
682
  export async function startMcpServer() {
520
683
  const server = new McpServer({
521
- name: 'open-writer',
684
+ name: 'openwriter',
522
685
  version: '0.2.0',
523
686
  });
524
687
  for (const tool of TOOL_REGISTRY) {
@@ -143,6 +143,22 @@ export function setMetadata(updates) {
143
143
  state.metadata = { ...state.metadata, ...updates };
144
144
  if (updates.title)
145
145
  state.title = updates.title;
146
+ // Auto-tag: tweetContext / articleContext ↔ "x" tag
147
+ for (const key of ['tweetContext', 'articleContext']) {
148
+ if (key in updates) {
149
+ const filename = state.filePath
150
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
151
+ : '';
152
+ if (filename) {
153
+ if (updates[key]) {
154
+ addDocTag(filename, 'x');
155
+ }
156
+ else {
157
+ removeDocTag(filename, 'x');
158
+ }
159
+ }
160
+ }
161
+ }
146
162
  }
147
163
  export function getStatus() {
148
164
  return {
@@ -210,7 +226,7 @@ function transferPendingAttrs(source, target) {
210
226
  // ============================================================================
211
227
  // AGENT WRITE LOCK
212
228
  // ============================================================================
213
- const AGENT_LOCK_MS = 5000; // Block browser doc-updates for 5s after agent write
229
+ const AGENT_LOCK_MS = 1500; // Block browser doc-updates for 1.5s after agent write
214
230
  let lastAgentWriteTime = 0;
215
231
  /** Set the agent write lock (called after agent changes). */
216
232
  export function setAgentLock() {
@@ -249,6 +265,8 @@ export function applyChanges(changes) {
249
265
  }
250
266
  // Debounced save — coalesces rapid agent writes into a single disk write
251
267
  debouncedSave();
268
+ // Update pending doc cache for the active document
269
+ updatePendingCacheForActiveDoc();
252
270
  // Find the last created node ID for chaining inserts
253
271
  let lastNodeId = null;
254
272
  for (let i = processed.length - 1; i >= 0; i--) {
@@ -309,7 +327,7 @@ function applyChangesToDocument(changes) {
309
327
  if (!found)
310
328
  continue;
311
329
  const contentArray = Array.isArray(change.content) ? change.content : [change.content];
312
- const originalNode = JSON.parse(JSON.stringify(found.parent[found.index]));
330
+ const originalNode = structuredClone(found.parent[found.index]);
313
331
  // Only store original on first rewrite (preserve baseline for reject)
314
332
  const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
315
333
  // First node replaces the target (rewrite)
@@ -426,6 +444,65 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
426
444
  state.docId = ensureDocId(state.metadata);
427
445
  }
428
446
  // ============================================================================
447
+ // PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
448
+ // ============================================================================
449
+ /** In-memory cache: filename → pending change count. Populated on load(), updated incrementally. */
450
+ const pendingDocCache = new Map();
451
+ /** Get the active doc's filename identifier (mirrors getActiveFilename in documents.ts). */
452
+ function activeDocFilename() {
453
+ return state.filePath
454
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
455
+ : '';
456
+ }
457
+ /** Update the pending cache for the active document from in-memory state. */
458
+ export function updatePendingCacheForActiveDoc() {
459
+ const filename = activeDocFilename();
460
+ if (!filename)
461
+ return;
462
+ const count = getPendingChangeCount();
463
+ if (count > 0) {
464
+ pendingDocCache.set(filename, count);
465
+ }
466
+ else {
467
+ pendingDocCache.delete(filename);
468
+ }
469
+ }
470
+ /** Remove a filename from the pending cache (after pending attrs are stripped). */
471
+ export function removePendingCacheEntry(filename) {
472
+ pendingDocCache.delete(filename);
473
+ }
474
+ /** Populate the pending cache from a full disk scan. Called once on startup. */
475
+ function populatePendingCache() {
476
+ pendingDocCache.clear();
477
+ try {
478
+ const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
479
+ for (const f of files) {
480
+ try {
481
+ const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
482
+ const { data } = matter(raw);
483
+ if (data.pending && Object.keys(data.pending).length > 0) {
484
+ pendingDocCache.set(f, Object.keys(data.pending).length);
485
+ }
486
+ }
487
+ catch { /* skip unreadable files */ }
488
+ }
489
+ }
490
+ catch { /* ignore */ }
491
+ // Scan external docs
492
+ for (const extPath of externalDocs) {
493
+ try {
494
+ if (!existsSync(extPath))
495
+ continue;
496
+ const raw = readFileSync(extPath, 'utf-8');
497
+ const { data } = matter(raw);
498
+ if (data.pending && Object.keys(data.pending).length > 0) {
499
+ pendingDocCache.set(extPath, Object.keys(data.pending).length);
500
+ }
501
+ }
502
+ catch { /* skip unreadable files */ }
503
+ }
504
+ }
505
+ // ============================================================================
429
506
  // PENDING DOCUMENT STORE OPERATIONS
430
507
  // ============================================================================
431
508
  /** Check if a document (or the current doc) has any pending changes. */
@@ -460,6 +537,7 @@ export function stripPendingAttrs() {
460
537
  }
461
538
  }
462
539
  strip(state.document.content);
540
+ removePendingCacheEntry(activeDocFilename());
463
541
  }
464
542
  /**
465
543
  * Mark leaf block nodes as pending within a node array.
@@ -485,83 +563,15 @@ function markLeafBlocksAsPending(nodes, status) {
485
563
  export function markAllNodesAsPending(doc, status) {
486
564
  markLeafBlocksAsPending(doc.content, status);
487
565
  }
488
- /** Get filenames of all docs with pending changes (disk scan + external docs + current in-memory doc). */
489
- export function getPendingDocFilenames() {
566
+ /** Read pending doc info from in-memory cache (O(1) instead of disk scan). */
567
+ export function getPendingDocInfo() {
490
568
  const filenames = [];
491
- try {
492
- const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
493
- for (const f of files) {
494
- try {
495
- const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
496
- const { data } = matter(raw);
497
- if (data.pending && Object.keys(data.pending).length > 0) {
498
- filenames.push(f);
499
- }
500
- }
501
- catch { /* skip unreadable files */ }
502
- }
503
- }
504
- catch { /* ignore */ }
505
- // Scan external docs for pending frontmatter
506
- for (const extPath of externalDocs) {
507
- try {
508
- if (!existsSync(extPath))
509
- continue;
510
- const raw = readFileSync(extPath, 'utf-8');
511
- const { data } = matter(raw);
512
- if (data.pending && Object.keys(data.pending).length > 0) {
513
- filenames.push(extPath);
514
- }
515
- }
516
- catch { /* skip unreadable files */ }
517
- }
518
- // Check current in-memory doc (may have unsaved pending state)
519
- const currentFilename = state.filePath
520
- ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
521
- : '';
522
- if (currentFilename && hasPendingChanges() && !filenames.includes(currentFilename)) {
523
- filenames.push(currentFilename);
524
- }
525
- return filenames;
526
- }
527
- /** Get pending change counts per filename (disk scan + external docs + current in-memory doc). */
528
- export function getPendingDocCounts() {
529
569
  const counts = {};
530
- try {
531
- const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
532
- for (const f of files) {
533
- try {
534
- const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
535
- const { data } = matter(raw);
536
- if (data.pending && Object.keys(data.pending).length > 0) {
537
- counts[f] = Object.keys(data.pending).length;
538
- }
539
- }
540
- catch { /* skip unreadable files */ }
541
- }
570
+ for (const [filename, count] of pendingDocCache) {
571
+ filenames.push(filename);
572
+ counts[filename] = count;
542
573
  }
543
- catch { /* ignore */ }
544
- // Scan external docs
545
- for (const extPath of externalDocs) {
546
- try {
547
- if (!existsSync(extPath))
548
- continue;
549
- const raw = readFileSync(extPath, 'utf-8');
550
- const { data } = matter(raw);
551
- if (data.pending && Object.keys(data.pending).length > 0) {
552
- counts[extPath] = Object.keys(data.pending).length;
553
- }
554
- }
555
- catch { /* skip unreadable files */ }
556
- }
557
- // Current in-memory doc may have unsaved pending state
558
- const currentFilename = state.filePath
559
- ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
560
- : '';
561
- if (currentFilename && hasPendingChanges()) {
562
- counts[currentFilename] = getPendingChangeCount();
563
- }
564
- return counts;
574
+ return { filenames, counts };
565
575
  }
566
576
  // ============================================================================
567
577
  // PERSISTENCE
@@ -630,36 +640,45 @@ export function load() {
630
640
  return { name: f, path: fullPath, mtime: stat.mtimeMs };
631
641
  })
632
642
  .sort((a, b) => b.mtime - a.mtime);
633
- if (files.length === 0) {
634
- // No existing docs start fresh with temp file
635
- state.filePath = tempFilePath();
636
- state.isTemp = true;
637
- return;
638
- }
639
- // Open the most recent file
640
- const latest = files[0];
641
- try {
642
- const raw = readFileSync(latest.path, 'utf-8');
643
- const parsed = markdownToTiptap(raw);
644
- state.document = parsed.document;
645
- state.title = parsed.title;
646
- state.metadata = parsed.metadata;
647
- state.lastModified = new Date(statSync(latest.path).mtimeMs);
648
- state.filePath = latest.path;
649
- state.isTemp = latest.name.startsWith(TEMP_PREFIX);
650
- // Lazy docId migration: assign if missing, save to persist
651
- const hadDocId = !!state.metadata.docId;
652
- state.docId = ensureDocId(state.metadata);
653
- if (!hadDocId) {
654
- const md = tiptapToMarkdown(state.document, state.title, state.metadata);
655
- writeFileSync(state.filePath, md, 'utf-8');
643
+ // Walk sorted files until we find a real document with content.
644
+ // Skip empty temp files so we don't open a blank scratch pad when real docs exist.
645
+ for (const file of files) {
646
+ try {
647
+ const raw = readFileSync(file.path, 'utf-8');
648
+ const parsed = markdownToTiptap(raw);
649
+ const isTemp = file.name.startsWith(TEMP_PREFIX);
650
+ // Skip empty temp files — prefer a real document
651
+ if (isTemp && isDocEmpty(parsed.document))
652
+ continue;
653
+ state.document = parsed.document;
654
+ state.title = parsed.title;
655
+ state.metadata = parsed.metadata;
656
+ state.lastModified = new Date(statSync(file.path).mtimeMs);
657
+ state.filePath = file.path;
658
+ state.isTemp = isTemp;
659
+ // Lazy docId migration: assign if missing, save to persist
660
+ const hadDocId = !!state.metadata.docId;
661
+ state.docId = ensureDocId(state.metadata);
662
+ if (!hadDocId) {
663
+ const md = tiptapToMarkdown(state.document, state.title, state.metadata);
664
+ writeFileSync(state.filePath, md, 'utf-8');
665
+ }
666
+ break;
667
+ }
668
+ catch {
669
+ // Corrupt file — try next one
670
+ continue;
656
671
  }
657
672
  }
658
- catch {
659
- // Corrupt file — start fresh
673
+ // If nothing loaded (all files were empty temps or corrupt), start fresh
674
+ if (!state.filePath) {
660
675
  state.filePath = tempFilePath();
661
676
  state.isTemp = true;
662
677
  }
678
+ // Populate pending doc cache from disk (single scan on startup)
679
+ populatePendingCache();
680
+ // Overlay active doc's in-memory state (may have unsaved pending changes)
681
+ updatePendingCacheForActiveDoc();
663
682
  // Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
664
683
  setAgentLock();
665
684
  }
@@ -915,6 +934,7 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
915
934
  }
916
935
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
917
936
  writeFileSync(targetPath, markdown, 'utf-8');
937
+ removePendingCacheEntry(filename);
918
938
  }
919
939
  catch { /* best-effort */ }
920
940
  }