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.
@@ -1,19 +1,21 @@
1
1
  /**
2
- * Plugin discovery: scans the plugins/ directory for available plugins.
3
- * Reads package.json metadata without importing or loading the plugin code.
2
+ * Plugin discovery: scans bundled plugins/ directory and user ~/.openwriter/plugins/
3
+ * for available plugins. Reads package.json metadata without importing plugin code.
4
4
  */
5
5
  import { existsSync, readdirSync, readFileSync } from 'fs';
6
6
  import { join } from 'path';
7
- import { fileURLToPath } from 'url';
7
+ import { fileURLToPath, pathToFileURL } from 'url';
8
8
  import { dirname } from 'path';
9
+ import { homedir } from 'os';
10
+ import { createRequire } from 'module';
9
11
  const __filename = fileURLToPath(import.meta.url);
10
12
  const __dirname = dirname(__filename);
13
+ const USER_PLUGINS_DIR = join(homedir(), '.openwriter', 'plugins');
11
14
  /**
12
- * Scan the plugins/ directory at the monorepo root.
13
- * Returns metadata from each plugin's package.json without importing code.
14
- * Returns [] if plugins/ doesn't exist (e.g. npm install scenario).
15
+ * Scan the bundled plugins/ directory at the monorepo root.
16
+ * Returns [] if plugins/ doesn't exist (e.g. npx install scenario).
15
17
  */
