openwriter 0.3.1 → 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.
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -3,12 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
7
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
8
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
6
9
  <title>OpenWriter</title>
7
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
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" />
10
- <script type="module" crossorigin src="/assets/index-BTxdHrWL.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-C9E86o6p.css">
13
+ <script type="module" crossorigin src="/assets/index-BwT1KW6a.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Be3gaGeo.css">
12
15
  </head>
13
16
  <body>
14
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 {
@@ -337,6 +422,54 @@ export async function startHttpServer(options = {}) {
337
422
  res.status(500).json({ error: err.message });
338
423
  }
339
424
  });
425
+ // Sidebar context menu action dispatch — routes to plugin's registered HTTP routes
426
+ app.post('/api/plugins/sidebar-action', async (req, res) => {
427
+ try {
428
+ const { action, filename, title, instructions, label } = req.body;
429
+ if (!action || !filename) {
430
+ res.status(400).json({ error: 'action and filename are required' });
431
+ return;
432
+ }
433
+ // Action format: "pluginPrefix:actionName" — forward to plugin's route
434
+ const colonIdx = action.indexOf(':');
435
+ if (colonIdx === -1) {
436
+ res.status(400).json({ error: 'action must be namespaced (e.g. "scheduler:schedule-post")' });
437
+ return;
438
+ }
439
+ const prefix = action.slice(0, colonIdx);
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
+ };
459
+ // Forward to plugin route: POST /api/{prefix}/sidebar-action
460
+ // Re-route the request through Express's internal router
461
+ req.url = `/api/${prefix}/sidebar-action`;
462
+ req.body = { action: actionName, filename, title, instructions, content: docContent };
463
+ app.handle(req, res, () => {
464
+ broadcastWritingFinished();
465
+ res.status(404).json({ error: `No handler registered for action "${action}"` });
466
+ });
467
+ }
468
+ catch (err) {
469
+ broadcastWritingFinished();
470
+ res.status(500).json({ error: err.message });
471
+ }
472
+ });
340
473
  // Serve built React app
341
474
  const clientDir = join(__dirname, '..', 'client');
342
475
  if (existsSync(clientDir)) {
@@ -365,7 +498,7 @@ export async function startHttpServer(options = {}) {
365
498
  setupWebSocket(server);
366
499
  // Broadcast agent status now that WS is ready
367
500
  broadcastAgentStatus(true);
368
- server.listen(port, () => {
501
+ server.listen(port, '127.0.0.1', () => {
369
502
  console.log(`OpenWriter running at http://localhost:${port}`);
370
503
  });
371
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) {