openwriter 0.5.2 → 0.5.4
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 +266 -1
- package/dist/server/index.js +62 -21
- package/dist/server/mcp.js +213 -57
- package/dist/server/state.js +4 -1
- package/package.json +2 -1
- package/skill/SKILL.md +61 -40
- package/dist/client/assets/index-BEDioWIU.js +0 -208
- package/dist/client/assets/index-BsHE9tXC.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -12,8 +12,7 @@ import { TOOL_REGISTRY } from './mcp.js';
|
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
14
|
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
|
|
15
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs } from './documents.js';
|
|
16
|
-
import { writePromptDebug } from './prompt-debug.js';
|
|
15
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
|
|
17
16
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
18
17
|
import { createLinkRouter } from './link-routes.js';
|
|
19
18
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -56,7 +55,14 @@ export async function startHttpServer(options = {}) {
|
|
|
56
55
|
res.status(404).json({ error: `Unknown tool: ${toolName}` });
|
|
57
56
|
return;
|
|
58
57
|
}
|
|
59
|
-
|
|
58
|
+
// Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
|
|
59
|
+
const schema = z.object(tool.schema);
|
|
60
|
+
const parsed = schema.safeParse(args || {});
|
|
61
|
+
if (!parsed.success) {
|
|
62
|
+
res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const result = await tool.handler(parsed.data);
|
|
60
66
|
res.json(result);
|
|
61
67
|
}
|
|
62
68
|
catch (err) {
|
|
@@ -223,6 +229,39 @@ export async function startHttpServer(options = {}) {
|
|
|
223
229
|
res.status(500).json({ error: err.message });
|
|
224
230
|
}
|
|
225
231
|
});
|
|
232
|
+
app.get('/api/documents/archived', (_req, res) => {
|
|
233
|
+
res.json(listArchivedDocuments());
|
|
234
|
+
});
|
|
235
|
+
app.get('/api/documents/search', (req, res) => {
|
|
236
|
+
const q = req.query.q || '';
|
|
237
|
+
const includeArchived = req.query.archived === 'true';
|
|
238
|
+
res.json(searchDocuments(q, includeArchived));
|
|
239
|
+
});
|
|
240
|
+
app.post('/api/documents/:filename/archive', (req, res) => {
|
|
241
|
+
try {
|
|
242
|
+
removeDocFromAllWorkspaces(req.params.filename);
|
|
243
|
+
const result = archiveDocument(req.params.filename);
|
|
244
|
+
if (result.switched && result.newDoc) {
|
|
245
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
246
|
+
}
|
|
247
|
+
broadcastDocumentsChanged();
|
|
248
|
+
broadcastWorkspacesChanged();
|
|
249
|
+
res.json(result);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
res.status(400).json({ error: err.message });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
app.post('/api/documents/:filename/unarchive', (req, res) => {
|
|
256
|
+
try {
|
|
257
|
+
const result = unarchiveDocument(req.params.filename);
|
|
258
|
+
broadcastDocumentsChanged();
|
|
259
|
+
res.json(result);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
res.status(400).json({ error: err.message });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
226
265
|
app.get('/api/documents/:filename/content', (req, res) => {
|
|
227
266
|
try {
|
|
228
267
|
const targetPath = resolveDocPath(req.params.filename);
|
|
@@ -361,22 +400,6 @@ export async function startHttpServer(options = {}) {
|
|
|
361
400
|
res.status(500).json({ error: err.message });
|
|
362
401
|
}
|
|
363
402
|
});
|
|
364
|
-
// Prompt debug: write full prompt to a timestamped .md file for inspection
|
|
365
|
-
app.post('/api/prompt-debug', (req, res) => {
|
|
366
|
-
try {
|
|
367
|
-
const { action, debug, metadata } = req.body;
|
|
368
|
-
if (!debug) {
|
|
369
|
-
res.status(400).json({ error: 'debug payload is required' });
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
const filename = writePromptDebug(action, debug, metadata);
|
|
373
|
-
broadcastDocumentsChanged();
|
|
374
|
-
res.json({ success: true, filename });
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
res.status(500).json({ error: err.message });
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
403
|
// Google Doc import
|
|
381
404
|
app.post('/api/import/gdoc', (req, res) => {
|
|
382
405
|
try {
|
|
@@ -523,8 +546,26 @@ export async function startHttpServer(options = {}) {
|
|
|
523
546
|
setupWebSocket(server);
|
|
524
547
|
// Broadcast agent status now that WS is ready
|
|
525
548
|
broadcastAgentStatus(true);
|
|
526
|
-
|
|
527
|
-
|
|
549
|
+
await new Promise((resolve, reject) => {
|
|
550
|
+
server.on('error', (err) => {
|
|
551
|
+
if (err.code === 'EADDRINUSE') {
|
|
552
|
+
console.error(`[HTTP] Port ${port} in use — retrying in 2s...`);
|
|
553
|
+
setTimeout(() => {
|
|
554
|
+
server.listen(port, '127.0.0.1', () => {
|
|
555
|
+
console.log(`OpenWriter running at http://localhost:${port}`);
|
|
556
|
+
resolve();
|
|
557
|
+
});
|
|
558
|
+
}, 2000);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
console.error(`[HTTP] Server error:`, err);
|
|
562
|
+
reject(err);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
server.listen(port, '127.0.0.1', () => {
|
|
566
|
+
console.log(`OpenWriter running at http://localhost:${port}`);
|
|
567
|
+
resolve();
|
|
568
|
+
});
|
|
528
569
|
});
|
|
529
570
|
// Open browser unless --no-open or running as MCP stdio pipe
|
|
530
571
|
const isMcpStdio = !process.stdout.isTTY;
|
package/dist/server/mcp.js
CHANGED
|
@@ -9,19 +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, promoteTempFile } 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';
|
|
23
24
|
import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
|
|
24
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
|
+
}
|
|
25
44
|
export const TOOL_REGISTRY = [
|
|
26
45
|
{
|
|
27
46
|
name: 'read_pad',
|
|
@@ -29,7 +48,7 @@ export const TOOL_REGISTRY = [
|
|
|
29
48
|
schema: {},
|
|
30
49
|
handler: async () => {
|
|
31
50
|
const doc = getDocument();
|
|
32
|
-
const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount());
|
|
51
|
+
const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount(), getDocId());
|
|
33
52
|
const activeFile = getActiveFilename();
|
|
34
53
|
const localCount = getMarkCount(activeFile);
|
|
35
54
|
const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(activeFile);
|
|
@@ -45,7 +64,7 @@ export const TOOL_REGISTRY = [
|
|
|
45
64
|
},
|
|
46
65
|
{
|
|
47
66
|
name: 'write_to_pad',
|
|
48
|
-
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).',
|
|
49
68
|
schema: {
|
|
50
69
|
changes: z.array(z.object({
|
|
51
70
|
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
@@ -53,13 +72,18 @@ export const TOOL_REGISTRY = [
|
|
|
53
72
|
afterNodeId: z.string().optional(),
|
|
54
73
|
content: z.any().optional(),
|
|
55
74
|
})).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
|
|
56
|
-
|
|
75
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
57
76
|
},
|
|
58
|
-
handler: async ({ changes,
|
|
77
|
+
handler: async ({ changes, docId }) => {
|
|
78
|
+
const filename = resolveDocId(docId);
|
|
79
|
+
const tweetMode = isTweetDoc(filename);
|
|
59
80
|
const processed = changes.map((change) => {
|
|
60
81
|
const resolved = { ...change };
|
|
61
82
|
if (typeof resolved.content === 'string') {
|
|
62
|
-
|
|
83
|
+
let nodes = parseMarkdownContent(resolved.content);
|
|
84
|
+
if (tweetMode)
|
|
85
|
+
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
86
|
+
resolved.content = nodes;
|
|
63
87
|
}
|
|
64
88
|
return resolved;
|
|
65
89
|
});
|
|
@@ -117,30 +141,32 @@ export const TOOL_REGISTRY = [
|
|
|
117
141
|
},
|
|
118
142
|
{
|
|
119
143
|
name: 'list_documents',
|
|
120
|
-
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.',
|
|
121
145
|
schema: {},
|
|
122
146
|
handler: async () => {
|
|
123
147
|
const docs = listDocuments();
|
|
124
148
|
const lines = docs.map((d) => {
|
|
125
149
|
const active = d.isActive ? ' (active)' : '';
|
|
150
|
+
const id = d.docId ? ` [${d.docId}]` : '';
|
|
126
151
|
const date = d.lastModified.split('T')[0];
|
|
127
|
-
return ` ${d.
|
|
152
|
+
return ` "${d.title}"${id}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
128
153
|
});
|
|
129
154
|
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}` }] };
|
|
130
155
|
},
|
|
131
156
|
},
|
|
132
157
|
{
|
|
133
158
|
name: 'switch_document',
|
|
134
|
-
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).',
|
|
135
160
|
schema: {
|
|
136
|
-
|
|
161
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
137
162
|
},
|
|
138
|
-
handler: async ({
|
|
163
|
+
handler: async ({ docId }) => {
|
|
164
|
+
const filename = resolveDocId(docId);
|
|
139
165
|
broadcastWritingFinished(); // Clear any in-progress creation spinner
|
|
140
166
|
const result = switchDocument(filename);
|
|
141
167
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
142
|
-
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
143
|
-
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}` }] };
|
|
144
170
|
},
|
|
145
171
|
},
|
|
146
172
|
{
|
|
@@ -172,18 +198,16 @@ export const TOOL_REGISTRY = [
|
|
|
172
198
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
173
199
|
}
|
|
174
200
|
try {
|
|
175
|
-
// Lock browser doc-updates: prevents race where browser sends a doc-update
|
|
176
|
-
// for the previous document but server has already switched active doc.
|
|
177
|
-
setAgentLock();
|
|
178
|
-
const result = createDocument(title, undefined, path);
|
|
179
|
-
// Auto-add to workspace if specified
|
|
180
|
-
let wsInfo = '';
|
|
181
|
-
if (wsTarget) {
|
|
182
|
-
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
|
|
183
|
-
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
184
|
-
}
|
|
185
201
|
if (empty) {
|
|
186
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();
|
|
187
211
|
save();
|
|
188
212
|
broadcastDocumentsChanged();
|
|
189
213
|
broadcastWorkspacesChanged();
|
|
@@ -191,19 +215,23 @@ export const TOOL_REGISTRY = [
|
|
|
191
215
|
return {
|
|
192
216
|
content: [{
|
|
193
217
|
type: 'text',
|
|
194
|
-
text: `Created "${result.title}"
|
|
218
|
+
text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
|
|
195
219
|
}],
|
|
196
220
|
};
|
|
197
221
|
}
|
|
198
|
-
// Two-step flow:
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
230
|
broadcastDocumentsChanged();
|
|
202
|
-
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
|
|
203
231
|
return {
|
|
204
232
|
content: [{
|
|
205
233
|
type: 'text',
|
|
206
|
-
text: `Created "${result.title}"
|
|
234
|
+
text: `Created "${result.title}" [${result.docId}]${wsInfo} — empty. Call populate_document with docId "${result.docId}" to add content.`,
|
|
207
235
|
}],
|
|
208
236
|
};
|
|
209
237
|
}
|
|
@@ -216,16 +244,20 @@ export const TOOL_REGISTRY = [
|
|
|
216
244
|
},
|
|
217
245
|
{
|
|
218
246
|
name: 'populate_document',
|
|
219
|
-
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.',
|
|
220
248
|
schema: {
|
|
221
249
|
content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
|
|
222
|
-
|
|
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.'),
|
|
223
251
|
},
|
|
224
|
-
handler: async ({ content,
|
|
252
|
+
handler: async ({ content, docId }) => {
|
|
253
|
+
const filename = docId ? resolveDocId(docId) : undefined;
|
|
225
254
|
try {
|
|
226
255
|
let doc;
|
|
227
256
|
if (typeof content === 'string') {
|
|
228
|
-
|
|
257
|
+
let nodes = parseMarkdownContent(content);
|
|
258
|
+
if (isTweetDoc(filename))
|
|
259
|
+
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
260
|
+
doc = { type: 'doc', content: nodes };
|
|
229
261
|
}
|
|
230
262
|
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
231
263
|
doc = content;
|
|
@@ -287,17 +319,19 @@ export const TOOL_REGISTRY = [
|
|
|
287
319
|
handler: async ({ path }) => {
|
|
288
320
|
const result = openFile(path);
|
|
289
321
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
290
|
-
const
|
|
291
|
-
|
|
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}` }] };
|
|
292
325
|
},
|
|
293
326
|
},
|
|
294
327
|
{
|
|
295
328
|
name: 'delete_document',
|
|
296
|
-
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).',
|
|
297
330
|
schema: {
|
|
298
|
-
|
|
331
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
299
332
|
},
|
|
300
|
-
handler: async ({
|
|
333
|
+
handler: async ({ docId }) => {
|
|
334
|
+
const filename = resolveDocId(docId);
|
|
301
335
|
const result = await deleteDocument(filename);
|
|
302
336
|
if (result.switched && result.newDoc) {
|
|
303
337
|
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
@@ -305,11 +339,44 @@ export const TOOL_REGISTRY = [
|
|
|
305
339
|
broadcastDocumentsChanged();
|
|
306
340
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
307
341
|
if (result.switched && result.newDoc) {
|
|
308
|
-
text += `. Switched to "${result.newDoc.
|
|
342
|
+
text += `. Switched to "${result.newDoc.title}"`;
|
|
309
343
|
}
|
|
310
344
|
return { content: [{ type: 'text', text }] };
|
|
311
345
|
},
|
|
312
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}"`;
|
|
363
|
+
}
|
|
364
|
+
return { content: [{ type: 'text', text }] };
|
|
365
|
+
},
|
|
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
|
+
},
|
|
313
380
|
{
|
|
314
381
|
name: 'get_metadata',
|
|
315
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.',
|
|
@@ -537,22 +604,27 @@ export const TOOL_REGISTRY = [
|
|
|
537
604
|
},
|
|
538
605
|
{
|
|
539
606
|
name: 'rename_item',
|
|
540
|
-
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.',
|
|
541
608
|
schema: {
|
|
542
609
|
type: z.enum(['workspace', 'container', 'document']).describe('What to rename'),
|
|
543
|
-
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).'),
|
|
544
612
|
newName: z.string().describe('The new name/title'),
|
|
545
613
|
containerId: z.string().optional().describe('Container ID (required for container renames)'),
|
|
546
614
|
workspaceFile: z.string().optional().describe('Parent workspace filename (required for container renames)'),
|
|
547
615
|
},
|
|
548
|
-
handler: async ({ type, filename, newName, containerId, workspaceFile }) => {
|
|
616
|
+
handler: async ({ type, filename, docId, newName, containerId, workspaceFile }) => {
|
|
549
617
|
if (type === 'workspace') {
|
|
618
|
+
if (!filename)
|
|
619
|
+
return { content: [{ type: 'text', text: 'Error: filename is required for workspace renames' }] };
|
|
550
620
|
renameWorkspace(filename, newName);
|
|
551
621
|
broadcastWorkspacesChanged();
|
|
552
622
|
return { content: [{ type: 'text', text: `Renamed workspace to "${newName}"` }] };
|
|
553
623
|
}
|
|
554
624
|
if (type === 'container') {
|
|
555
625
|
const wsFile = workspaceFile || filename;
|
|
626
|
+
if (!wsFile)
|
|
627
|
+
return { content: [{ type: 'text', text: 'Error: workspaceFile or filename is required for container renames' }] };
|
|
556
628
|
if (!containerId)
|
|
557
629
|
return { content: [{ type: 'text', text: 'Error: containerId is required for container renames' }] };
|
|
558
630
|
renameContainer(wsFile, containerId, newName);
|
|
@@ -560,19 +632,22 @@ export const TOOL_REGISTRY = [
|
|
|
560
632
|
return { content: [{ type: 'text', text: `Renamed container ${containerId} to "${newName}"` }] };
|
|
561
633
|
}
|
|
562
634
|
if (type === 'document') {
|
|
563
|
-
|
|
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);
|
|
564
639
|
broadcastDocumentsChanged();
|
|
565
|
-
if (
|
|
640
|
+
if (resolvedFilename === getActiveFilename()) {
|
|
566
641
|
broadcastTitleChanged(newName);
|
|
567
642
|
}
|
|
568
|
-
return { content: [{ type: 'text', text: `Renamed document
|
|
643
|
+
return { content: [{ type: 'text', text: `Renamed document [${docId}] to "${newName}"` }] };
|
|
569
644
|
}
|
|
570
645
|
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
571
646
|
},
|
|
572
647
|
},
|
|
573
648
|
{
|
|
574
649
|
name: 'edit_text',
|
|
575
|
-
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).',
|
|
576
651
|
schema: {
|
|
577
652
|
nodeId: z.string().describe('ID of the node to edit'),
|
|
578
653
|
edits: z.array(z.object({
|
|
@@ -584,9 +659,10 @@ export const TOOL_REGISTRY = [
|
|
|
584
659
|
}).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
|
|
585
660
|
removeMark: z.string().optional().describe('Mark type to remove from matched text'),
|
|
586
661
|
})).describe('Array of text edits to apply'),
|
|
587
|
-
|
|
662
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
|
|
588
663
|
},
|
|
589
|
-
handler: async ({ nodeId, edits,
|
|
664
|
+
handler: async ({ nodeId, edits, docId }) => {
|
|
665
|
+
const filename = resolveDocId(docId);
|
|
590
666
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
591
667
|
if (targetIsNonActive) {
|
|
592
668
|
const result = applyTextEditsToFile(filename, nodeId, edits);
|
|
@@ -629,6 +705,11 @@ export const TOOL_REGISTRY = [
|
|
|
629
705
|
if (!apiKey) {
|
|
630
706
|
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
631
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());
|
|
632
713
|
const { GoogleGenAI } = await import('@google/genai');
|
|
633
714
|
const ai = new GoogleGenAI({ apiKey });
|
|
634
715
|
const response = await ai.models.generateContent({
|
|
@@ -654,9 +735,20 @@ export const TOOL_REGISTRY = [
|
|
|
654
735
|
const src = `/_images/${filename}`;
|
|
655
736
|
// Optionally set as article cover + append to carousel history
|
|
656
737
|
if (set_cover) {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
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] : [];
|
|
660
752
|
// Seed with current coverImage if array is empty (first carousel entry)
|
|
661
753
|
if (existing.length === 0 && articleContext.coverImage) {
|
|
662
754
|
existing = [articleContext.coverImage];
|
|
@@ -676,6 +768,69 @@ export const TOOL_REGISTRY = [
|
|
|
676
768
|
};
|
|
677
769
|
},
|
|
678
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
|
+
},
|
|
679
834
|
{
|
|
680
835
|
name: 'list_versions',
|
|
681
836
|
description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
|
|
@@ -751,11 +906,12 @@ export const TOOL_REGISTRY = [
|
|
|
751
906
|
},
|
|
752
907
|
{
|
|
753
908
|
name: 'get_agent_marks',
|
|
754
|
-
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
|
|
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.',
|
|
755
910
|
schema: {
|
|
756
|
-
|
|
911
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get marks across all documents.'),
|
|
757
912
|
},
|
|
758
|
-
handler: async ({
|
|
913
|
+
handler: async ({ docId }) => {
|
|
914
|
+
const filename = docId ? resolveDocId(docId) : undefined;
|
|
759
915
|
const marks = getMarks(filename);
|
|
760
916
|
const entries = Object.entries(marks);
|
|
761
917
|
if (entries.length === 0) {
|
package/dist/server/state.js
CHANGED
|
@@ -146,7 +146,7 @@ export function setMetadata(updates) {
|
|
|
146
146
|
state.metadata = { ...state.metadata, ...updates };
|
|
147
147
|
if (updates.title)
|
|
148
148
|
state.title = updates.title;
|
|
149
|
-
// Auto-tag: tweetContext / articleContext ↔ "x" tag
|
|
149
|
+
// Auto-tag: tweetContext / articleContext ↔ "x" + mode tag
|
|
150
150
|
for (const key of ['tweetContext', 'articleContext']) {
|
|
151
151
|
if (key in updates) {
|
|
152
152
|
const filename = state.filePath
|
|
@@ -155,6 +155,9 @@ export function setMetadata(updates) {
|
|
|
155
155
|
if (filename) {
|
|
156
156
|
if (updates[key]) {
|
|
157
157
|
addDocTag(filename, 'x');
|
|
158
|
+
const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
|
|
159
|
+
if (mode)
|
|
160
|
+
addDocTag(filename, mode);
|
|
158
161
|
}
|
|
159
162
|
else {
|
|
160
163
|
removeDocTag(filename, 'x');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "vite build && tsc -p tsconfig.server.json",
|
|
35
|
+
"prepublishOnly": "cp ../../skills/openwriter/SKILL.md skill/SKILL.md",
|
|
35
36
|
"preview": "node dist/bin/pad.js",
|
|
36
37
|
"lint": "eslint src server bin --ext .ts,.tsx"
|
|
37
38
|
},
|