openwriter 0.5.1 → 0.5.3
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/bin/pad.js +66 -9
- package/dist/client/assets/index-BAbqg4Q8.js +210 -0
- package/dist/client/assets/index-BR_sMmFf.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +30 -1
- package/dist/server/documents.js +303 -2
- package/dist/server/index.js +103 -37
- package/dist/server/marks.js +166 -0
- package/dist/server/mcp.js +272 -55
- package/dist/server/state.js +7 -1
- package/dist/server/workspaces.js +16 -0
- package/dist/server/ws.js +18 -3
- package/package.json +2 -4
- package/skill/SKILL.md +34 -2
- package/dist/client/assets/index-Be3gaGeo.css +0 -1
- package/dist/client/assets/index-BwT1KW6a.js +0 -207
package/dist/server/mcp.js
CHANGED
|
@@ -9,17 +9,38 @@ import { randomUUID } from 'crypto';
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { DATA_DIR, ensureDataDir } from './helpers.js';
|
|
12
|
+
import { DATA_DIR, ensureDataDir, resolveDocPath } from './helpers.js';
|
|
13
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
|
|
14
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename, updateDocumentTitle } from './documents.js';
|
|
14
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
|
|
15
15
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
16
16
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, renameWorkspace, renameContainer } from './workspaces.js';
|
|
17
|
-
import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
|
|
17
|
+
import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
|
|
18
18
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
19
|
-
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
19
|
+
import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
|
|
20
|
+
import matter from 'gray-matter';
|
|
20
21
|
import { getUpdateInfo } from './update-check.js';
|
|
21
22
|
import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
|
|
22
23
|
import { markdownToTiptap } from './markdown.js';
|
|
24
|
+
import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
|
|
25
|
+
import { broadcastMarksChanged } from './ws.js';
|
|
26
|
+
/** Check if a document is in tweet compose mode (has tweetContext metadata). */
|
|
27
|
+
function isTweetDoc(filename) {
|
|
28
|
+
if (!filename || filename === getActiveFilename()) {
|
|
29
|
+
return !!getMetadata()?.tweetContext;
|
|
30
|
+
}
|
|
31
|
+
const targetPath = resolveDocPath(filename);
|
|
32
|
+
const cached = getCachedDocument(targetPath);
|
|
33
|
+
if (cached)
|
|
34
|
+
return !!cached.metadata?.tweetContext;
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
37
|
+
const { data } = matter(raw);
|
|
38
|
+
return !!data?.tweetContext;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
23
44
|
export const TOOL_REGISTRY = [
|
|
24
45
|
{
|
|
25
46
|
name: 'read_pad',
|
|
@@ -27,13 +48,23 @@ export const TOOL_REGISTRY = [
|
|
|
27
48
|
schema: {},
|
|
28
49
|
handler: async () => {
|
|
29
50
|
const doc = getDocument();
|
|
30
|
-
const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount());
|
|
31
|
-
|
|
51
|
+
const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount(), getDocId());
|
|
52
|
+
const activeFile = getActiveFilename();
|
|
53
|
+
const localCount = getMarkCount(activeFile);
|
|
54
|
+
const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(activeFile);
|
|
55
|
+
let hint = '';
|
|
56
|
+
if (localCount > 0)
|
|
57
|
+
hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
|
|
58
|
+
if (otherMarks > 0)
|
|
59
|
+
hint += `\n[${otherMarks} agent mark${otherMarks !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
60
|
+
if (hint)
|
|
61
|
+
hint += '\n[call get_agent_marks to review]';
|
|
62
|
+
return { content: [{ type: 'text', text: compact + hint }] };
|
|
32
63
|
},
|
|
33
64
|
},
|
|
34
65
|
{
|
|
35
66
|
name: 'write_to_pad',
|
|
36
|
-
description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts.
|
|
67
|
+
description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts. Target document by docId (8-char hex from list_documents or read_pad).',
|
|
37
68
|
schema: {
|
|
38
69
|
changes: z.array(z.object({
|
|
39
70
|
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
@@ -41,13 +72,18 @@ export const TOOL_REGISTRY = [
|
|
|
41
72
|
afterNodeId: z.string().optional(),
|
|
42
73
|
content: z.any().optional(),
|
|
43
74
|
})).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
|
|
44
|
-
|
|
75
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
45
76
|
},
|
|
46
|
-
handler: async ({ changes,
|
|
77
|
+
handler: async ({ changes, docId }) => {
|
|
78
|
+
const filename = resolveDocId(docId);
|
|
79
|
+
const tweetMode = isTweetDoc(filename);
|
|
47
80
|
const processed = changes.map((change) => {
|
|
48
81
|
const resolved = { ...change };
|
|
49
82
|
if (typeof resolved.content === 'string') {
|
|
50
|
-
|
|
83
|
+
let nodes = parseMarkdownContent(resolved.content);
|
|
84
|
+
if (tweetMode)
|
|
85
|
+
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
86
|
+
resolved.content = nodes;
|
|
51
87
|
}
|
|
52
88
|
return resolved;
|
|
53
89
|
});
|
|
@@ -105,30 +141,32 @@ export const TOOL_REGISTRY = [
|
|
|
105
141
|
},
|
|
106
142
|
{
|
|
107
143
|
name: 'list_documents',
|
|
108
|
-
description: 'List all documents
|
|
144
|
+
description: 'List all documents. Shows title, docId (8-char hex), word count, last modified date, and which document is active. Use the docId to target documents in other tools.',
|
|
109
145
|
schema: {},
|
|
110
146
|
handler: async () => {
|
|
111
147
|
const docs = listDocuments();
|
|
112
148
|
const lines = docs.map((d) => {
|
|
113
149
|
const active = d.isActive ? ' (active)' : '';
|
|
150
|
+
const id = d.docId ? ` [${d.docId}]` : '';
|
|
114
151
|
const date = d.lastModified.split('T')[0];
|
|
115
|
-
return ` ${d.
|
|
152
|
+
return ` "${d.title}"${id}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
116
153
|
});
|
|
117
154
|
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}` }] };
|
|
118
155
|
},
|
|
119
156
|
},
|
|
120
157
|
{
|
|
121
158
|
name: 'switch_document',
|
|
122
|
-
description: 'Switch to a different document
|
|
159
|
+
description: 'Switch to a different document. Saves the current document first. Returns a compact read of the newly active document. Target document by docId (8-char hex from list_documents or read_pad).',
|
|
123
160
|
schema: {
|
|
124
|
-
|
|
161
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
125
162
|
},
|
|
126
|
-
handler: async ({
|
|
163
|
+
handler: async ({ docId }) => {
|
|
164
|
+
const filename = resolveDocId(docId);
|
|
127
165
|
broadcastWritingFinished(); // Clear any in-progress creation spinner
|
|
128
166
|
const result = switchDocument(filename);
|
|
129
167
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
130
|
-
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
131
|
-
return { content: [{ type: 'text', text: `Switched to "${result.title}"\n\n${compact}` }] };
|
|
168
|
+
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), getDocId());
|
|
169
|
+
return { content: [{ type: 'text', text: `Switched to "${result.title}" [${docId}]\n\n${compact}` }] };
|
|
132
170
|
},
|
|
133
171
|
},
|
|
134
172
|
{
|
|
@@ -160,18 +198,16 @@ export const TOOL_REGISTRY = [
|
|
|
160
198
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
161
199
|
}
|
|
162
200
|
try {
|
|
163
|
-
// Lock browser doc-updates: prevents race where browser sends a doc-update
|
|
164
|
-
// for the previous document but server has already switched active doc.
|
|
165
|
-
setAgentLock();
|
|
166
|
-
const result = createDocument(title, undefined, path);
|
|
167
|
-
// Auto-add to workspace if specified
|
|
168
|
-
let wsInfo = '';
|
|
169
|
-
if (wsTarget) {
|
|
170
|
-
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
171
|
-
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
172
|
-
}
|
|
173
201
|
if (empty) {
|
|
174
202
|
// Immediate switch — no spinner, no populate_document needed
|
|
203
|
+
setAgentLock();
|
|
204
|
+
const result = createDocument(title, undefined, path);
|
|
205
|
+
let wsInfo = '';
|
|
206
|
+
if (wsTarget) {
|
|
207
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
208
|
+
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
209
|
+
}
|
|
210
|
+
const newDocId = getDocId();
|
|
175
211
|
save();
|
|
176
212
|
broadcastDocumentsChanged();
|
|
177
213
|
broadcastWorkspacesChanged();
|
|
@@ -179,19 +215,23 @@ export const TOOL_REGISTRY = [
|
|
|
179
215
|
return {
|
|
180
216
|
content: [{
|
|
181
217
|
type: 'text',
|
|
182
|
-
text: `Created "${result.title}"
|
|
218
|
+
text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
|
|
183
219
|
}],
|
|
184
220
|
};
|
|
185
221
|
}
|
|
186
|
-
// Two-step flow:
|
|
187
|
-
|
|
188
|
-
|
|
222
|
+
// Two-step flow: create file on disk WITHOUT switching the user's view.
|
|
223
|
+
// The spinner persists in the sidebar until populate_document is called.
|
|
224
|
+
const result = createDocumentFile(title, path);
|
|
225
|
+
let wsInfo = '';
|
|
226
|
+
if (wsTarget) {
|
|
227
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
228
|
+
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
229
|
+
}
|
|
189
230
|
broadcastDocumentsChanged();
|
|
190
|
-
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
|
|
191
231
|
return {
|
|
192
232
|
content: [{
|
|
193
233
|
type: 'text',
|
|
194
|
-
text: `Created "${result.title}"
|
|
234
|
+
text: `Created "${result.title}" [${result.docId}]${wsInfo} — empty. Call populate_document with docId "${result.docId}" to add content.`,
|
|
195
235
|
}],
|
|
196
236
|
};
|
|
197
237
|
}
|
|
@@ -204,16 +244,20 @@ export const TOOL_REGISTRY = [
|
|
|
204
244
|
},
|
|
205
245
|
{
|
|
206
246
|
name: 'populate_document',
|
|
207
|
-
description: 'Populate a document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document. Pass the
|
|
247
|
+
description: 'Populate a document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document. Pass the docId from create_document\'s response to ensure content goes to the right doc even if the user switched away.',
|
|
208
248
|
schema: {
|
|
209
249
|
content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
|
|
210
|
-
|
|
250
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex from create_document or list_documents). If provided and differs from the active doc, writes directly to disk without switching the user\'s view. Recommended — prevents race conditions when the user navigates during content generation.'),
|
|
211
251
|
},
|
|
212
|
-
handler: async ({ content,
|
|
252
|
+
handler: async ({ content, docId }) => {
|
|
253
|
+
const filename = docId ? resolveDocId(docId) : undefined;
|
|
213
254
|
try {
|
|
214
255
|
let doc;
|
|
215
256
|
if (typeof content === 'string') {
|
|
216
|
-
|
|
257
|
+
let nodes = parseMarkdownContent(content);
|
|
258
|
+
if (isTweetDoc(filename))
|
|
259
|
+
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
260
|
+
doc = { type: 'doc', content: nodes };
|
|
217
261
|
}
|
|
218
262
|
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
219
263
|
doc = content;
|
|
@@ -275,17 +319,19 @@ export const TOOL_REGISTRY = [
|
|
|
275
319
|
handler: async ({ path }) => {
|
|
276
320
|
const result = openFile(path);
|
|
277
321
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
278
|
-
const
|
|
279
|
-
|
|
322
|
+
const openedDocId = getDocId();
|
|
323
|
+
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), openedDocId);
|
|
324
|
+
return { content: [{ type: 'text', text: `Opened "${result.title}" [${openedDocId}] from ${path}\n\n${compact}` }] };
|
|
280
325
|
},
|
|
281
326
|
},
|
|
282
327
|
{
|
|
283
328
|
name: 'delete_document',
|
|
284
|
-
description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool.',
|
|
329
|
+
description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool. Target document by docId (8-char hex from list_documents or read_pad).',
|
|
285
330
|
schema: {
|
|
286
|
-
|
|
331
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
287
332
|
},
|
|
288
|
-
handler: async ({
|
|
333
|
+
handler: async ({ docId }) => {
|
|
334
|
+
const filename = resolveDocId(docId);
|
|
289
335
|
const result = await deleteDocument(filename);
|
|
290
336
|
if (result.switched && result.newDoc) {
|
|
291
337
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
@@ -293,11 +339,44 @@ export const TOOL_REGISTRY = [
|
|
|
293
339
|
broadcastDocumentsChanged();
|
|
294
340
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
295
341
|
if (result.switched && result.newDoc) {
|
|
296
|
-
text += `. Switched to "${result.newDoc.
|
|
342
|
+
text += `. Switched to "${result.newDoc.title}"`;
|
|
343
|
+
}
|
|
344
|
+
return { content: [{ type: 'text', text }] };
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: 'archive_document',
|
|
349
|
+
description: 'Archive a document. Removes it from the active document list without deleting the file. Archived docs can be restored later with unarchive_document. If archiving the active document, automatically switches to the most recent remaining doc. Target document by docId (8-char hex from list_documents).',
|
|
350
|
+
schema: {
|
|
351
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
352
|
+
},
|
|
353
|
+
handler: async ({ docId }) => {
|
|
354
|
+
const filename = resolveDocId(docId);
|
|
355
|
+
const result = archiveDocument(filename);
|
|
356
|
+
if (result.switched && result.newDoc) {
|
|
357
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
358
|
+
}
|
|
359
|
+
broadcastDocumentsChanged();
|
|
360
|
+
let text = `Archived "${filename}"`;
|
|
361
|
+
if (result.switched && result.newDoc) {
|
|
362
|
+
text += `. Switched to "${result.newDoc.title}"`;
|
|
297
363
|
}
|
|
298
364
|
return { content: [{ type: 'text', text }] };
|
|
299
365
|
},
|
|
300
366
|
},
|
|
367
|
+
{
|
|
368
|
+
name: 'unarchive_document',
|
|
369
|
+
description: 'Restore an archived document back to the active document list. Target document by docId (8-char hex from list_documents with includeArchived).',
|
|
370
|
+
schema: {
|
|
371
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
372
|
+
},
|
|
373
|
+
handler: async ({ docId }) => {
|
|
374
|
+
const filename = resolveDocId(docId);
|
|
375
|
+
const result = unarchiveDocument(filename);
|
|
376
|
+
broadcastDocumentsChanged();
|
|
377
|
+
return { content: [{ type: 'text', text: `Restored "${result.title}" [${docId}] from archive` }] };
|
|
378
|
+
},
|
|
379
|
+
},
|
|
301
380
|
{
|
|
302
381
|
name: 'get_metadata',
|
|
303
382
|
description: 'Get the JSON frontmatter metadata for the active document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
|
|
@@ -335,8 +414,13 @@ export const TOOL_REGISTRY = [
|
|
|
335
414
|
save();
|
|
336
415
|
broadcastMetadataChanged(getMetadata());
|
|
337
416
|
if (cleaned.title) {
|
|
417
|
+
// Promote temp file → named file when title is set
|
|
418
|
+
const promoted = promoteTempFile(cleaned.title);
|
|
338
419
|
broadcastTitleChanged(cleaned.title);
|
|
339
420
|
broadcastDocumentsChanged();
|
|
421
|
+
if (promoted) {
|
|
422
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
|
|
423
|
+
}
|
|
340
424
|
}
|
|
341
425
|
const keys = Object.keys(cleaned);
|
|
342
426
|
const parts = [];
|
|
@@ -520,22 +604,27 @@ export const TOOL_REGISTRY = [
|
|
|
520
604
|
},
|
|
521
605
|
{
|
|
522
606
|
name: 'rename_item',
|
|
523
|
-
description: 'Rename a workspace, container, or document. For workspaces: updates the manifest title. For containers: updates the container name in the workspace tree. For documents: updates the title in frontmatter.',
|
|
607
|
+
description: 'Rename a workspace, container, or document. For workspaces: updates the manifest title. For containers: updates the container name in the workspace tree. For documents: updates the title in frontmatter — use docId to identify the document.',
|
|
524
608
|
schema: {
|
|
525
609
|
type: z.enum(['workspace', 'container', 'document']).describe('What to rename'),
|
|
526
|
-
filename: z.string().describe('Workspace manifest filename (for workspace/container)
|
|
610
|
+
filename: z.string().optional().describe('Workspace manifest filename (required for workspace/container renames). Not used for document renames.'),
|
|
611
|
+
docId: z.string().optional().describe('Document docId (required for document renames, 8-char hex from list_documents).'),
|
|
527
612
|
newName: z.string().describe('The new name/title'),
|
|
528
613
|
containerId: z.string().optional().describe('Container ID (required for container renames)'),
|
|
529
614
|
workspaceFile: z.string().optional().describe('Parent workspace filename (required for container renames)'),
|
|
530
615
|
},
|
|
531
|
-
handler: async ({ type, filename, newName, containerId, workspaceFile }) => {
|
|
616
|
+
handler: async ({ type, filename, docId, newName, containerId, workspaceFile }) => {
|
|
532
617
|
if (type === 'workspace') {
|
|
618
|
+
if (!filename)
|
|
619
|
+
return { content: [{ type: 'text', text: 'Error: filename is required for workspace renames' }] };
|
|
533
620
|
renameWorkspace(filename, newName);
|
|
534
621
|
broadcastWorkspacesChanged();
|
|
535
622
|
return { content: [{ type: 'text', text: `Renamed workspace to "${newName}"` }] };
|
|
536
623
|
}
|
|
537
624
|
if (type === 'container') {
|
|
538
625
|
const wsFile = workspaceFile || filename;
|
|
626
|
+
if (!wsFile)
|
|
627
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile or filename is required for container renames' }] };
|
|
539
628
|
if (!containerId)
|
|
540
629
|
return { content: [{ type: 'text', text: 'Error: containerId is required for container renames' }] };
|
|
541
630
|
renameContainer(wsFile, containerId, newName);
|
|
@@ -543,19 +632,22 @@ export const TOOL_REGISTRY = [
|
|
|
543
632
|
return { content: [{ type: 'text', text: `Renamed container ${containerId} to "${newName}"` }] };
|
|
544
633
|
}
|
|
545
634
|
if (type === 'document') {
|
|
546
|
-
|
|
635
|
+
if (!docId)
|
|
636
|
+
return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
|
|
637
|
+
const resolvedFilename = resolveDocId(docId);
|
|
638
|
+
updateDocumentTitle(resolvedFilename, newName);
|
|
547
639
|
broadcastDocumentsChanged();
|
|
548
|
-
if (
|
|
640
|
+
if (resolvedFilename === getActiveFilename()) {
|
|
549
641
|
broadcastTitleChanged(newName);
|
|
550
642
|
}
|
|
551
|
-
return { content: [{ type: 'text', text: `Renamed document
|
|
643
|
+
return { content: [{ type: 'text', text: `Renamed document [${docId}] to "${newName}"` }] };
|
|
552
644
|
}
|
|
553
645
|
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
554
646
|
},
|
|
555
647
|
},
|
|
556
648
|
{
|
|
557
649
|
name: 'edit_text',
|
|
558
|
-
description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node.
|
|
650
|
+
description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node. Target document by docId (8-char hex from list_documents or read_pad).',
|
|
559
651
|
schema: {
|
|
560
652
|
nodeId: z.string().describe('ID of the node to edit'),
|
|
561
653
|
edits: z.array(z.object({
|
|
@@ -567,9 +659,10 @@ export const TOOL_REGISTRY = [
|
|
|
567
659
|
}).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
|
|
568
660
|
removeMark: z.string().optional().describe('Mark type to remove from matched text'),
|
|
569
661
|
})).describe('Array of text edits to apply'),
|
|
570
|
-
|
|
662
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
571
663
|
},
|
|
572
|
-
handler: async ({ nodeId, edits,
|
|
664
|
+
handler: async ({ nodeId, edits, docId }) => {
|
|
665
|
+
const filename = resolveDocId(docId);
|
|
573
666
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
574
667
|
if (targetIsNonActive) {
|
|
575
668
|
const result = applyTextEditsToFile(filename, nodeId, edits);
|
|
@@ -612,6 +705,11 @@ export const TOOL_REGISTRY = [
|
|
|
612
705
|
if (!apiKey) {
|
|
613
706
|
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
614
707
|
}
|
|
708
|
+
// Capture document context BEFORE the async image generation.
|
|
709
|
+
// The active document can change during the await (user switches docs),
|
|
710
|
+
// so we snapshot the metadata and filePath now to stay scoped.
|
|
711
|
+
const preAwaitFilePath = getFilePath();
|
|
712
|
+
const preAwaitMeta = structuredClone(getMetadata());
|
|
615
713
|
const { GoogleGenAI } = await import('@google/genai');
|
|
616
714
|
const ai = new GoogleGenAI({ apiKey });
|
|
617
715
|
const response = await ai.models.generateContent({
|
|
@@ -637,9 +735,20 @@ export const TOOL_REGISTRY = [
|
|
|
637
735
|
const src = `/_images/${filename}`;
|
|
638
736
|
// Optionally set as article cover + append to carousel history
|
|
639
737
|
if (set_cover) {
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
738
|
+
const docChanged = getFilePath() !== preAwaitFilePath;
|
|
739
|
+
if (docChanged) {
|
|
740
|
+
// Active document changed during image generation — skip set_cover
|
|
741
|
+
// to avoid leaking cover images across documents.
|
|
742
|
+
return {
|
|
743
|
+
content: [{
|
|
744
|
+
type: 'text',
|
|
745
|
+
text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
|
|
746
|
+
}],
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
// Use pre-await metadata snapshot to build the update (not live state)
|
|
750
|
+
const articleContext = preAwaitMeta.articleContext || {};
|
|
751
|
+
let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
|
|
643
752
|
// Seed with current coverImage if array is empty (first carousel entry)
|
|
644
753
|
if (existing.length === 0 && articleContext.coverImage) {
|
|
645
754
|
existing = [articleContext.coverImage];
|
|
@@ -659,6 +768,69 @@ export const TOOL_REGISTRY = [
|
|
|
659
768
|
};
|
|
660
769
|
},
|
|
661
770
|
},
|
|
771
|
+
{
|
|
772
|
+
name: 'insert_image',
|
|
773
|
+
description: 'Generate an image via Gemini and insert it inline into a document. The image appears with a green pending decoration for user review. Uses the same change pipeline as write_to_pad.',
|
|
774
|
+
schema: {
|
|
775
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
776
|
+
prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
|
|
777
|
+
afterNodeId: z.string().describe('Insert after this node ID, or "end" to append at the bottom.'),
|
|
778
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
779
|
+
alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
|
|
780
|
+
},
|
|
781
|
+
handler: async ({ docId, prompt, afterNodeId, aspect_ratio, alt }) => {
|
|
782
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
783
|
+
if (!apiKey) {
|
|
784
|
+
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
785
|
+
}
|
|
786
|
+
const filename = resolveDocId(docId);
|
|
787
|
+
// Generate image via Gemini
|
|
788
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
789
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
790
|
+
const response = await ai.models.generateContent({
|
|
791
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
792
|
+
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
793
|
+
config: {
|
|
794
|
+
responseModalities: ['IMAGE'],
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
798
|
+
const imagePart = parts?.find((p) => p.inlineData);
|
|
799
|
+
if (!imagePart?.inlineData?.data) {
|
|
800
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
801
|
+
}
|
|
802
|
+
// Save to ~/.openwriter/_images/
|
|
803
|
+
ensureDataDir();
|
|
804
|
+
const imagesDir = join(DATA_DIR, '_images');
|
|
805
|
+
if (!existsSync(imagesDir))
|
|
806
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
807
|
+
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
808
|
+
const filePath = join(imagesDir, imgFilename);
|
|
809
|
+
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
810
|
+
const src = `/_images/${imgFilename}`;
|
|
811
|
+
// Build image node and insert change
|
|
812
|
+
const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
|
|
813
|
+
const change = { operation: 'insert', afterNodeId, content: [imageNode] };
|
|
814
|
+
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
815
|
+
if (targetIsNonActive) {
|
|
816
|
+
const { lastNodeId } = applyChangesToFile(filename, [change]);
|
|
817
|
+
broadcastPendingDocsChanged();
|
|
818
|
+
return {
|
|
819
|
+
content: [{
|
|
820
|
+
type: 'text',
|
|
821
|
+
text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
|
|
822
|
+
}],
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const { lastNodeId } = applyChanges([change]);
|
|
826
|
+
return {
|
|
827
|
+
content: [{
|
|
828
|
+
type: 'text',
|
|
829
|
+
text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
|
|
830
|
+
}],
|
|
831
|
+
};
|
|
832
|
+
},
|
|
833
|
+
},
|
|
662
834
|
{
|
|
663
835
|
name: 'list_versions',
|
|
664
836
|
description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
|
|
@@ -732,6 +904,51 @@ export const TOOL_REGISTRY = [
|
|
|
732
904
|
return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
|
|
733
905
|
},
|
|
734
906
|
},
|
|
907
|
+
{
|
|
908
|
+
name: 'get_agent_marks',
|
|
909
|
+
description: 'Get inline feedback marks left by the user. Users select text in the editor, right-click → Agent Mark, and leave notes for the agent. Returns marks grouped by document with text, note, and nodeId. Call resolve_agent_marks after addressing each mark.',
|
|
910
|
+
schema: {
|
|
911
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get marks across all documents.'),
|
|
912
|
+
},
|
|
913
|
+
handler: async ({ docId }) => {
|
|
914
|
+
const filename = docId ? resolveDocId(docId) : undefined;
|
|
915
|
+
const marks = getMarks(filename);
|
|
916
|
+
const entries = Object.entries(marks);
|
|
917
|
+
if (entries.length === 0) {
|
|
918
|
+
return { content: [{ type: 'text', text: 'No agent marks found.' }] };
|
|
919
|
+
}
|
|
920
|
+
const lines = [];
|
|
921
|
+
for (const [file, fileMarks] of entries) {
|
|
922
|
+
lines.push(`${file}:`);
|
|
923
|
+
for (const m of fileMarks) {
|
|
924
|
+
const notePart = m.note ? ` — "${m.note}"` : '';
|
|
925
|
+
lines.push(` [${m.id}] "${m.text}"${notePart} (node:${m.nodeId})`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: 'resolve_agent_marks',
|
|
933
|
+
description: 'Remove agent marks after addressing the user\'s feedback. Pass the mark IDs from get_agent_marks. Decorations clear in the browser immediately.',
|
|
934
|
+
schema: {
|
|
935
|
+
mark_ids: z.array(z.string()).describe('Array of mark IDs to resolve'),
|
|
936
|
+
},
|
|
937
|
+
handler: async ({ mark_ids }) => {
|
|
938
|
+
const resolved = resolveMarks(mark_ids);
|
|
939
|
+
// Broadcast to browser so decorations update
|
|
940
|
+
const activeFile = getActiveFilename();
|
|
941
|
+
broadcastMarksChanged(activeFile);
|
|
942
|
+
return {
|
|
943
|
+
content: [{
|
|
944
|
+
type: 'text',
|
|
945
|
+
text: resolved.length > 0
|
|
946
|
+
? `Resolved ${resolved.length} mark${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
|
|
947
|
+
: 'No matching marks found.',
|
|
948
|
+
}],
|
|
949
|
+
};
|
|
950
|
+
},
|
|
951
|
+
},
|
|
735
952
|
];
|
|
736
953
|
/** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
|
|
737
954
|
export function registerPluginTools(tools) {
|
package/dist/server/state.js
CHANGED
|
@@ -82,6 +82,9 @@ export function getTitle() {
|
|
|
82
82
|
export function getFilePath() {
|
|
83
83
|
return state.filePath;
|
|
84
84
|
}
|
|
85
|
+
export function getIsTemp() {
|
|
86
|
+
return state.isTemp;
|
|
87
|
+
}
|
|
85
88
|
export function getDocId() {
|
|
86
89
|
return state.docId;
|
|
87
90
|
}
|
|
@@ -143,7 +146,7 @@ export function setMetadata(updates) {
|
|
|
143
146
|
state.metadata = { ...state.metadata, ...updates };
|
|
144
147
|
if (updates.title)
|
|
145
148
|
state.title = updates.title;
|
|
146
|
-
// Auto-tag: tweetContext / articleContext ↔ "x" tag
|
|
149
|
+
// Auto-tag: tweetContext / articleContext ↔ "x" + mode tag
|
|
147
150
|
for (const key of ['tweetContext', 'articleContext']) {
|
|
148
151
|
if (key in updates) {
|
|
149
152
|
const filename = state.filePath
|
|
@@ -152,6 +155,9 @@ export function setMetadata(updates) {
|
|
|
152
155
|
if (filename) {
|
|
153
156
|
if (updates[key]) {
|
|
154
157
|
addDocTag(filename, 'x');
|
|
158
|
+
const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
|
|
159
|
+
if (mode)
|
|
160
|
+
addDocTag(filename, mode);
|
|
155
161
|
}
|
|
156
162
|
else {
|
|
157
163
|
removeDocTag(filename, 'x');
|
|
@@ -315,6 +315,22 @@ export function findOrCreateContainer(wsFile, name) {
|
|
|
315
315
|
// ============================================================================
|
|
316
316
|
// CROSS-WORKSPACE QUERIES
|
|
317
317
|
// ============================================================================
|
|
318
|
+
/** Rename a document reference in every workspace that contains it. */
|
|
319
|
+
export function renameDocInAllWorkspaces(oldFile, newFile, newTitle) {
|
|
320
|
+
const workspaces = listWorkspaces();
|
|
321
|
+
for (const info of workspaces) {
|
|
322
|
+
try {
|
|
323
|
+
const ws = readWorkspace(info.filename);
|
|
324
|
+
const found = findDocNode(ws.root, oldFile);
|
|
325
|
+
if (found) {
|
|
326
|
+
found.node.file = newFile;
|
|
327
|
+
found.node.title = newTitle;
|
|
328
|
+
writeWorkspace(info.filename, ws);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch { /* skip corrupt manifests */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
318
334
|
/** Remove a document from every workspace that references it. */
|
|
319
335
|
export function removeDocFromAllWorkspaces(file) {
|
|
320
336
|
const workspaces = listWorkspaces();
|
package/dist/server/ws.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
5
|
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
|
-
import { switchDocument, createDocument, deleteDocument, getActiveFilename } from './documents.js';
|
|
6
|
+
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
const clients = new Set();
|
|
9
9
|
let currentAgentConnected = false;
|
|
@@ -114,8 +114,16 @@ export function setupWebSocket(server) {
|
|
|
114
114
|
}
|
|
115
115
|
if (msg.type === 'title-update' && msg.title) {
|
|
116
116
|
setMetadata({ title: msg.title });
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const promoted = promoteTempFile(msg.title);
|
|
118
|
+
if (promoted) {
|
|
119
|
+
save();
|
|
120
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
|
|
121
|
+
broadcastDocumentsChanged();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
debouncedSave();
|
|
125
|
+
debouncedBroadcastDocumentsChanged();
|
|
126
|
+
}
|
|
119
127
|
}
|
|
120
128
|
if (msg.type === 'save') {
|
|
121
129
|
save();
|
|
@@ -331,6 +339,13 @@ export function broadcastWritingFinished() {
|
|
|
331
339
|
ws.send(msg);
|
|
332
340
|
}
|
|
333
341
|
}
|
|
342
|
+
export function broadcastMarksChanged(filename) {
|
|
343
|
+
const msg = JSON.stringify({ type: 'marks-changed', filename });
|
|
344
|
+
for (const ws of clients) {
|
|
345
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
346
|
+
ws.send(msg);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
334
349
|
export function broadcastSyncStatus(status) {
|
|
335
350
|
lastSyncStatus = status;
|
|
336
351
|
const msg = JSON.stringify({ type: 'sync-status', ...status });
|