16
- export function discoverPlugins() {
18
+ function discoverBundledPlugins() {
17
19
  // At runtime: dist/server/ → ../../../.. → monorepo root → /plugins/
18
20
  const pluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
19
21
  if (!existsSync(pluginsDir))
@@ -29,11 +31,15 @@ export function discoverPlugins() {
29
31
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
30
32
  if (!pkg.name)
31
33
  continue;
34
+ const manifest = pkg.openwriter;
32
35
  results.push({
33
36
  name: pkg.name,
34
37
  dirName: entry.name,
35
38
  version: pkg.version || '0.0.0',
36
39
  description: pkg.description || '',
40
+ source: 'bundled',
41
+ displayName: manifest?.displayName,
42
+ category: manifest?.category,
37
43
  });
38
44
  }
39
45
  catch {
@@ -42,13 +48,99 @@ export function discoverPlugins() {
42
48
  }
43
49
  return results;
44
50
  }
51
+ /**
52
+ * Scan ~/.openwriter/plugins/node_modules/ for user-installed plugins.
53
+ * Matches packages with `openwriter` field in package.json or matching naming conventions.
54
+ */
55
+ function discoverUserPlugins() {
56
+ const nodeModules = join(USER_PLUGINS_DIR, 'node_modules');
57
+ if (!existsSync(nodeModules))
58
+ return [];
59
+ const results = [];
60
+ const scanDir = (dir) => {
61
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
62
+ if (!entry.isDirectory())
63
+ continue;
64
+ // Handle scoped packages (@scope/package-name)
65
+ if (entry.name.startsWith('@')) {
66
+ const scopeDir = join(dir, entry.name);
67
+ for (const scoped of readdirSync(scopeDir, { withFileTypes: true })) {
68
+ if (!scoped.isDirectory())
69
+ continue;
70
+ tryAddPlugin(join(scopeDir, scoped.name), `${entry.name}/${scoped.name}`, results);
71
+ }
72
+ }
73
+ else {
74
+ tryAddPlugin(join(dir, entry.name), entry.name, results);
75
+ }
76
+ }
77
+ };
78
+ scanDir(nodeModules);
79
+ return results;
80
+ }
81
+ function tryAddPlugin(pkgDir, fullName, results) {
82
+ const pkgPath = join(pkgDir, 'package.json');
83
+ if (!existsSync(pkgPath))
84
+ return;
85
+ try {
86
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
87
+ if (!pkg.name)
88
+ return;
89
+ const manifest = pkg.openwriter;
90
+ const isOpenWriterPlugin = manifest ||
91
+ /^openwriter-plugin-/.test(pkg.name) ||
92
+ /^@openwriter\/plugin-/.test(pkg.name) ||
93
+ /^@[\w-]+\/openwriter-plugin-/.test(pkg.name);
94
+ if (!isOpenWriterPlugin)
95
+ return;
96
+ results.push({
97
+ name: pkg.name,
98
+ dirName: fullName,
99
+ version: pkg.version || '0.0.0',
100
+ description: pkg.description || '',
101
+ source: 'user',
102
+ displayName: manifest?.displayName,
103
+ category: manifest?.category,
104
+ });
105
+ }
106
+ catch {
107
+ // Skip malformed package.json
108
+ }
109
+ }
110
+ /**
111
+ * Discover all plugins from both bundled and user sources.
112
+ * Deduplicates by name (bundled takes priority).
113
+ */
114
+ export function discoverPlugins() {
115
+ const bundled = discoverBundledPlugins();
116
+ const user = discoverUserPlugins();
117
+ // Deduplicate: bundled wins if same name exists in both
118
+ const seen = new Set(bundled.map(p => p.name));
119
+ const deduped = [...bundled];
120
+ for (const p of user) {
121
+ if (!seen.has(p.name)) {
122
+ deduped.push(p);
123
+ seen.add(p.name);
124
+ }
125
+ }
126
+ return deduped;
127
+ }
45
128
  /**
46
129
  * Import a plugin by npm package name and extract its metadata.
47
130
  * Returns the plugin's configSchema and full module export.
48
131
  */
49
- export async function loadPluginModule(name) {
132
+ export async function loadPluginModule(name, source = 'bundled') {
50
133
  try {
51
- const mod = await import(name);
134
+ let mod;
135
+ if (source === 'user') {
136
+ // ESM import from non-standard node_modules requires path resolution
137
+ const userRequire = createRequire(join(USER_PLUGINS_DIR, 'package.json'));
138
+ const resolved = userRequire.resolve(name);
139
+ mod = await import(pathToFileURL(resolved).href);
140
+ }
141
+ else {
142
+ mod = await import(name);
143
+ }
52
144
  const plugin = mod.default || mod.plugin || mod;
53
145
  if (!plugin.name || !plugin.version)
54
146
  return null;
@@ -58,7 +150,7 @@ export async function loadPluginModule(name) {
58
150
  };
59
151
  }
60
152
  catch (err) {
61
- console.error(`[PluginDiscovery] Failed to import "${name}":`, err.message);
153
+ console.error(`[PluginDiscovery] Failed to import "${name}" (${source}):`, err.message);
62
154
  return null;
63
155
  }
64
156
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * CLI plugin management: install, remove, list user plugins.
3
+ * User plugins live in ~/.openwriter/plugins/ with their own package.json and node_modules.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { execSync } from 'child_process';
9
+ const PLUGINS_DIR = join(homedir(), '.openwriter', 'plugins');
10
+ const PLUGINS_PKG = join(PLUGINS_DIR, 'package.json');
11
+ const VALID_NAME = /^(@openwriter\/plugin-[\w-]+|openwriter-plugin-[\w-]+|@[\w-]+\/openwriter-plugin-[\w-]+)$/;
12
+ /** Ensure ~/.openwriter/plugins/ exists with a package.json */
13
+ export function ensureUserPluginsDir() {
14
+ if (!existsSync(PLUGINS_DIR)) {
15
+ mkdirSync(PLUGINS_DIR, { recursive: true });
16
+ }
17
+ if (!existsSync(PLUGINS_PKG)) {
18
+ writeFileSync(PLUGINS_PKG, JSON.stringify({
19
+ private: true,
20
+ type: 'module',
21
+ dependencies: {}
22
+ }, null, 2), 'utf-8');
23
+ }
24
+ }
25
+ /** Install a plugin package into the user plugins directory */
26
+ export function installPlugin(packageName) {
27
+ if (!VALID_NAME.test(packageName)) {
28
+ console.error(`Invalid plugin name: ${packageName}`);
29
+ console.error('Plugin names must match: openwriter-plugin-*, @openwriter/plugin-*, or @scope/openwriter-plugin-*');
30
+ process.exit(1);
31
+ }
32
+ ensureUserPluginsDir();
33
+ console.error(`Installing ${packageName}...`);
34
+ try {
35
+ execSync(`npm install --save ${packageName}`, {
36
+ cwd: PLUGINS_DIR,
37
+ stdio: 'inherit',
38
+ });
39
+ console.error(`Installed ${packageName} successfully.`);
40
+ }
41
+ catch {
42
+ console.error(`Failed to install ${packageName}.`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ /** Remove a plugin package from the user plugins directory */
47
+ export function removePlugin(packageName) {
48
+ ensureUserPluginsDir();
49
+ const pkg = readPkg();
50
+ if (!pkg.dependencies?.[packageName]) {
51
+ console.error(`Plugin ${packageName} is not installed.`);
52
+ process.exit(1);
53
+ }
54
+ console.error(`Removing ${packageName}...`);
55
+ try {
56
+ execSync(`npm uninstall ${packageName}`, {
57
+ cwd: PLUGINS_DIR,
58
+ stdio: 'inherit',
59
+ });
60
+ console.error(`Removed ${packageName} successfully.`);
61
+ }
62
+ catch {
63
+ console.error(`Failed to remove ${packageName}.`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ /** List installed user plugins */
68
+ export function listInstalledPlugins() {
69
+ ensureUserPluginsDir();
70
+ const pkg = readPkg();
71
+ const deps = pkg.dependencies || {};
72
+ const names = Object.keys(deps);
73
+ if (names.length === 0) {
74
+ console.error('No user plugins installed.');
75
+ console.error('Install one with: openwriter plugin install <package-name>');
76
+ return;
77
+ }
78
+ console.error('Installed plugins:');
79
+ for (const name of names) {
80
+ console.error(` ${name}@${deps[name]}`);
81
+ }
82
+ }
83
+ /** CLI router for plugin subcommands */
84
+ export function handlePluginCommand(args) {
85
+ const sub = args[0];
86
+ const name = args[1];
87
+ switch (sub) {
88
+ case 'install':
89
+ if (!name) {
90
+ console.error('Usage: openwriter plugin install <package-name>');
91
+ process.exit(1);
92
+ }
93
+ installPlugin(name);
94
+ break;
95
+ case 'remove':
96
+ case 'uninstall':
97
+ if (!name) {
98
+ console.error('Usage: openwriter plugin remove <package-name>');
99
+ process.exit(1);
100
+ }
101
+ removePlugin(name);
102
+ break;
103
+ case 'list':
104
+ case 'ls':
105
+ listInstalledPlugins();
106
+ break;
107
+ default:
108
+ console.error('Usage: openwriter plugin <install|remove|list> [package-name]');
109
+ process.exit(1);
110
+ }
111
+ }
112
+ function readPkg() {
113
+ try {
114
+ return JSON.parse(readFileSync(PLUGINS_PKG, 'utf-8'));
115
+ }
116
+ catch {
117
+ return {};
118
+ }
119
+ }
@@ -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);
23
+ const loaded = await loadPluginModule(d.name, d.source);
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);
44
+ const loaded = await loadPluginModule(name, managed.discovered.source);
45
45
  if (!loaded)
46
46
  return { success: false, error: `Failed to import "${name}"` };
47
47
  managed.plugin = loaded.plugin;
@@ -114,9 +114,12 @@ export class PluginManager {
114
114
  enabled: m.enabled,
115
115
  configSchema: m.configSchema,
116
116
  config: m.config,
117
+ source: m.discovered.source,
118
+ displayName: m.discovered.displayName,
119
+ category: m.discovered.category,
117
120
  }));
118
121
  }
119
- /** Get enabled plugins' context menu items (backward-compatible with GET /api/plugins). */
122
+ /** Get enabled plugins' context menu items and sidebar menu items. */
120
123
  getEnabledPluginDescriptors() {
121
124
  const results = [];
122
125
  for (const managed of this.plugins.values()) {
@@ -124,7 +127,9 @@ export class PluginManager {
124
127
  continue;
125
128
  results.push({
126
129
  name: managed.plugin.name,
130
+ displayName: managed.discovered.displayName,
127
131
  contextMenuItems: managed.plugin.contextMenuItems?.() || [],
132
+ sidebarMenuItems: managed.plugin.sidebarMenuItems?.() || [],
128
133
  });
129
134
  }
130
135
  return results;
@@ -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
+ }
@@ -8,7 +8,7 @@ import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
- import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc } from './helpers.js';
11
+ import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
13
  const DEFAULT_DOC = {
14
14
  type: 'doc',
@@ -31,7 +31,7 @@ const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
31
31
  const externalDocs = new Set();
32
32
  function persistExternalDocs() {
33
33
  try {
34
- writeFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]), 'utf-8');
34
+ atomicWriteFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]));
35
35
  }
