openwriter 0.1.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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Workspace manifest CRUD for OpenWriter v2.
3
+ * Unified container model: containers (ordered/unordered) hold docs, tags are cross-cutting.
4
+ * Manifests live in ~/.openwriter/_workspaces/*.json.
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { randomUUID } from 'crypto';
9
+ import matter from 'gray-matter';
10
+ import { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath } from './helpers.js';
11
+ const ORDER_FILE = join(WORKSPACES_DIR, '_order.json');
12
+ import { isV1, migrateV1toV2 } from './workspace-types.js';
13
+ import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
14
+ import { addTag, removeTag, removeFileFromAllTags, listTagsForFile } from './workspace-tags.js';
15
+ // ============================================================================
16
+ // INTERNAL HELPERS
17
+ // ============================================================================
18
+ function workspacePath(filename) {
19
+ return join(WORKSPACES_DIR, filename);
20
+ }
21
+ function readWorkspace(filename) {
22
+ const raw = readFileSync(workspacePath(filename), 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ if (isV1(parsed)) {
25
+ const migrated = migrateV1toV2(parsed);
26
+ writeWorkspace(filename, migrated);
27
+ return migrated;
28
+ }
29
+ return parsed;
30
+ }
31
+ function writeWorkspace(filename, workspace) {
32
+ writeFileSync(workspacePath(filename), JSON.stringify(workspace, null, 2), 'utf-8');
33
+ }
34
+ function readOrder() {
35
+ try {
36
+ if (!existsSync(ORDER_FILE))
37
+ return [];
38
+ return JSON.parse(readFileSync(ORDER_FILE, 'utf-8'));
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ function writeOrder(order) {
45
+ writeFileSync(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
46
+ }
47
+ function readDocFrontmatter(filename) {
48
+ try {
49
+ const filePath = resolveDocPath(filename);
50
+ if (!existsSync(filePath))
51
+ return null;
52
+ const raw = readFileSync(filePath, 'utf-8');
53
+ const { data } = matter(raw);
54
+ return data;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ // ============================================================================
61
+ // CRUD
62
+ // ============================================================================
63
+ export function listWorkspaces() {
64
+ ensureWorkspacesDir();
65
+ const files = readdirSync(WORKSPACES_DIR).filter((f) => f.endsWith('.json') && f !== '_order.json');
66
+ const infos = files.map((f) => {
67
+ try {
68
+ const ws = readWorkspace(f);
69
+ return { filename: f, title: ws.title, docCount: countDocs(ws.root) };
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }).filter((b) => b !== null);
75
+ // Sort by persisted order; unknown workspaces append at end
76
+ const order = readOrder();
77
+ if (order.length === 0)
78
+ return infos;
79
+ const orderIndex = new Map(order.map((f, i) => [f, i]));
80
+ infos.sort((a, b) => {
81
+ const ai = orderIndex.get(a.filename) ?? Infinity;
82
+ const bi = orderIndex.get(b.filename) ?? Infinity;
83
+ return ai - bi;
84
+ });
85
+ return infos;
86
+ }
87
+ export function getWorkspace(filename) {
88
+ ensureWorkspacesDir();
89
+ const p = workspacePath(filename);
90
+ if (!existsSync(p))
91
+ throw new Error(`Workspace not found: ${filename}`);
92
+ return readWorkspace(filename);
93
+ }
94
+ export function createWorkspace(options) {
95
+ ensureWorkspacesDir();
96
+ const { title, voiceProfileId = null } = options;
97
+ const slug = sanitizeFilename(title).toLowerCase().replace(/\s+/g, '-');
98
+ const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
99
+ const workspace = { version: 2, title, voiceProfileId, root: [], tags: {} };
100
+ writeWorkspace(filename, workspace);
101
+ // Append to order
102
+ const order = readOrder();
103
+ order.push(filename);
104
+ writeOrder(order);
105
+ return { filename, title, docCount: 0 };
106
+ }
107
+ export function deleteWorkspace(filename) {
108
+ ensureWorkspacesDir();
109
+ const p = workspacePath(filename);
110
+ if (!existsSync(p))
111
+ throw new Error(`Workspace not found: ${filename}`);
112
+ unlinkSync(p);
113
+ // Remove from order
114
+ const order = readOrder();
115
+ const idx = order.indexOf(filename);
116
+ if (idx >= 0) {
117
+ order.splice(idx, 1);
118
+ writeOrder(order);
119
+ }
120
+ }
121
+ export function reorderWorkspaces(orderedFilenames) {
122
+ ensureWorkspacesDir();
123
+ writeOrder(orderedFilenames);
124
+ }
125
+ // ============================================================================
126
+ // DOC OPERATIONS
127
+ // ============================================================================
128
+ export function addDoc(wsFile, containerId, file, title, afterFile) {
129
+ const ws = getWorkspace(wsFile);
130
+ addDocToContainer(ws.root, containerId, file, title, afterFile);
131
+ writeWorkspace(wsFile, ws);
132
+ return ws;
133
+ }
134
+ export function removeDoc(wsFile, file) {
135
+ const ws = getWorkspace(wsFile);
136
+ removeNode(ws.root, file);
137
+ removeFileFromAllTags(ws.tags, file);
138
+ writeWorkspace(wsFile, ws);
139
+ return ws;
140
+ }
141
+ export function moveDoc(wsFile, file, targetContainerId, afterFile) {
142
+ const ws = getWorkspace(wsFile);
143
+ moveNode(ws.root, file, targetContainerId, afterFile);
144
+ writeWorkspace(wsFile, ws);
145
+ return ws;
146
+ }
147
+ export function reorderDoc(wsFile, file, afterFile) {
148
+ const ws = getWorkspace(wsFile);
149
+ reorderNode(ws.root, file, afterFile);
150
+ writeWorkspace(wsFile, ws);
151
+ return ws;
152
+ }
153
+ // ============================================================================
154
+ // CONTAINER OPERATIONS
155
+ // ============================================================================
156
+ export function addContainerToWorkspace(wsFile, parentContainerId, name) {
157
+ const ws = getWorkspace(wsFile);
158
+ const container = addContainerToTree(ws.root, parentContainerId, name);
159
+ writeWorkspace(wsFile, ws);
160
+ return { workspace: ws, containerId: container.id };
161
+ }
162
+ export function removeContainer(wsFile, containerId) {
163
+ const ws = getWorkspace(wsFile);
164
+ const found = findContainer(ws.root, containerId);
165
+ if (!found)
166
+ throw new Error(`Container "${containerId}" not found`);
167
+ // Collect files in container to clean up tags
168
+ const files = collectAllFiles(found.node.items || []);
169
+ removeNode(ws.root, containerId);
170
+ for (const file of files)
171
+ removeFileFromAllTags(ws.tags, file);
172
+ writeWorkspace(wsFile, ws);
173
+ return ws;
174
+ }
175
+ export function renameContainer(wsFile, containerId, name) {
176
+ const ws = getWorkspace(wsFile);
177
+ const found = findContainer(ws.root, containerId);
178
+ if (!found)
179
+ throw new Error(`Container "${containerId}" not found`);
180
+ found.node.name = name;
181
+ writeWorkspace(wsFile, ws);
182
+ return ws;
183
+ }
184
+ export function reorderContainer(wsFile, containerId, afterIdentifier) {
185
+ const ws = getWorkspace(wsFile);
186
+ reorderNode(ws.root, containerId, afterIdentifier);
187
+ writeWorkspace(wsFile, ws);
188
+ return ws;
189
+ }
190
+ // ============================================================================
191
+ // TAG OPERATIONS
192
+ // ============================================================================
193
+ export function tagDoc(wsFile, file, tag) {
194
+ const ws = getWorkspace(wsFile);
195
+ if (!findDocNode(ws.root, file))
196
+ throw new Error(`Document "${file}" not in workspace`);
197
+ addTag(ws.tags, tag, file);
198
+ writeWorkspace(wsFile, ws);
199
+ return ws;
200
+ }
201
+ export function untagDoc(wsFile, file, tag) {
202
+ const ws = getWorkspace(wsFile);
203
+ removeTag(ws.tags, tag, file);
204
+ writeWorkspace(wsFile, ws);
205
+ return ws;
206
+ }
207
+ export function getDocTags(wsFile, file) {
208
+ const ws = getWorkspace(wsFile);
209
+ return listTagsForFile(ws.tags, file);
210
+ }
211
+ // ============================================================================
212
+ // CONTEXT
213
+ // ============================================================================
214
+ export function updateWorkspaceContext(wsFile, context) {
215
+ const ws = getWorkspace(wsFile);
216
+ ws.context = { ...ws.context, ...context };
217
+ writeWorkspace(wsFile, ws);
218
+ return ws;
219
+ }
220
+ export function getItemContext(wsFile, docFile) {
221
+ const ws = getWorkspace(wsFile);
222
+ const found = findDocNode(ws.root, docFile);
223
+ if (!found)
224
+ throw new Error(`Document "${docFile}" not found in workspace`);
225
+ const tags = listTagsForFile(ws.tags, docFile);
226
+ return {
227
+ workspaceTitle: ws.title,
228
+ workspaceContext: ws.context || {},
229
+ tags,
230
+ };
231
+ }
232
+ // ============================================================================
233
+ // CROSS-WORKSPACE QUERIES
234
+ // ============================================================================
235
+ export function getWorkspaceAssignedFiles() {
236
+ const assigned = new Set();
237
+ const workspaces = listWorkspaces();
238
+ for (const info of workspaces) {
239
+ try {
240
+ const ws = readWorkspace(info.filename);
241
+ for (const file of collectAllFiles(ws.root))
242
+ assigned.add(file);
243
+ }
244
+ catch { /* skip corrupt manifests */ }
245
+ }
246
+ return assigned;
247
+ }
248
+ export function getWorkspaceStructure(filename) {
249
+ return getWorkspace(filename);
250
+ }
251
+ /** Read the frontmatter title for a doc file. Falls back to filename without extension. */
252
+ export function getDocTitle(filename) {
253
+ const fm = readDocFrontmatter(filename);
254
+ if (fm?.title && fm.title !== 'Untitled')
255
+ return fm.title;
256
+ return filename.replace(/\.md$/, '');
257
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
3
+ */
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { updateDocument, getDocument, getTitle, getFilePath, getDocId, setMetadata, save, onChanges, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, stripPendingAttrs, } from './state.js';
6
+ import { switchDocument, createDocument, deleteDocument } from './documents.js';
7
+ const clients = new Set();
8
+ let currentAgentConnected = false;
9
+ // Debounced auto-save: persist to disk 2s after last doc-update
10
+ let saveTimer = null;
11
+ function debouncedSave() {
12
+ if (saveTimer)
13
+ clearTimeout(saveTimer);
14
+ saveTimer = setTimeout(() => {
15
+ save();
16
+ console.log('[WS] Auto-saved to disk');
17
+ }, 2000);
18
+ }
19
+ export function setupWebSocket(server) {
20
+ const wss = new WebSocketServer({ server });
21
+ // Push agent changes to all browser clients
22
+ onChanges((changes) => {
23
+ const msg = JSON.stringify({ type: 'node-changes', changes });
24
+ for (const ws of clients) {
25
+ if (ws.readyState === WebSocket.OPEN) {
26
+ ws.send(msg);
27
+ }
28
+ }
29
+ // Notify browser of updated pending docs list (debounced)
30
+ broadcastPendingDocsChanged();
31
+ });
32
+ wss.on('connection', (ws) => {
33
+ clients.add(ws);
34
+ console.log(`[WS] Client connected (total: ${clients.size})`);
35
+ // Send current agent status to newly connected client
36
+ ws.send(JSON.stringify({ type: 'agent-status', agentConnected: currentAgentConnected }));
37
+ // Send current sync status if available
38
+ if (lastSyncStatus) {
39
+ ws.send(JSON.stringify({ type: 'sync-status', ...lastSyncStatus }));
40
+ }
41
+ // Always send authoritative document state on connect — forces browser to adopt server state
42
+ // (prevents stale browser tabs from displaying old content)
43
+ const filePath = getFilePath();
44
+ const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
45
+ ws.send(JSON.stringify({
46
+ type: 'document-switched',
47
+ document: getDocument(),
48
+ title: getTitle(),
49
+ filename,
50
+ docId: getDocId(),
51
+ }));
52
+ // Send pending docs info on connect
53
+ ws.send(JSON.stringify({
54
+ type: 'pending-docs-changed',
55
+ pendingDocs: {
56
+ filenames: getPendingDocFilenames(),
57
+ counts: getPendingDocCounts(),
58
+ },
59
+ }));
60
+ ws.on('message', (data) => {
61
+ try {
62
+ const msg = JSON.parse(data.toString());
63
+ if (msg.type === 'doc-update' && msg.document) {
64
+ if (isAgentLocked()) {
65
+ // Agent write in progress — ignore browser doc-updates
66
+ }
67
+ else {
68
+ updateDocument(msg.document);
69
+ debouncedSave();
70
+ }
71
+ }
72
+ // Browser requests fresh state on reconnect (instead of pushing stale state)
73
+ if (msg.type === 'request-document') {
74
+ const filePath = getFilePath();
75
+ const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
76
+ ws.send(JSON.stringify({
77
+ type: 'document-switched',
78
+ document: getDocument(),
79
+ title: getTitle(),
80
+ filename,
81
+ docId: getDocId(),
82
+ }));
83
+ }
84
+ if (msg.type === 'title-update' && msg.title) {
85
+ setMetadata({ title: msg.title });
86
+ debouncedSave();
87
+ }
88
+ if (msg.type === 'save') {
89
+ save();
90
+ }
91
+ if (msg.type === 'switch-document' && msg.filename) {
92
+ try {
93
+ const result = switchDocument(msg.filename);
94
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
95
+ }
96
+ catch (err) {
97
+ console.error('[WS] Switch document failed:', err.message);
98
+ }
99
+ }
100
+ if (msg.type === 'create-document') {
101
+ try {
102
+ const result = createDocument(msg.title);
103
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
104
+ }
105
+ catch (err) {
106
+ console.error('[WS] Create document failed:', err.message);
107
+ }
108
+ }
109
+ if (msg.type === 'pending-resolved' && msg.filename) {
110
+ const action = msg.action; // 'accept' or 'reject'
111
+ const resolvedFilename = msg.filename;
112
+ if (action === 'reject' && msg.wasAgentCreated) {
113
+ // Agent-created doc with all content rejected → delete the file
114
+ try {
115
+ deleteDocument(resolvedFilename);
116
+ }
117
+ catch (err) {
118
+ console.error('[WS] Failed to delete rejected agent doc:', err.message);
119
+ }
120
+ }
121
+ // Strip pending attrs that transferPendingAttrs() re-added from stale server state,
122
+ // then save clean markdown to disk.
123
+ stripPendingAttrs();
124
+ save();
125
+ broadcastPendingDocsChanged();
126
+ }
127
+ }
128
+ catch {
129
+ // Ignore malformed messages
130
+ }
131
+ });
132
+ ws.on('close', () => {
133
+ clients.delete(ws);
134
+ console.log(`[WS] Client disconnected (total: ${clients.size})`);
135
+ });
136
+ });
137
+ }
138
+ export function broadcastDocumentSwitched(document, title, filename) {
139
+ const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId() });
140
+ for (const ws of clients) {
141
+ if (ws.readyState === WebSocket.OPEN) {
142
+ ws.send(msg);
143
+ }
144
+ }
145
+ }
146
+ export function broadcastDocumentsChanged() {
147
+ const msg = JSON.stringify({ type: 'documents-changed' });
148
+ for (const ws of clients) {
149
+ if (ws.readyState === WebSocket.OPEN) {
150
+ ws.send(msg);
151
+ }
152
+ }
153
+ }
154
+ export function broadcastWorkspacesChanged() {
155
+ const msg = JSON.stringify({ type: 'workspaces-changed' });
156
+ for (const ws of clients) {
157
+ if (ws.readyState === WebSocket.OPEN)
158
+ ws.send(msg);
159
+ }
160
+ }
161
+ export function broadcastTitleChanged(title) {
162
+ const msg = JSON.stringify({ type: 'title-changed', title });
163
+ for (const ws of clients) {
164
+ if (ws.readyState === WebSocket.OPEN)
165
+ ws.send(msg);
166
+ }
167
+ }
168
+ // Debounced: getPendingDocCounts() scans all files on disk + parses YAML.
169
+ // Rapid agent writes would trigger this scan on every change batch.
170
+ let pendingDocsTimer = null;
171
+ const PENDING_DOCS_DEBOUNCE_MS = 500;
172
+ export function broadcastPendingDocsChanged() {
173
+ if (pendingDocsTimer)
174
+ clearTimeout(pendingDocsTimer);
175
+ pendingDocsTimer = setTimeout(() => {
176
+ pendingDocsTimer = null;
177
+ const msg = JSON.stringify({
178
+ type: 'pending-docs-changed',
179
+ pendingDocs: {
180
+ filenames: getPendingDocFilenames(),
181
+ counts: getPendingDocCounts(),
182
+ },
183
+ });
184
+ for (const ws of clients) {
185
+ if (ws.readyState === WebSocket.OPEN)
186
+ ws.send(msg);
187
+ }
188
+ }, PENDING_DOCS_DEBOUNCE_MS);
189
+ }
190
+ export function broadcastAgentStatus(connected) {
191
+ currentAgentConnected = connected;
192
+ const msg = JSON.stringify({ type: 'agent-status', agentConnected: connected });
193
+ for (const ws of clients) {
194
+ if (ws.readyState === WebSocket.OPEN) {
195
+ ws.send(msg);
196
+ }
197
+ }
198
+ }
199
+ let lastSyncStatus = null;
200
+ export function broadcastSyncStatus(status) {
201
+ lastSyncStatus = status;
202
+ const msg = JSON.stringify({ type: 'sync-status', ...status });
203
+ for (const ws of clients) {
204
+ if (ws.readyState === WebSocket.OPEN) {
205
+ ws.send(msg);
206
+ }
207
+ }
208
+ }
209
+ export function getLastSyncStatus() {
210
+ return lastSyncStatus;
211
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "openwriter",
3
+ "version": "0.1.0",
4
+ "description": "Local TipTap editor for human-agent collaboration via MCP",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Travis Steward",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/travsteward/openwriter.git"
11
+ },
12
+ "homepage": "https://github.com/travsteward/openwriter",
13
+ "bugs": "https://github.com/travsteward/openwriter/issues",
14
+ "keywords": [
15
+ "editor",
16
+ "tiptap",
17
+ "mcp",
18
+ "ai",
19
+ "writing",
20
+ "agent",
21
+ "local-first",
22
+ "markdown",
23
+ "rich-text",
24
+ "human-agent-collaboration"
25
+ ],
26
+ "bin": {
27
+ "openwriter": "./dist/bin/pad.js"
28
+ },
29
+ "scripts": {
30
+ "dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
31
+ "build": "vite build && tsc -p tsconfig.server.json",
32
+ "preview": "node dist/bin/pad.js",
33
+ "lint": "eslint src server bin --ext .ts,.tsx"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.12.1",
37
+ "@tiptap/core": "^3.0.0",
38
+ "@tiptap/extension-code-block-lowlight": "^3.19.0",
39
+ "@tiptap/extension-highlight": "^3.19.0",
40
+ "@tiptap/extension-image": "^3.19.0",
41
+ "@tiptap/extension-link": "^3.19.0",
42
+ "@tiptap/extension-placeholder": "^3.0.0",
43
+ "@tiptap/extension-subscript": "^3.19.0",
44
+ "@tiptap/extension-superscript": "^3.19.0",
45
+ "@tiptap/extension-table": "^3.19.0",
46
+ "@tiptap/extension-task-item": "^3.19.0",
47
+ "@tiptap/extension-task-list": "^3.19.0",
48
+ "@tiptap/extension-text-style": "^3.0.0",
49
+ "@tiptap/extension-typography": "^3.19.0",
50
+ "@tiptap/extension-underline": "^3.0.0",
51
+ "@tiptap/extension-unique-id": "^3.0.0",
52
+ "@tiptap/pm": "^3.0.0",
53
+ "@tiptap/react": "^3.0.0",
54
+ "@tiptap/starter-kit": "^3.0.0",
55
+ "@turbodocx/html-to-docx": "^1.20.1",
56
+ "@types/multer": "^2.0.0",
57
+ "express": "^4.21.0",
58
+ "gray-matter": "^4.0.3",
59
+ "lowlight": "^3.3.0",
60
+ "markdown-it": "^14.1.1",
61
+ "markdown-it-ins": "^4.0.0",
62
+ "markdown-it-mark": "^4.0.0",
63
+ "markdown-it-sub": "^2.0.0",
64
+ "markdown-it-sup": "^2.0.0",
65
+ "multer": "^2.0.2",
66
+ "open": "^10.1.0",
67
+ "react": "^18.3.1",
68
+ "react-dom": "^18.3.1",
69
+ "ws": "^8.18.0",
70
+ "zod": "^3.25.76"
71
+ },
72
+ "devDependencies": {
73
+ "@types/express": "^5.0.0",
74
+ "@types/markdown-it": "^14.1.2",
75
+ "@types/react": "^18.3.0",
76
+ "@types/react-dom": "^18.3.0",
77
+ "@types/ws": "^8.5.0",
78
+ "@vitejs/plugin-react": "^4.3.0",
79
+ "concurrently": "^9.1.0",
80
+ "tsx": "^4.19.0",
81
+ "typescript": "^5.6.0",
82
+ "vite": "^6.0.0"
83
+ },
84
+ "files": [
85
+ "dist/",
86
+ "package.json"
87
+ ]
88
+ }