openwriter 0.6.3 → 0.6.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.
@@ -100,7 +100,7 @@ export function listDocuments() {
100
100
  wordCount,
101
101
  isActive: fullPath === currentPath,
102
102
  ...(data.docId ? { docId: data.docId } : {}),
103
- ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
103
+ ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
104
104
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
105
105
  };
106
106
  }
@@ -136,6 +136,7 @@ export async function startHttpServer(options = {}) {
136
136
  setMetadata(req.body);
137
137
  save();
138
138
  broadcastMetadataChanged(getMetadata());
139
+ broadcastDocumentsChanged();
139
140
  res.json({ success: true });
140
141
  }
141
142
  catch (err) {
@@ -12,19 +12,25 @@ const __filename = fileURLToPath(import.meta.url);
12
12
  const __dirname = dirname(__filename);
13
13
  const USER_PLUGINS_DIR = join(homedir(), '.openwriter', 'plugins');
14
14
  /**
15
- * Scan the bundled plugins/ directory at the monorepo root.
16
- * Returns [] if plugins/ doesn't exist (e.g. npx install scenario).
15
+ * Scan for bundled plugins. Checks two locations:
16
+ * 1. dist/plugins/ plugins copied into the npm package at publish time
17
+ * 2. monorepo root plugins/ — local dev with workspace symlinks
17
18
  */
18
19
  function discoverBundledPlugins() {
19
- // At runtime: dist/server/ → ../../../.. monorepo root → /plugins/
20
- const pluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
20
+ // 1. Monorepo root: dist/server/ → ../../../../plugins/ (live dev code)
21
+ const monoPluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
22
+ // 2. Bundled in dist: dist/server/ → ../plugins/ (npm install)
23
+ const distPluginsDir = join(__dirname, '..', 'plugins');
24
+ // Prefer monorepo path in dev (live code), fall back to bundled copy in npm install
25
+ const pluginsDir = existsSync(monoPluginsDir) ? monoPluginsDir : distPluginsDir;
21
26
  if (!existsSync(pluginsDir))
22
27
  return [];
23
28
  const results = [];
24
29
  for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) {
25
30
  if (!entry.isDirectory())
26
31
  continue;
27
- const pkgPath = join(pluginsDir, entry.name, 'package.json');
32
+ const pluginDir = join(pluginsDir, entry.name);
33
+ const pkgPath = join(pluginDir, 'package.json');
28
34
  if (!existsSync(pkgPath))
29
35
  continue;
30
36
  try {
@@ -40,6 +46,7 @@ function discoverBundledPlugins() {
40
46
  source: 'bundled',
41
47
  displayName: manifest?.displayName,
42
48
  category: manifest?.category,
49
+ pluginDir,
43
50
  });
44
51
  }
45
52
  catch {
@@ -101,12 +108,20 @@ function tryAddPlugin(pkgDir, fullName, results) {
101
108
  source: 'user',
102
109
  displayName: manifest?.displayName,
103
110
  category: manifest?.category,
111
+ pluginDir: pkgDir,
104
112
  });
105
113
  }
106
114
  catch {
107
115
  // Skip malformed package.json
108
116
  }
109
117
  }
118
+ /** Preferred display order for bundled plugins */
119
+ const BUNDLED_ORDER = [
120
+ '@openwriter/plugin-authors-voice',
121
+ '@openwriter/plugin-publish',
122
+ '@openwriter/plugin-image-gen',
123
+ '@openwriter/plugin-x-api',
124
+ ];
110
125
  /**
111
126
  * Discover all plugins from both bundled and user sources.
112
127
  * Deduplicates by name (bundled takes priority).
@@ -114,6 +129,12 @@ function tryAddPlugin(pkgDir, fullName, results) {
114
129
  export function discoverPlugins() {
115
130
  const bundled = discoverBundledPlugins();
116
131
  const user = discoverUserPlugins();
132
+ // Sort bundled plugins by preferred order (unknown plugins go to end)
133
+ bundled.sort((a, b) => {
134
+ const ai = BUNDLED_ORDER.indexOf(a.name);
135
+ const bi = BUNDLED_ORDER.indexOf(b.name);
136
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
137
+ });
117
138
  // Deduplicate: bundled wins if same name exists in both
118
139
  const seen = new Set(bundled.map(p => p.name));
119
140
  const deduped = [...bundled];
@@ -126,10 +147,10 @@ export function discoverPlugins() {
126
147
  return deduped;
127
148
  }
128
149
  /**
129
- * Import a plugin by npm package name and extract its metadata.
130
- * Returns the plugin's configSchema and full module export.
150
+ * Import a plugin module and extract its metadata.
151
+ * Uses pluginDir for path-based imports (works in both npm install and monorepo dev).
131
152
  */