36
36
  catch { /* best-effort */ }
37
37
  }
@@ -172,6 +172,15 @@ export function getStatus() {
172
172
  // SETTERS
173
173
  // ============================================================================
174
174
  export function updateDocument(doc) {
175
+ // Safety: reject dramatically smaller documents (same logic as destructive save check).
176
+ // Prevents stale browser tabs from overwriting the correct in-memory state with
177
+ // corrupted content (e.g. tweet compose view sending 4-node doc vs 40-node original).
178
+ const currentNodes = state.document?.content?.length ?? 0;
179
+ const incomingNodes = doc?.content?.length ?? 0;
180
+ if (currentNodes > 5 && incomingNodes < currentNodes * 0.3) {
181
+ console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
182
+ return;
183
+ }
175
184
  // Preserve pending attrs that the browser doesn't track in its document model.
176
185
  // Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
177
186
  // Without this, browser overwrites server state and pending info is lost on next save.
@@ -601,7 +610,7 @@ function writeToDisk() {
601
610
  catch { /* stat failed, proceed with save */ }
602
611
  }
603
612
  }
604
- writeFileSync(state.filePath, markdown, 'utf-8');
613
+ atomicWriteFileSync(state.filePath, markdown);
605
614
  // Best-effort version snapshot — never blocks saves
606
615
  try {
607
616
  snapshotIfNeeded(state.docId, state.filePath);
@@ -661,7 +670,7 @@ export function load() {
661
670
  state.docId = ensureDocId(state.metadata);
662
671
  if (!hadDocId) {
663
672
  const md = tiptapToMarkdown(state.document, state.title, state.metadata);
664
- writeFileSync(state.filePath, md, 'utf-8');
673
+ atomicWriteFileSync(state.filePath, md);
665
674
  }
666
675
  break;
667
676
  }
@@ -770,7 +779,11 @@ function cleanupEmptyTempFiles() {
770
779
  try {
771
780
  const raw = readFileSync(fullPath, 'utf-8');
772
781
  const parsed = markdownToTiptap(raw);
773
- if (isDocEmpty(parsed.document)) {
782
+ // Keep temp files that have meaningful metadata (templates, pending changes, tags)
783
+ const meta = parsed.metadata || {};
784
+ const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
785
+ || (Array.isArray(meta.tags) && meta.tags.length > 0);
786
+ if (isDocEmpty(parsed.document) && !hasMetadata) {
774
787
  unlinkSync(fullPath);
775
788
  }
776
789
  }
@@ -841,7 +854,7 @@ export function addDocTag(filename, tag) {
841
854
  tags.push(tag);
842
855
  parsed.metadata.tags = tags;
843
856
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
844
- writeFileSync(targetPath, markdown, 'utf-8');
857
+ atomicWriteFileSync(targetPath, markdown);
845
858
  }
846
859
  }
847
860
  catch { /* best-effort */ }
@@ -874,7 +887,7 @@ export function removeDocTag(filename, tag) {
874
887
  tags.splice(idx, 1);
875
888
  parsed.metadata.tags = tags.length > 0 ? tags : undefined;
876
889
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
877
- writeFileSync(targetPath, markdown, 'utf-8');
890
+ atomicWriteFileSync(targetPath, markdown);
878
891
  }
879
892
  }
880
893
  catch { /* best-effort */ }
@@ -899,7 +912,7 @@ export function saveDocToFile(filename, doc) {
899
912
  transferPendingAttrs(parsed.document, doc);
900
913
  }
901
914
  const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
902
- writeFileSync(targetPath, markdown, 'utf-8');
915
+ atomicWriteFileSync(targetPath, markdown);
903
916
  }
904
917
  catch { /* best-effort */ }
905
918
  }
