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.
- package/dist/bin/pad.js +3 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-Be3gaGeo.css +1 -0
- package/dist/client/assets/index-BwT1KW6a.js +207 -0
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/index.html +5 -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 +141 -8
- 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-discovery.js +102 -10
- package/dist/server/plugin-install.js +119 -0
- package/dist/server/plugin-manager.js +8 -3
- 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 +5 -1
- package/skill/SKILL.md +101 -17
- package/dist/client/assets/index-BTxdHrWL.js +0 -209
- package/dist/client/assets/index-C9E86o6p.css +0 -1
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin discovery: scans
|
|
3
|
-
* Reads package.json metadata without importing
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/server/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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' }
|
|
158
|
+
setMetadata({ tweetContext: { mode: 'tweet' } });
|
|
134
159
|
}
|
|
135
160
|
else if (tmpl === 'reply') {
|
|
136
|
-
setMetadata({ tweetContext: { url, mode: 'reply' }
|
|
161
|
+
setMetadata({ tweetContext: { url, mode: 'reply' } });
|
|
137
162
|
}
|
|
138
163
|
else if (tmpl === 'quote') {
|
|
139
|
-
setMetadata({ tweetContext: { url, mode: 'quote' }
|
|
164
|
+
setMetadata({ tweetContext: { url, mode: 'quote' } });
|
|
140
165
|
}
|
|
141
166
|
else if (tmpl === 'article') {
|
|
142
|
-
setMetadata({ articleContext: { active: true }
|
|
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
|
|
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
|
+
"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",
|