132
- export async function loadPluginModule(name, source = 'bundled') {
153
+ export async function loadPluginModule(name, source = 'bundled', pluginDir) {
133
154
  try {
134
155
  let mod;
135
156
  if (source === 'user') {
@@ -138,7 +159,13 @@ export async function loadPluginModule(name, source = 'bundled') {
138
159
  const resolved = userRequire.resolve(name);
139
160
  mod = await import(pathToFileURL(resolved).href);
140
161
  }
162
+ else if (pluginDir) {
163
+ // Path-based import — works for both bundled-in-dist and monorepo plugins
164
+ const mainPath = join(pluginDir, 'dist', 'index.js');
165
+ mod = await import(pathToFileURL(mainPath).href);
166
+ }
141
167
  else {
168
+ // Fallback: bare import (workspace symlinks in monorepo dev)
142
169
  mod = await import(name);
143
170
  }
144
171
  const plugin = mod.default || mod.plugin || mod;
@@ -20,7 +20,7 @@ export class PluginManager {
20
20
  const savedPlugins = savedConfig.plugins || {};
21
21
  for (const d of discovered) {
22
22
  // Load module to get configSchema
23
- const loaded = await loadPluginModule(d.name, d.source);
23
+ const loaded = await loadPluginModule(d.name, d.source, d.pluginDir);
24
24
  const saved = savedPlugins[d.name];
25
25
  this.plugins.set(d.name, {
26
26
  discovered: d,
@@ -41,7 +41,7 @@ export class PluginManager {
41
41
  return { success: true };
42
42
  // Ensure plugin module is loaded
43
43
  if (!managed.plugin) {
44
- const loaded = await loadPluginModule(name, managed.discovered.source);
44
+ const loaded = await loadPluginModule(name, managed.discovered.source, managed.discovered.pluginDir);
45
45
  if (!loaded)
46
46
  return { success: false, error: `Failed to import "${name}"` };
47
47
  managed.plugin = loaded.plugin;
@@ -117,5 +117,196 @@ export function createSchedulerRouter() {
117
117
  router.get('/api/scheduler/history', proxy('/scheduler/history'));
118
118
  // Available connections for scheduler
119
119
  router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
120
+ // --- Autoplugs ---
121
+ // Goals
122
+ router.get('/api/scheduler/autoplugs/goals', proxy('/scheduler/autoplugs/goals'));
123
+ router.post('/api/scheduler/autoplugs/goals', proxy('/scheduler/autoplugs/goals', 'POST'));
124
+ router.patch('/api/scheduler/autoplugs/goals/:id', async (req, res) => {
125
+ try {
126
+ if (!isAuthenticated()) {
127
+ res.json({ error: 'Not authenticated' });
128
+ return;
129
+ }
130
+ const upstream = await platformFetch(`/scheduler/autoplugs/goals/${req.params.id}`, {
131
+ method: 'PATCH',
132
+ body: JSON.stringify(req.body),
133
+ });
134
+ const data = await upstream.json();
135
+ if (!upstream.ok) {
136
+ res.status(upstream.status).json(data);
137
+ return;
138
+ }
139
+ res.json(data);
140
+ }
141
+ catch (err) {
142
+ res.status(500).json({ error: err.message });
143
+ }
144
+ });
145
+ router.delete('/api/scheduler/autoplugs/goals/:id', async (req, res) => {
146
+ try {
147
+ if (!isAuthenticated()) {
148
+ res.json({ error: 'Not authenticated' });
149
+ return;
150
+ }
151
+ const upstream = await platformFetch(`/scheduler/autoplugs/goals/${req.params.id}`, { method: 'DELETE' });
152
+ const data = await upstream.json();
153
+ if (!upstream.ok) {
154
+ res.status(upstream.status).json(data);
155
+ return;
156
+ }
157
+ res.json(data);
158
+ }
159
+ catch (err) {
160
+ res.status(500).json({ error: err.message });
161
+ }
162
+ });
163
+ // Pool (per goal)
164
+ router.get('/api/scheduler/autoplugs/goals/:id/pool', async (req, res) => {
165
+ try {
166
+ if (!isAuthenticated()) {
167
+ res.json({ error: 'Not authenticated' });
168
+ return;
169
+ }
170
+ const upstream = await platformFetch(`/scheduler/autoplugs/goals/${req.params.id}/pool`);
171
+ const data = await upstream.json();
172
+ if (!upstream.ok) {
173
+ res.status(upstream.status).json(data);
174
+ return;
175
+ }
176
+ res.json(data);
177
+ }
178
+ catch (err) {
179
+ res.status(500).json({ error: err.message });
180
+ }
181
+ });
182
+ router.post('/api/scheduler/autoplugs/goals/:id/pool', async (req, res) => {
183
+ try {
184
+ if (!isAuthenticated()) {
185
+ res.json({ error: 'Not authenticated' });
186
+ return;
187
+ }
188
+ const upstream = await platformFetch(`/scheduler/autoplugs/goals/${req.params.id}/pool`, {
189
+ method: 'POST',
190
+ body: JSON.stringify(req.body),
191
+ });
192
+ const data = await upstream.json();
193
+ if (!upstream.ok) {
194
+ res.status(upstream.status).json(data);
195
+ return;
196
+ }
197
+ res.json(data);
198
+ }
199
+ catch (err) {
200
+ res.status(500).json({ error: err.message });
201
+ }
202
+ });
203
+ router.patch('/api/scheduler/autoplugs/pool/:id', async (req, res) => {
204
+ try {
205
+ if (!isAuthenticated()) {
206
+ res.json({ error: 'Not authenticated' });
207
+ return;
208
+ }
209
+ const upstream = await platformFetch(`/scheduler/autoplugs/pool/${req.params.id}`, {
210
+ method: 'PATCH',
211
+ body: JSON.stringify(req.body),
212
+ });
213
+ const data = await upstream.json();
214
+ if (!upstream.ok) {
215
+ res.status(upstream.status).json(data);
216
+ return;
217
+ }
218
+ res.json(data);
219
+ }
220
+ catch (err) {
221
+ res.status(500).json({ error: err.message });
222
+ }
223
+ });
224
+ router.delete('/api/scheduler/autoplugs/pool/:id', async (req, res) => {
225
+ try {
226
+ if (!isAuthenticated()) {
227
+ res.json({ error: 'Not authenticated' });
228
+ return;
229
+ }
230
+ const upstream = await platformFetch(`/scheduler/autoplugs/pool/${req.params.id}`, { method: 'DELETE' });
231
+ const data = await upstream.json();
232
+ if (!upstream.ok) {
233
+ res.status(upstream.status).json(data);
234
+ return;
235
+ }
236
+ res.json(data);
237
+ }
238
+ catch (err) {
239
+ res.status(500).json({ error: err.message });
240
+ }
241
+ });
242
+ // Rules
243
+ router.get('/api/scheduler/autoplugs/rules', proxy('/scheduler/autoplugs/rules'));
244
+ router.post('/api/scheduler/autoplugs/rules', proxy('/scheduler/autoplugs/rules', 'POST'));
245
+ router.patch('/api/scheduler/autoplugs/rules/:id', async (req, res) => {
246
+ try {
247
+ if (!isAuthenticated()) {
248
+ res.json({ error: 'Not authenticated' });
249
+ return;
250
+ }
251
+ const upstream = await platformFetch(`/scheduler/autoplugs/rules/${req.params.id}`, {
252
+ method: 'PATCH',
253
+ body: JSON.stringify(req.body),
254
+ });
255
+ const data = await upstream.json();
256
+ if (!upstream.ok) {
257
+ res.status(upstream.status).json(data);
258
+ return;
259
+ }
260
+ res.json(data);
261
+ }
262
+ catch (err) {
263
+ res.status(500).json({ error: err.message });
264
+ }
265
+ });
266
+ router.delete('/api/scheduler/autoplugs/rules/:id', async (req, res) => {
267
+ try {
268
+ if (!isAuthenticated()) {
269
+ res.json({ error: 'Not authenticated' });
270
+ return;
271
+ }
272
+ const upstream = await platformFetch(`/scheduler/autoplugs/rules/${req.params.id}`, { method: 'DELETE' });
273
+ const data = await upstream.json();
274
+ if (!upstream.ok) {
275
+ res.status(upstream.status).json(data);
276
+ return;
277
+ }
278
+ res.json(data);
279
+ }
280
+ catch (err) {
281
+ res.status(500).json({ error: err.message });
282
+ }
283
+ });
284
+ // AV Key
285
+ router.post('/api/scheduler/autoplugs/av-key', proxy('/scheduler/autoplugs/av-key', 'POST'));
286
+ router.delete('/api/scheduler/autoplugs/av-key', proxy('/scheduler/autoplugs/av-key', 'DELETE'));
287
+ router.get('/api/scheduler/autoplugs/av-key/status', proxy('/scheduler/autoplugs/av-key/status'));
288
+ // Tracking
289
+ router.get('/api/scheduler/autoplugs/tracking', proxy('/scheduler/autoplugs/tracking'));
290
+ router.patch('/api/scheduler/autoplugs/tracking/:id', async (req, res) => {
291
+ try {
292
+ if (!isAuthenticated()) {
293
+ res.json({ error: 'Not authenticated' });
294
+ return;
295
+ }
296
+ const upstream = await platformFetch(`/scheduler/autoplugs/tracking/${req.params.id}`, {
297
+ method: 'PATCH',
298
+ body: JSON.stringify(req.body),
299
+ });
300
+ const data = await upstream.json();
301
+ if (!upstream.ok) {
302
+ res.status(upstream.status).json(data);
303
+ return;
304
+ }
305
+ res.json(data);
306
+ }
307
+ catch (err) {
308
+ res.status(500).json({ error: err.message });
309
+ }
310
+ });
120
311
  return router;
121
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.6.3",
3
+ "version": "0.6.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,7 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "vite build && tsc -p tsconfig.server.json",
35
- "prepublishOnly": "node -e \"const fs=require('fs'),p=require('path');fs.copyFileSync(p.resolve('../../skills/openwriter/SKILL.md'),'skill/SKILL.md');fs.mkdirSync('skill/docs',{recursive:true});fs.copyFileSync(p.resolve('../../skills/openwriter/docs/welcome.md'),'skill/docs/welcome.md')\"",
35
+ "prepublishOnly": "node scripts/prepublish.cjs",
36
36
  "preview": "node dist/bin/pad.js",
37
37
  "lint": "eslint src server bin --ext .ts,.tsx"
38
38
  },
@@ -73,6 +73,7 @@
73
73
  "react-dom": "^18.3.1",
74
74
  "trash": "^10.1.0",
75
75
  "ws": "^8.18.0",
76
+ "@xdevplatform/xdk": "^0.4.0",
76
77
  "zod": "^3.25.76"
77
78
  },
78
79
  "devDependencies": {
package/skill/SKILL.md CHANGED
@@ -15,7 +15,7 @@ description: |
15
15
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
16
16
  metadata:
17
17
  author: travsteward
18
- version: "0.2.3"
18
+ version: "0.2.4"
19
19
  repository: https://github.com/travsteward/openwriter
20
20
  license: MIT
21
21
  ---
@@ -91,8 +91,9 @@ After setup, tell the user:
91
91
  Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its YAML frontmatter. Titles are for human communication and agent reasoning. DocIds are for agent action.
92
92
 
93
93
  - `list_documents` and `read_pad` always show both title and docId
94
- - All doc-targeting tools take `docId` as their parameter (not filename)
94
+ - All doc-targeting tools take `docId` as their parameter (not filename, not frontmatter read from disk)
95
95
  - Two documents can have the same title — the docId disambiguates
96
+ - Filenames contain UUIDs unrelated to docIds — the first segment of a filename UUID looks like a docId but is not
96
97
 
97
98
  ## MCP Tools Reference (36 core + 21 publish platform)
98
99