@@ -933,7 +946,7 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
933
946
  delete parsed.metadata.agentCreated;
934
947
  }
935
948
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
936
- writeFileSync(targetPath, markdown, 'utf-8');
949
+ atomicWriteFileSync(targetPath, markdown);
937
950
  removePendingCacheEntry(filename);
938
951
  }
939
952
  catch { /* best-effort */ }
package/dist/server/ws.js CHANGED
@@ -27,7 +27,23 @@ function debouncedBroadcastDocumentsChanged() {
27
27
  }, 2100);
28
28
  }
29
29
  export function setupWebSocket(server) {
30
- const wss = new WebSocketServer({ server });
30
+ const wss = new WebSocketServer({
31
+ server,
32
+ verifyClient: ({ req }) => {
33
+ const origin = req.headers.origin;
34
+ // Allow connections with no origin (non-browser clients like MCP)
35
+ // and localhost origins only (blocks cross-site WebSocket hijacking)
36
+ if (!origin)
37
+ return true;
38
+ try {
39
+ const url = new URL(origin);
40
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ },
46
+ });
31
47
  // Push agent changes to all browser clients
32
48
  onChanges((changes) => {
33
49
  const msg = JSON.stringify({ type: 'node-changes', changes });
@@ -126,20 +142,29 @@ export function setupWebSocket(server) {
126
142
  try {
127
143
  const tmpl = msg.template;
128
144
  const url = msg.url;
129
- // Create with no title → temp file path (avoids naming conflicts)
130
- const result = createDocument();
131
- // Set template-appropriate metadata
145
+ // Create named document (dedup handles collisions)
146
+ let title = 'Untitled';
147
+ if (tmpl === 'tweet')
148
+ title = 'Tweet';
149
+ else if (tmpl === 'reply')
150
+ title = 'Reply';
151
+ else if (tmpl === 'quote')
152
+ title = 'Quote Tweet';
153
+ else if (tmpl === 'article')
154
+ title = 'Article';
155
+ const result = createDocument(title);
156
+ // Set template-specific metadata
132
157
  if (tmpl === 'tweet') {
133
- setMetadata({ tweetContext: { mode: 'tweet' }, title: 'Tweet' });
158
+ setMetadata({ tweetContext: { mode: 'tweet' } });
134
159
  }
135
160
  else if (tmpl === 'reply') {
136
- setMetadata({ tweetContext: { url, mode: 'reply' }, title: 'Reply' });
161
+ setMetadata({ tweetContext: { url, mode: 'reply' } });
137
162
  }
138
163
  else if (tmpl === 'quote') {
139
- setMetadata({ tweetContext: { url, mode: 'quote' }, title: 'Quote Tweet' });
164
+ setMetadata({ tweetContext: { url, mode: 'quote' } });
140
165
  }
141
166
  else if (tmpl === 'article') {
142
- setMetadata({ articleContext: { active: true }, title: 'Article' });
167
+ setMetadata({ articleContext: { active: true } });
143
168
  }
144
169
  save();
145
170
  broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
@@ -186,6 +211,7 @@ export function setupWebSocket(server) {
186
211
  }
187
212
  stripPendingAttrs();
188
213
  save();
214
+ updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
189
215
  }
190
216
  else {
191
217
  // Race path: resolved doc is NOT the active one (server switched away).
@@ -206,7 +232,8 @@ export function setupWebSocket(server) {
206
232
  });
207
233
  }
208
234
  export function broadcastDocumentSwitched(document, title, filename, metadata) {
209
- const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: metadata ?? getMetadata() });
235
+ const resolvedMeta = metadata ?? getMetadata();
236
+ const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: resolvedMeta });
210
237
  for (const ws of clients) {
211
238
  if (ws.readyState === WebSocket.OPEN) {
212
239
  ws.send(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
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",
@@ -26,6 +26,10 @@
26
26
  "bin": {
27
27
  "openwriter": "./dist/bin/pad.js"
28
28
  },
29
+ "exports": {
30
+ ".": "./dist/bin/pad.js",
31
+ "./plugin-types": "./dist/server/plugin-types.js"
32
+ },
29
33
  "scripts": {
30
34
  "dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
31
35
  "build": "vite build && tsc -p tsconfig.server.json",