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.
- package/README.md +3 -3
- package/dist/bin/pad.js +35 -3
- package/dist/client/assets/index-BTxdHrWL.js +209 -0
- package/dist/client/assets/index-C9E86o6p.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +50 -41
- package/dist/server/markdown-parse.js +14 -1
- package/dist/server/markdown-serialize.js +7 -3
- package/dist/server/mcp-client.js +29 -28
- package/dist/server/mcp.js +183 -20
- package/dist/server/state.js +121 -101
- package/dist/server/tweet-routes.js +98 -0
- package/dist/server/update-check.js +96 -0
- package/dist/server/ws.js +66 -13
- package/package.json +2 -1
- package/skill/SKILL.md +14 -11
- package/dist/client/assets/index-De-jpZgc.css +0 -1
- package/dist/client/assets/index-FOERHzGc.js +0 -205
package/dist/server/mcp.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: '
|
|
684
|
+
name: 'openwriter',
|
|
522
685
|
version: '0.2.0',
|
|
523
686
|
});
|
|
524
687
|
for (const tool of TOOL_REGISTRY) {
|
package/dist/server/state.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
/**
|
|
489
|
-
export function
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
}
|