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.
@@ -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-DiDoklNt.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CqeJ7cMy.css">
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>
@@ -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
- // Most recently modified first new docs appear at top (matches spinner position)
72
- files.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
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
- filePath = isTemp ? tempFilePath() : filePathForTitle(docTitle);
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
- writeFileSync(filePath, markdown, 'utf-8');
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
- writeFileSync(filePath, markdown, 'utf-8');
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
@@ -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);
@@ -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
- if (isAbsolute(filename) || /[/\\]/.test(filename))
43
+ // External docs use absolute paths as identifiers — pass through
44
+ if (isAbsolute(filename))
44
45
  return filename;
45
- return join(DATA_DIR, filename);
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
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
99
+ atomicWriteFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
87
100
  }
@@ -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
- // sendBeacon sends as text/plain, so we parse the JSON manually
101
- app.post('/api/flush', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
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 = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
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;
@@ -524,7 +524,7 @@ export const TOOL_REGISTRY = [
524
524
  },
525
525
  {
526
526
  name: 'generate_image',
527
- description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
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.generateImages({
541
- model: 'imagen-4.0-generate-001',
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
- numberOfImages: 1,
545
- aspectRatio: (aspect_ratio || '16:9'),
544
+ responseModalities: ['IMAGE'],
546
545
  },
547
546
  });
548
- const image = response.generatedImages?.[0];
549
- if (!image?.image?.imageBytes) {
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(image.image.imageBytes, 'base64'));
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
+ }