openwriter 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-Be3gaGeo.css +1 -0
- package/dist/client/assets/index-BwT1KW6a.js +207 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +81 -7
- package/dist/server/git-sync.js +3 -2
- package/dist/server/helpers.js +17 -4
- package/dist/server/index.js +115 -10
- package/dist/server/markdown-parse.js +12 -0
- package/dist/server/markdown-serialize.js +12 -0
- package/dist/server/mcp.js +9 -9
- package/dist/server/plugin-manager.js +1 -0
- package/dist/server/prompt-debug.js +58 -0
- package/dist/server/state.js +22 -9
- package/dist/server/ws.js +36 -9
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/dist/client/assets/index-CqeJ7cMy.css +0 -1
- package/dist/client/assets/index-DiDoklNt.js +0 -209
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BwT1KW6a.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Be3gaGeo.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -10,8 +10,26 @@ import trash from 'trash';
|
|
|
10
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
12
|
import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, } from './state.js';
|
|
13
|
-
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
13
|
+
import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
|
+
const DOC_ORDER_FILE = join(DATA_DIR, '_doc-order.json');
|
|
16
|
+
function readDocOrder() {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(DOC_ORDER_FILE))
|
|
19
|
+
return [];
|
|
20
|
+
return JSON.parse(readFileSync(DOC_ORDER_FILE, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeDocOrder(order) {
|
|
27
|
+
ensureDataDir();
|
|
28
|
+
writeFileSync(DOC_ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
export function reorderDocs(orderedFilenames) {
|
|
31
|
+
writeDocOrder(orderedFilenames);
|
|
32
|
+
}
|
|
15
33
|
export function listDocuments() {
|
|
16
34
|
ensureDataDir();
|
|
17
35
|
const currentPath = getFilePath();
|
|
@@ -68,12 +86,28 @@ export function listDocuments() {
|
|
|
68
86
|
}
|
|
69
87
|
catch { /* skip unreadable external files */ }
|
|
70
88
|
}
|
|
71
|
-
//
|
|
72
|
-
|
|
89
|
+
// Sort by persisted order; docs not in manifest prepend (newest first by mtime)
|
|
90
|
+
const order = readDocOrder();
|
|
91
|
+
if (order.length > 0) {
|
|
92
|
+
const orderIndex = new Map(order.map((f, i) => [f, i]));
|
|
93
|
+
files.sort((a, b) => {
|
|
94
|
+
const ai = orderIndex.get(a.filename) ?? -1;
|
|
95
|
+
const bi = orderIndex.get(b.filename) ?? -1;
|
|
96
|
+
// Both unknown → newest first by mtime
|
|
97
|
+
if (ai === -1 && bi === -1)
|
|
98
|
+
return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
|
|
99
|
+
// Unknown docs sort before known (prepend)
|
|
100
|
+
if (ai === -1)
|
|
101
|
+
return -1;
|
|
102
|
+
if (bi === -1)
|
|
103
|
+
return 1;
|
|
104
|
+
return ai - bi;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
73
107
|
return files;
|
|
74
108
|
}
|
|
75
109
|
export function switchDocument(filename) {
|
|
76
|
-
// Cancel any pending debounced save, then save current doc immediately
|
|
110
|
+
// Cancel any pending debounced save, then save current doc immediately.
|
|
77
111
|
cancelDebouncedSave();
|
|
78
112
|
save();
|
|
79
113
|
// Read target from disk — markdownToTiptap rehydrates pending state
|
|
@@ -116,7 +150,19 @@ export function createDocument(title, content, path) {
|
|
|
116
150
|
}
|
|
117
151
|
else {
|
|
118
152
|
isTemp = !title;
|
|
119
|
-
|
|
153
|
+
if (isTemp) {
|
|
154
|
+
filePath = tempFilePath();
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
filePath = filePathForTitle(docTitle);
|
|
158
|
+
// Deduplicate: append counter if file already exists
|
|
159
|
+
if (existsSync(filePath)) {
|
|
160
|
+
let counter = 2;
|
|
161
|
+
while (existsSync(filePathForTitle(`${docTitle} ${counter}`)))
|
|
162
|
+
counter++;
|
|
163
|
+
filePath = filePathForTitle(`${docTitle} ${counter}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
120
166
|
filename = filePath.split(/[/\\]/).pop();
|
|
121
167
|
}
|
|
122
168
|
let newDoc;
|
|
@@ -138,7 +184,7 @@ export function createDocument(title, content, path) {
|
|
|
138
184
|
// Write doc to disk
|
|
139
185
|
const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
|
|
140
186
|
ensureDataDir();
|
|
141
|
-
|
|
187
|
+
atomicWriteFileSync(filePath, markdown);
|
|
142
188
|
return { document: getDocument(), title: getTitle(), filename };
|
|
143
189
|
}
|
|
144
190
|
export async function deleteDocument(filename) {
|
|
@@ -193,7 +239,7 @@ export function updateDocumentTitle(filename, newTitle) {
|
|
|
193
239
|
const parsed = markdownToTiptap(raw);
|
|
194
240
|
const metadata = { ...parsed.metadata, title: newTitle };
|
|
195
241
|
const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
|
|
196
|
-
|
|
242
|
+
atomicWriteFileSync(filePath, markdown);
|
|
197
243
|
// Update state if this is the active document
|
|
198
244
|
const baseName = filePath.split(/[/\\]/).pop() || '';
|
|
199
245
|
if (getFilePath() === filePath) {
|
|
@@ -222,6 +268,34 @@ export function openFile(fullPath) {
|
|
|
222
268
|
const filename = isExternalDoc(fullPath) ? fullPath : baseName;
|
|
223
269
|
return { document: getDocument(), title: getTitle(), filename };
|
|
224
270
|
}
|
|
271
|
+
export function duplicateDocument(filename) {
|
|
272
|
+
// Cancel any pending debounced save, then save current doc immediately
|
|
273
|
+
cancelDebouncedSave();
|
|
274
|
+
save();
|
|
275
|
+
const sourcePath = resolveDocPath(filename);
|
|
276
|
+
if (!existsSync(sourcePath)) {
|
|
277
|
+
throw new Error(`Document not found: ${filename}`);
|
|
278
|
+
}
|
|
279
|
+
const raw = readFileSync(sourcePath, 'utf-8');
|
|
280
|
+
const parsed = markdownToTiptap(raw);
|
|
281
|
+
// Generate deduplicated title
|
|
282
|
+
let newTitle = `${parsed.title} (Copy)`;
|
|
283
|
+
let filePath = filePathForTitle(newTitle);
|
|
284
|
+
if (existsSync(filePath)) {
|
|
285
|
+
let counter = 2;
|
|
286
|
+
while (existsSync(filePathForTitle(`${parsed.title} (Copy ${counter})`)))
|
|
287
|
+
counter++;
|
|
288
|
+
newTitle = `${parsed.title} (Copy ${counter})`;
|
|
289
|
+
filePath = filePathForTitle(newTitle);
|
|
290
|
+
}
|
|
291
|
+
const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
|
|
292
|
+
setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
|
|
293
|
+
const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
|
|
294
|
+
ensureDataDir();
|
|
295
|
+
atomicWriteFileSync(filePath, markdown);
|
|
296
|
+
const newFilename = filePath.split(/[/\\]/).pop();
|
|
297
|
+
return { document: getDocument(), title: getTitle(), filename: newFilename };
|
|
298
|
+
}
|
|
225
299
|
export function getActiveFilename() {
|
|
226
300
|
const filePath = getFilePath();
|
|
227
301
|
// For external docs, return the full path as the identifier
|
package/dist/server/git-sync.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile } from 'child_process';
|
|
|
6
6
|
import { existsSync, writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { DATA_DIR, readConfig, saveConfig } from './helpers.js';
|
|
9
|
-
import { save } from './state.js';
|
|
9
|
+
import { save, cancelDebouncedSave } from './state.js';
|
|
10
10
|
const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
|
|
11
11
|
const NETWORK_TIMEOUT = 30000;
|
|
12
12
|
let currentSyncState = 'unconfigured';
|
|
@@ -242,7 +242,8 @@ export async function pushSync(onStatus) {
|
|
|
242
242
|
lastError = undefined;
|
|
243
243
|
onStatus({ state: 'syncing' });
|
|
244
244
|
try {
|
|
245
|
-
// Flush current document to disk first
|
|
245
|
+
// Flush current document to disk first (cancel debounce to ensure immediate write)
|
|
246
|
+
cancelDebouncedSave();
|
|
246
247
|
save();
|
|
247
248
|
ensureGitignore();
|
|
248
249
|
await exec('git', ['add', '-A'], DATA_DIR);
|
package/dist/server/helpers.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Both state.ts and documents.ts import from here to avoid duplication.
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
6
|
-
import { join, isAbsolute, basename, dirname } from 'path';
|
|
6
|
+
import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
export const DATA_DIR = join(homedir(), '.openwriter');
|
|
@@ -40,9 +40,16 @@ export function tempFilePath() {
|
|
|
40
40
|
// ---- Path resolution for external documents ----
|
|
41
41
|
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
42
42
|
export function resolveDocPath(filename) {
|
|
43
|
-
|
|
43
|
+
// External docs use absolute paths as identifiers — pass through
|
|
44
|
+
if (isAbsolute(filename))
|
|
44
45
|
return filename;
|
|
45
|
-
|
|
46
|
+
// Internal docs must resolve within DATA_DIR — block path traversal
|
|
47
|
+
const resolved = resolve(DATA_DIR, filename);
|
|
48
|
+
const dataDir = resolve(DATA_DIR);
|
|
49
|
+
if (resolved !== dataDir && !resolved.startsWith(dataDir + sep)) {
|
|
50
|
+
throw new Error(`Path traversal blocked: ${filename}`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
46
53
|
}
|
|
47
54
|
/** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
|
|
48
55
|
export function isExternalDoc(filename) {
|
|
@@ -62,6 +69,12 @@ export function getParentDirName(filename) {
|
|
|
62
69
|
return '';
|
|
63
70
|
return basename(dirname(filename));
|
|
64
71
|
}
|
|
72
|
+
/** Atomic write: write to temp file + rename to prevent corruption on crash. */
|
|
73
|
+
export function atomicWriteFileSync(filePath, data) {
|
|
74
|
+
const tmpPath = filePath + '.tmp';
|
|
75
|
+
writeFileSync(tmpPath, data, 'utf-8');
|
|
76
|
+
renameSync(tmpPath, filePath);
|
|
77
|
+
}
|
|
65
78
|
/** Generate an 8-char hex node ID for TipTap block nodes. */
|
|
66
79
|
export function generateNodeId() {
|
|
67
80
|
return randomUUID().replace(/-/g, '').slice(0, 8);
|
|
@@ -83,5 +96,5 @@ export function saveConfig(updates) {
|
|
|
83
96
|
ensureDataDir();
|
|
84
97
|
const current = readConfig();
|
|
85
98
|
const merged = { ...current, ...updates };
|
|
86
|
-
|
|
99
|
+
atomicWriteFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
87
100
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -6,13 +6,14 @@ import express from 'express';
|
|
|
6
6
|
import { createServer } from 'http';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
|
-
import { existsSync } from 'fs';
|
|
10
|
-
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastSyncStatus } from './ws.js';
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag } from './state.js';
|
|
15
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
|
|
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';
|
|
16
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
18
|
import { createLinkRouter } from './link-routes.js';
|
|
18
19
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -21,6 +22,7 @@ import { importGoogleDoc } from './gdoc-import.js';
|
|
|
21
22
|
import { createVersionRouter } from './version-routes.js';
|
|
22
23
|
import { createSyncRouter } from './sync-routes.js';
|
|
23
24
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
25
|
+
import { resolveDocPath } from './helpers.js';
|
|
24
26
|
import { createImageRouter } from './image-upload.js';
|
|
25
27
|
import { createExportRouter } from './export-routes.js';
|
|
26
28
|
import { PluginManager } from './plugin-manager.js';
|
|
@@ -97,15 +99,15 @@ export async function startHttpServer(options = {}) {
|
|
|
97
99
|
res.json({ success: true });
|
|
98
100
|
});
|
|
99
101
|
// Beacon-based flush: browser sends this on beforeunload/visibilitychange
|
|
100
|
-
//
|
|
101
|
-
app.post('/api/flush',
|
|
102
|
+
// Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
|
|
103
|
+
app.post('/api/flush', (req, res) => {
|
|
102
104
|
try {
|
|
103
105
|
if (isAgentLocked()) {
|
|
104
106
|
console.log('[Flush] Blocked (agent write lock active)');
|
|
105
107
|
res.status(204).end();
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
|
-
const msg =
|
|
110
|
+
const msg = req.body;
|
|
109
111
|
if (msg.document) {
|
|
110
112
|
updateDocument(msg.document);
|
|
111
113
|
save();
|
|
@@ -127,10 +129,58 @@ export async function startHttpServer(options = {}) {
|
|
|
127
129
|
app.get('/api/documents', (_req, res) => {
|
|
128
130
|
res.json(listDocuments());
|
|
129
131
|
});
|
|
132
|
+
app.put('/api/documents/reorder', (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const { order } = req.body;
|
|
135
|
+
if (!Array.isArray(order))
|
|
136
|
+
return res.status(400).json({ error: 'order must be an array' });
|
|
137
|
+
reorderDocs(order);
|
|
138
|
+
broadcastDocumentsChanged();
|
|
139
|
+
res.json({ success: true });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
res.status(400).json({ error: err.message });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
130
145
|
app.post('/api/documents', (req, res) => {
|
|
131
146
|
try {
|
|
132
147
|
const result = createDocument(req.body.title, req.body.content, req.body.path);
|
|
148
|
+
// Apply metadata if provided (e.g. tweetContext for threadified docs)
|
|
149
|
+
if (req.body.metadata) {
|
|
150
|
+
setMetadata(req.body.metadata);
|
|
151
|
+
save();
|
|
152
|
+
}
|
|
153
|
+
// Plugin flags: mark all content as pending + tag as agent-created
|
|
154
|
+
if (req.body.markPending) {
|
|
155
|
+
markAllNodesAsPending(getDocument(), 'insert');
|
|
156
|
+
updatePendingCacheForActiveDoc();
|
|
157
|
+
save();
|
|
158
|
+
}
|
|
159
|
+
if (req.body.agentCreated) {
|
|
160
|
+
setMetadata({ agentCreated: true });
|
|
161
|
+
save();
|
|
162
|
+
}
|
|
133
163
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
164
|
+
if (req.body.markPending || req.body.agentCreated) {
|
|
165
|
+
broadcastDocumentsChanged();
|
|
166
|
+
broadcastPendingDocsChanged();
|
|
167
|
+
}
|
|
168
|
+
res.json(result);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
res.status(400).json({ error: err.message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
app.post('/api/documents/duplicate', (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const { filename } = req.body;
|
|
177
|
+
if (!filename) {
|
|
178
|
+
res.status(400).json({ error: 'filename is required' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const result = duplicateDocument(filename);
|
|
182
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
183
|
+
broadcastDocumentsChanged();
|
|
134
184
|
res.json(result);
|
|
135
185
|
}
|
|
136
186
|
catch (err) {
|
|
@@ -172,6 +222,25 @@ export async function startHttpServer(options = {}) {
|
|
|
172
222
|
res.status(500).json({ error: err.message });
|
|
173
223
|
}
|
|
174
224
|
});
|
|
225
|
+
app.get('/api/documents/:filename/content', (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const targetPath = resolveDocPath(req.params.filename);
|
|
228
|
+
if (!existsSync(targetPath)) {
|
|
229
|
+
res.status(404).json({ error: 'Document not found' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
233
|
+
const parsed = markdownToTiptap(raw);
|
|
234
|
+
res.json({
|
|
235
|
+
title: parsed.title,
|
|
236
|
+
document: parsed.document,
|
|
237
|
+
metadata: parsed.metadata,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
res.status(500).json({ error: err.message });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
175
244
|
app.delete('/api/documents/:filename', async (req, res) => {
|
|
176
245
|
try {
|
|
177
246
|
removeDocFromAllWorkspaces(req.params.filename);
|
|
@@ -252,6 +321,22 @@ export async function startHttpServer(options = {}) {
|
|
|
252
321
|
res.status(500).json({ error: err.message });
|
|
253
322
|
}
|
|
254
323
|
});
|
|
324
|
+
// Prompt debug: write full prompt to a timestamped .md file for inspection
|
|
325
|
+
app.post('/api/prompt-debug', (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
const { action, debug, metadata } = req.body;
|
|
328
|
+
if (!debug) {
|
|
329
|
+
res.status(400).json({ error: 'debug payload is required' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const filename = writePromptDebug(action, debug, metadata);
|
|
333
|
+
broadcastDocumentsChanged();
|
|
334
|
+
res.json({ success: true, filename });
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
res.status(500).json({ error: err.message });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
255
340
|
// Google Doc import
|
|
256
341
|
app.post('/api/import/gdoc', (req, res) => {
|
|
257
342
|
try {
|
|
@@ -340,7 +425,7 @@ export async function startHttpServer(options = {}) {
|
|
|
340
425
|
// Sidebar context menu action dispatch — routes to plugin's registered HTTP routes
|
|
341
426
|
app.post('/api/plugins/sidebar-action', async (req, res) => {
|
|
342
427
|
try {
|
|
343
|
-
const { action, filename, title } = req.body;
|
|
428
|
+
const { action, filename, title, instructions, label } = req.body;
|
|
344
429
|
if (!action || !filename) {
|
|
345
430
|
res.status(400).json({ error: 'action and filename are required' });
|
|
346
431
|
return;
|
|
@@ -353,15 +438,35 @@ export async function startHttpServer(options = {}) {
|
|
|
353
438
|
}
|
|
354
439
|
const prefix = action.slice(0, colonIdx);
|
|
355
440
|
const actionName = action.slice(colonIdx + 1);
|
|
441
|
+
// Read document content so plugins don't need to call back
|
|
442
|
+
let docContent = '';
|
|
443
|
+
try {
|
|
444
|
+
const targetPath = resolveDocPath(filename);
|
|
445
|
+
if (existsSync(targetPath)) {
|
|
446
|
+
docContent = readFileSync(targetPath, 'utf-8');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch { /* content stays empty */ }
|
|
450
|
+
// Show sidebar spinner while plugin processes
|
|
451
|
+
const spinnerTitle = label ? `${label}: ${title}` : title;
|
|
452
|
+
broadcastWritingStarted(spinnerTitle);
|
|
453
|
+
// Intercept res.json to clear spinner when plugin handler responds
|
|
454
|
+
const origJson = res.json.bind(res);
|
|
455
|
+
res.json = (body) => {
|
|
456
|
+
broadcastWritingFinished();
|
|
457
|
+
return origJson(body);
|
|
458
|
+
};
|
|
356
459
|
// Forward to plugin route: POST /api/{prefix}/sidebar-action
|
|
357
460
|
// Re-route the request through Express's internal router
|
|
358
461
|
req.url = `/api/${prefix}/sidebar-action`;
|
|
359
|
-
req.body = { action: actionName, filename, title };
|
|
462
|
+
req.body = { action: actionName, filename, title, instructions, content: docContent };
|
|
360
463
|
app.handle(req, res, () => {
|
|
464
|
+
broadcastWritingFinished();
|
|
361
465
|
res.status(404).json({ error: `No handler registered for action "${action}"` });
|
|
362
466
|
});
|
|
363
467
|
}
|
|
364
468
|
catch (err) {
|
|
469
|
+
broadcastWritingFinished();
|
|
365
470
|
res.status(500).json({ error: err.message });
|
|
366
471
|
}
|
|
367
472
|
});
|
|
@@ -393,7 +498,7 @@ export async function startHttpServer(options = {}) {
|
|
|
393
498
|
setupWebSocket(server);
|
|
394
499
|
// Broadcast agent status now that WS is ready
|
|
395
500
|
broadcastAgentStatus(true);
|
|
396
|
-
server.listen(port, () => {
|
|
501
|
+
server.listen(port, '127.0.0.1', () => {
|
|
397
502
|
console.log(`OpenWriter running at http://localhost:${port}`);
|
|
398
503
|
});
|
|
399
504
|
// Open browser unless --no-open or running as MCP stdio pipe
|
|
@@ -85,6 +85,18 @@ function rehydratePendingState(doc, pending) {
|
|
|
85
85
|
if (entry.o) {
|
|
86
86
|
target.attrs.pendingOriginalContent = entry.o;
|
|
87
87
|
}
|
|
88
|
+
if (entry.g) {
|
|
89
|
+
target.attrs.pendingGroupId = entry.g;
|
|
90
|
+
}
|
|
91
|
+
// Selection range attrs (sub-paragraph enhance)
|
|
92
|
+
if (entry.sf != null)
|
|
93
|
+
target.attrs.pendingSelectionFrom = entry.sf;
|
|
94
|
+
if (entry.st != null)
|
|
95
|
+
target.attrs.pendingSelectionTo = entry.st;
|
|
96
|
+
if (entry.of != null)
|
|
97
|
+
target.attrs.pendingOriginalFrom = entry.of;
|
|
98
|
+
if (entry.ot != null)
|
|
99
|
+
target.attrs.pendingOriginalTo = entry.ot;
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
}
|
|
@@ -30,6 +30,18 @@ function collectPendingState(doc) {
|
|
|
30
30
|
if (node.attrs.pendingOriginalContent) {
|
|
31
31
|
entry.o = node.attrs.pendingOriginalContent;
|
|
32
32
|
}
|
|
33
|
+
if (node.attrs.pendingGroupId) {
|
|
34
|
+
entry.g = node.attrs.pendingGroupId;
|
|
35
|
+
}
|
|
36
|
+
// Selection range attrs (sub-paragraph enhance)
|
|
37
|
+
if (node.attrs.pendingSelectionFrom != null)
|
|
38
|
+
entry.sf = node.attrs.pendingSelectionFrom;
|
|
39
|
+
if (node.attrs.pendingSelectionTo != null)
|
|
40
|
+
entry.st = node.attrs.pendingSelectionTo;
|
|
41
|
+
if (node.attrs.pendingOriginalFrom != null)
|
|
42
|
+
entry.of = node.attrs.pendingOriginalFrom;
|
|
43
|
+
if (node.attrs.pendingOriginalTo != null)
|
|
44
|
+
entry.ot = node.attrs.pendingOriginalTo;
|
|
33
45
|
const t = nodeText(node);
|
|
34
46
|
if (t)
|
|
35
47
|
entry.t = t;
|
package/dist/server/mcp.js
CHANGED
|
@@ -524,7 +524,7 @@ export const TOOL_REGISTRY = [
|
|
|
524
524
|
},
|
|
525
525
|
{
|
|
526
526
|
name: 'generate_image',
|
|
527
|
-
description: 'Generate an image using Gemini
|
|
527
|
+
description: 'Generate an image using Gemini Nano Banana 2. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
|
|
528
528
|
schema: {
|
|
529
529
|
prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
|
|
530
530
|
aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
|
|
@@ -537,16 +537,16 @@ export const TOOL_REGISTRY = [
|
|
|
537
537
|
}
|
|
538
538
|
const { GoogleGenAI } = await import('@google/genai');
|
|
539
539
|
const ai = new GoogleGenAI({ apiKey });
|
|
540
|
-
const response = await ai.models.
|
|
541
|
-
model: '
|
|
542
|
-
prompt
|
|
540
|
+
const response = await ai.models.generateContent({
|
|
541
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
542
|
+
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
543
543
|
config: {
|
|
544
|
-
|
|
545
|
-
aspectRatio: (aspect_ratio || '16:9'),
|
|
544
|
+
responseModalities: ['IMAGE'],
|
|
546
545
|
},
|
|
547
546
|
});
|
|
548
|
-
const
|
|
549
|
-
|
|
547
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
548
|
+
const imagePart = parts?.find((p) => p.inlineData);
|
|
549
|
+
if (!imagePart?.inlineData?.data) {
|
|
550
550
|
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
551
551
|
}
|
|
552
552
|
// Save to ~/.openwriter/_images/
|
|
@@ -556,7 +556,7 @@ export const TOOL_REGISTRY = [
|
|
|
556
556
|
mkdirSync(imagesDir, { recursive: true });
|
|
557
557
|
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
558
558
|
const filePath = join(imagesDir, filename);
|
|
559
|
-
writeFileSync(filePath, Buffer.from(
|
|
559
|
+
writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
560
560
|
const src = `/_images/${filename}`;
|
|
561
561
|
// Optionally set as article cover + append to carousel history
|
|
562
562
|
if (set_cover) {
|
|
@@ -127,6 +127,7 @@ export class PluginManager {
|
|
|
127
127
|
continue;
|
|
128
128
|
results.push({
|
|
129
129
|
name: managed.plugin.name,
|
|
130
|
+
displayName: managed.discovered.displayName,
|
|
130
131
|
contextMenuItems: managed.plugin.contextMenuItems?.() || [],
|
|
131
132
|
sidebarMenuItems: managed.plugin.sidebarMenuItems?.() || [],
|
|
132
133
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: prompt-debug.ts
|
|
3
|
+
* Purpose: Write AV prompt debug data to timestamped .md files for inspection.
|
|
4
|
+
* Each enhance creates a new file in DATA_DIR, visible in the sidebar.
|
|
5
|
+
*/
|
|
6
|
+
import { DATA_DIR, ensureDataDir, atomicWriteFileSync } from './helpers.js';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
/**
|
|
9
|
+
* Write prompt debug info to a timestamped markdown file.
|
|
10
|
+
* Returns the filename created.
|
|
11
|
+
*/
|
|
12
|
+
export function writePromptDebug(action, debug, metadata) {
|
|
13
|
+
ensureDataDir();
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
16
|
+
const filename = `_prompt-${action || 'debug'}-${ts}.md`;
|
|
17
|
+
const filePath = join(DATA_DIR, filename);
|
|
18
|
+
const timeStr = now.toLocaleTimeString('en-US', { hour12: true, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
19
|
+
let md = `---\ntitle: "Prompt Debug: ${action} @ ${timeStr}"\n---\n\n`;
|
|
20
|
+
// Metadata summary
|
|
21
|
+
if (metadata) {
|
|
22
|
+
md += `## Metadata\n\n`;
|
|
23
|
+
md += `| Key | Value |\n|-----|-------|\n`;
|
|
24
|
+
if (metadata.action)
|
|
25
|
+
md += `| Action | ${metadata.action} |\n`;
|
|
26
|
+
if (metadata.profileUsed)
|
|
27
|
+
md += `| Profile | ${metadata.profileUsed} |\n`;
|
|
28
|
+
if (metadata.nodesIn != null)
|
|
29
|
+
md += `| Nodes In | ${metadata.nodesIn} |\n`;
|
|
30
|
+
if (metadata.nodesOut != null)
|
|
31
|
+
md += `| Nodes Out | ${metadata.nodesOut} |\n`;
|
|
32
|
+
if (metadata.ragExamples != null)
|
|
33
|
+
md += `| RAG Examples | ${metadata.ragExamples} |\n`;
|
|
34
|
+
if (metadata.ragTotalWords != null)
|
|
35
|
+
md += `| RAG Total Words | ${metadata.ragTotalWords} |\n`;
|
|
36
|
+
if (metadata.processingTimeMs != null)
|
|
37
|
+
md += `| Processing Time | ${metadata.processingTimeMs}ms |\n`;
|
|
38
|
+
if (metadata.estimatedCost != null)
|
|
39
|
+
md += `| Estimated Cost | $${metadata.estimatedCost.toFixed(4)} |\n`;
|
|
40
|
+
md += `\n`;
|
|
41
|
+
}
|
|
42
|
+
// System prompt
|
|
43
|
+
if (debug.systemPrompt) {
|
|
44
|
+
md += `## System Prompt\n\n`;
|
|
45
|
+
md += debug.systemPrompt + '\n\n';
|
|
46
|
+
}
|
|
47
|
+
// User prompt
|
|
48
|
+
if (debug.userPrompt) {
|
|
49
|
+
md += `---\n\n## User Prompt\n\n`;
|
|
50
|
+
md += debug.userPrompt + '\n\n';
|
|
51
|
+
}
|
|
52
|
+
// Raw LLM response (when available)
|
|
53
|
+
if (debug.rawResponse) {
|
|
54
|
+
md += `---\n\n## Raw LLM Output\n\n\`\`\`json\n${debug.rawResponse}\n\`\`\`\n\n`;
|
|
55
|
+
}
|
|
56
|
+
atomicWriteFileSync(filePath, md);
|
|
57
|
+
return filename;
|
|
58
|
+
}
|