openwriter 0.14.0 → 0.15.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/client/assets/index-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -7,11 +7,11 @@ import { createServer } from 'http';
|
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
9
|
import { existsSync, readFileSync } from 'fs';
|
|
10
|
-
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished,
|
|
10
|
+
import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } 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, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile } from './state.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, markAsAgentStub } from './state.js';
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
16
|
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
|
|
17
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
@@ -34,11 +34,18 @@ import { createTaskRouter } from './task-routes.js';
|
|
|
34
34
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
35
35
|
import { PluginManager } from './plugin-manager.js';
|
|
36
36
|
import { checkForUpdate, getUpdateInfo, getCurrentVersion } from './update-check.js';
|
|
37
|
-
import {
|
|
37
|
+
import { addComment, getComments, resolveComments, unresolveComments, deleteComments, editComment } from './comments.js';
|
|
38
|
+
import { initLogger, logger, generateRequestId, withRequestId } from './logger.js';
|
|
38
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
39
40
|
const __dirname = dirname(__filename);
|
|
40
41
|
export async function startHttpServer(options = {}) {
|
|
41
42
|
const port = options.port || 5050;
|
|
43
|
+
// Initialize structured logging first — every subsequent module call can
|
|
44
|
+
// emit events from this point. Config file lives at ~/.openwriter/
|
|
45
|
+
// log-config.json (missing = safe public defaults: error-only, no text).
|
|
46
|
+
// adr: adr/logging-system.md
|
|
47
|
+
initLogger();
|
|
48
|
+
logger.info('state', 'server-boot', `OpenWriter starting on port ${port}`, { port });
|
|
42
49
|
const app = express();
|
|
43
50
|
app.use(express.json({ limit: '10mb' }));
|
|
44
51
|
// API routes for direct HTTP access (fallback if WS not available)
|
|
@@ -56,26 +63,33 @@ export async function startHttpServer(options = {}) {
|
|
|
56
63
|
});
|
|
57
64
|
// MCP-over-HTTP: allows client-mode terminals to proxy tool calls
|
|
58
65
|
app.post('/api/mcp-call', async (req, res) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
const { tool: toolName, arguments: args } = req.body;
|
|
67
|
+
// Wrap the call in a request ID scope so every event logged during
|
|
68
|
+
// this tool invocation correlates. adr: adr/logging-system.md
|
|
69
|
+
const reqId = generateRequestId(`mcp-http-${toolName || 'unknown'}`);
|
|
70
|
+
await withRequestId(reqId, async () => {
|
|
71
|
+
try {
|
|
72
|
+
const tool = TOOL_REGISTRY.find((t) => t.name === toolName);
|
|
73
|
+
if (!tool) {
|
|
74
|
+
res.status(404).json({ error: `Unknown tool: ${toolName}` });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
|
|
78
|
+
const schema = z.object(tool.schema);
|
|
79
|
+
const parsed = schema.safeParse(args || {});
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
logger.debug('mcp', 'tool-call-http', tool.name, { tool: tool.name });
|
|
85
|
+
const result = await tool.handler(parsed.data);
|
|
86
|
+
res.json(result);
|
|
65
87
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!parsed.success) {
|
|
70
|
-
res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
|
|
71
|
-
return;
|
|
88
|
+
catch (err) {
|
|
89
|
+
logger.error('mcp', 'tool-error-http', `${toolName}: ${err.message}`, { tool: toolName }, err);
|
|
90
|
+
res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
|
|
72
91
|
}
|
|
73
|
-
|
|
74
|
-
res.json(result);
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
|
|
78
|
-
}
|
|
92
|
+
});
|
|
79
93
|
});
|
|
80
94
|
app.get('/api/update-info', (_req, res) => {
|
|
81
95
|
const latestVersion = getUpdateInfo();
|
|
@@ -245,7 +259,7 @@ export async function startHttpServer(options = {}) {
|
|
|
245
259
|
// Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
|
|
246
260
|
app.post('/api/flush', (req, res) => {
|
|
247
261
|
try {
|
|
248
|
-
if (isAgentLocked()) {
|
|
262
|
+
if (isAgentLocked(getActiveFilename())) {
|
|
249
263
|
console.log('[Flush] Blocked (agent write lock active)');
|
|
250
264
|
res.status(204).end();
|
|
251
265
|
return;
|
|
@@ -391,8 +405,9 @@ export async function startHttpServer(options = {}) {
|
|
|
391
405
|
save();
|
|
392
406
|
}
|
|
393
407
|
if (req.body.agentCreated) {
|
|
394
|
-
|
|
395
|
-
|
|
408
|
+
// In-memory stub registry — not persisted to disk frontmatter.
|
|
409
|
+
// adr: adr/agent-stub-model.md
|
|
410
|
+
markAsAgentStub(result.filename);
|
|
396
411
|
}
|
|
397
412
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
398
413
|
if (req.body.markPending || req.body.agentCreated) {
|
|
@@ -611,64 +626,103 @@ export async function startHttpServer(options = {}) {
|
|
|
611
626
|
res.status(400).json({ error: err.message });
|
|
612
627
|
}
|
|
613
628
|
});
|
|
614
|
-
//
|
|
615
|
-
app.post('/api/
|
|
629
|
+
// Comments (formerly "agent marks")
|
|
630
|
+
app.post('/api/comments', (req, res) => {
|
|
616
631
|
try {
|
|
617
632
|
const { filename, text, note, nodeId, nodeIds } = req.body;
|
|
618
633
|
if (!filename || !text || !nodeId) {
|
|
619
634
|
res.status(400).json({ error: 'filename, text, and nodeId are required' });
|
|
620
635
|
return;
|
|
621
636
|
}
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
res.json({ success: true,
|
|
637
|
+
const comment = addComment(filename, text, note || '', nodeId, nodeIds);
|
|
638
|
+
broadcastCommentsChanged(filename);
|
|
639
|
+
res.json({ success: true, comment });
|
|
625
640
|
}
|
|
626
641
|
catch (err) {
|
|
627
642
|
res.status(500).json({ error: err.message });
|
|
628
643
|
}
|
|
629
644
|
});
|
|
630
|
-
app.get('/api/
|
|
645
|
+
app.get('/api/comments/:filename', (req, res) => {
|
|
631
646
|
try {
|
|
632
|
-
const
|
|
633
|
-
res.json({
|
|
647
|
+
const byFile = getComments(req.params.filename);
|
|
648
|
+
res.json({ comments: byFile[req.params.filename] || [] });
|
|
634
649
|
}
|
|
635
650
|
catch (err) {
|
|
636
651
|
res.status(500).json({ error: err.message });
|
|
637
652
|
}
|
|
638
653
|
});
|
|
639
|
-
app.patch('/api/
|
|
654
|
+
app.patch('/api/comments', (req, res) => {
|
|
640
655
|
try {
|
|
641
656
|
const { filename, id, note } = req.body;
|
|
642
657
|
if (!filename || !id || typeof note !== 'string') {
|
|
643
658
|
res.status(400).json({ error: 'filename, id, and note are required' });
|
|
644
659
|
return;
|
|
645
660
|
}
|
|
646
|
-
const
|
|
647
|
-
if (!
|
|
648
|
-
res.status(404).json({ error: '
|
|
661
|
+
const comment = editComment(filename, id, note);
|
|
662
|
+
if (!comment) {
|
|
663
|
+
res.status(404).json({ error: 'comment not found' });
|
|
649
664
|
return;
|
|
650
665
|
}
|
|
651
|
-
|
|
652
|
-
res.json({ success: true,
|
|
666
|
+
broadcastCommentsChanged(filename);
|
|
667
|
+
res.json({ success: true, comment });
|
|
653
668
|
}
|
|
654
669
|
catch (err) {
|
|
655
670
|
res.status(500).json({ error: err.message });
|
|
656
671
|
}
|
|
657
672
|
});
|
|
658
|
-
|
|
673
|
+
// Permanently delete comments. Different from /resolve — this is the
|
|
674
|
+
// "remove this record" path; resolve is the "addressed, archive it" path.
|
|
675
|
+
app.delete('/api/comments', (req, res) => {
|
|
659
676
|
try {
|
|
660
677
|
const { ids } = req.body;
|
|
661
678
|
if (!Array.isArray(ids)) {
|
|
662
679
|
res.status(400).json({ error: 'ids must be an array' });
|
|
663
680
|
return;
|
|
664
681
|
}
|
|
665
|
-
const
|
|
682
|
+
const deleted = deleteComments(ids);
|
|
683
|
+
const activeFilename = getActiveFilename();
|
|
684
|
+
broadcastCommentsChanged(activeFilename);
|
|
685
|
+
res.json({ success: true, deleted });
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
res.status(500).json({ error: err.message });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
// Mark comments as resolved (state change, not deletion). The records
|
|
692
|
+
// stay on disk; only the decoration disappears.
|
|
693
|
+
app.post('/api/comments/resolve', (req, res) => {
|
|
694
|
+
try {
|
|
695
|
+
const { ids } = req.body;
|
|
696
|
+
if (!Array.isArray(ids)) {
|
|
697
|
+
res.status(400).json({ error: 'ids must be an array' });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const resolved = resolveComments(ids);
|
|
701
|
+
const activeFilename = getActiveFilename();
|
|
702
|
+
broadcastCommentsChanged(activeFilename);
|
|
666
703
|
res.json({ success: true, resolved });
|
|
667
704
|
}
|
|
668
705
|
catch (err) {
|
|
669
706
|
res.status(500).json({ error: err.message });
|
|
670
707
|
}
|
|
671
708
|
});
|
|
709
|
+
// Clear the resolved flag — the comment surfaces again and re-decorates.
|
|
710
|
+
app.post('/api/comments/unresolve', (req, res) => {
|
|
711
|
+
try {
|
|
712
|
+
const { ids } = req.body;
|
|
713
|
+
if (!Array.isArray(ids)) {
|
|
714
|
+
res.status(400).json({ error: 'ids must be an array' });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const cleared = unresolveComments(ids);
|
|
718
|
+
const activeFilename = getActiveFilename();
|
|
719
|
+
broadcastCommentsChanged(activeFilename);
|
|
720
|
+
res.json({ success: true, cleared });
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
res.status(500).json({ error: err.message });
|
|
724
|
+
}
|
|
725
|
+
});
|
|
672
726
|
// Mount workspace CRUD + doc/container routes
|
|
673
727
|
app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
|
|
674
728
|
// Mount link-doc routes (create-link-doc, auto-tag-link)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured event logger for openwriter.
|
|
3
|
+
*
|
|
4
|
+
* Architectural model:
|
|
5
|
+
* - One JSON-per-line file at `~/.openwriter/profiles/<profile>/events.log`.
|
|
6
|
+
* One file (not per-category) so grep/jq covers everything in one place.
|
|
7
|
+
* - Levels: error < warn < info < debug < trace. Default `error` — safe
|
|
8
|
+
* for public installs. Travis's machine overrides to `trace` via
|
|
9
|
+
* `~/.openwriter/log-config.json`.
|
|
10
|
+
* - Document text is redacted unless `includeText: true` is set in the
|
|
11
|
+
* config file. Public users never have text content land in logs.
|
|
12
|
+
* - Request IDs flow through async chains via AsyncLocalStorage. One
|
|
13
|
+
* external trigger (MCP tool call, WS message) gets one ID; every
|
|
14
|
+
* event emitted while processing inherits it. "What did this request
|
|
15
|
+
* cause" is a single jq query.
|
|
16
|
+
* - 50 MB rotation, keep last 5. No manual cleanup ever needed.
|
|
17
|
+
* - File handle owned by us (not stdout) so logs survive MCP kill+
|
|
18
|
+
* restart. The current `diagnostic.log` proved this works.
|
|
19
|
+
*
|
|
20
|
+
* Public users see: errors only, no text, small file, share freely for
|
|
21
|
+
* bug reports without privacy concern. Travis sees: everything, with text.
|
|
22
|
+
*
|
|
23
|
+
* adr: adr/logging-system.md
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, statSync, renameSync, unlinkSync, appendFileSync, readFileSync, watch } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
28
|
+
import { homedir } from 'os';
|
|
29
|
+
import { getDataDir } from './helpers.js';
|
|
30
|
+
const LEVEL_RANK = {
|
|
31
|
+
error: 0,
|
|
32
|
+
warn: 1,
|
|
33
|
+
info: 2,
|
|
34
|
+
debug: 3,
|
|
35
|
+
trace: 4,
|
|
36
|
+
};
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// CONFIG
|
|
39
|
+
// ============================================================================
|
|
40
|
+
const CONFIG_PATH = join(homedir(), '.openwriter', 'log-config.json');
|
|
41
|
+
const DEFAULT_CONFIG = {
|
|
42
|
+
level: 'error', // safe-for-public: errors only
|
|
43
|
+
includeText: false, // safe-for-public: no document content
|
|
44
|
+
};
|
|
45
|
+
let currentConfig = { ...DEFAULT_CONFIG };
|
|
46
|
+
let configWatcher = null;
|
|
47
|
+
function readConfig() {
|
|
48
|
+
if (!existsSync(CONFIG_PATH))
|
|
49
|
+
return { ...DEFAULT_CONFIG };
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
52
|
+
const data = JSON.parse(raw);
|
|
53
|
+
const level = (typeof data.level === 'string' && data.level in LEVEL_RANK) ? data.level : DEFAULT_CONFIG.level;
|
|
54
|
+
const includeText = data.includeText === true;
|
|
55
|
+
return { level, includeText };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { ...DEFAULT_CONFIG };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Initialize the logger. Reads config file, sets up live-reload watcher. */
|
|
62
|
+
export function initLogger() {
|
|
63
|
+
currentConfig = readConfig();
|
|
64
|
+
// Live-reload on config changes — flip verbosity without restarting.
|
|
65
|
+
if (existsSync(CONFIG_PATH)) {
|
|
66
|
+
try {
|
|
67
|
+
configWatcher?.close();
|
|
68
|
+
configWatcher = watch(CONFIG_PATH, { persistent: false }, () => {
|
|
69
|
+
const next = readConfig();
|
|
70
|
+
const changed = next.level !== currentConfig.level || next.includeText !== currentConfig.includeText;
|
|
71
|
+
currentConfig = next;
|
|
72
|
+
if (changed) {
|
|
73
|
+
// Log the config change itself so it's traceable in the log.
|
|
74
|
+
writeEvent({
|
|
75
|
+
ts: new Date().toISOString(),
|
|
76
|
+
level: 'info',
|
|
77
|
+
category: 'state',
|
|
78
|
+
event: 'log-config-reloaded',
|
|
79
|
+
fields: { level: next.level, includeText: next.includeText },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function getLogConfig() {
|
|
88
|
+
return { ...currentConfig };
|
|
89
|
+
}
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// REQUEST ID CONTEXT
|
|
92
|
+
// ============================================================================
|
|
93
|
+
const requestContext = new AsyncLocalStorage();
|
|
94
|
+
/** Generate a short, greppable request ID. */
|
|
95
|
+
export function generateRequestId(prefix) {
|
|
96
|
+
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
|
97
|
+
}
|
|
98
|
+
/** Run an async chain with a request ID attached. Every log call within
|
|
99
|
+
* fn (and its async descendants) automatically inherits the requestId. */
|
|
100
|
+
export function withRequestId(requestId, fn) {
|
|
101
|
+
return requestContext.run({ requestId }, fn);
|
|
102
|
+
}
|
|
103
|
+
/** Current request ID, if any. Undefined outside a withRequestId scope. */
|
|
104
|
+
export function getCurrentRequestId() {
|
|
105
|
+
return requestContext.getStore()?.requestId;
|
|
106
|
+
}
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// TEXT REDACTION
|
|
109
|
+
// ============================================================================
|
|
110
|
+
/** Wrap any text content that should be redacted in public logs. When
|
|
111
|
+
* `includeText: true` in config, returns the original. Otherwise returns
|
|
112
|
+
* `<redacted:Nchars>`. Use this for any document text excerpt before
|
|
113
|
+
* passing it to a log call's `fields`. */
|
|
114
|
+
export function redactText(text) {
|
|
115
|
+
if (text == null)
|
|
116
|
+
return '<null>';
|
|
117
|
+
if (currentConfig.includeText)
|
|
118
|
+
return text;
|
|
119
|
+
return `<redacted:${text.length}chars>`;
|
|
120
|
+
}
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// FILE I/O + ROTATION
|
|
123
|
+
// ============================================================================
|
|
124
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
125
|
+
const KEEP_ROTATIONS = 5;
|
|
126
|
+
function getLogPath() {
|
|
127
|
+
return join(getDataDir(), 'events.log');
|
|
128
|
+
}
|
|
129
|
+
function ensureLogDir() {
|
|
130
|
+
const dir = getDataDir();
|
|
131
|
+
if (!existsSync(dir))
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
function rotateIfNeeded() {
|
|
135
|
+
const path = getLogPath();
|
|
136
|
+
if (!existsSync(path))
|
|
137
|
+
return;
|
|
138
|
+
let size;
|
|
139
|
+
try {
|
|
140
|
+
size = statSync(path).size;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (size < MAX_FILE_BYTES)
|
|
146
|
+
return;
|
|
147
|
+
try {
|
|
148
|
+
// Shift events.log.4 → .5 → discard, .3 → .4, ..., .log → .log.1
|
|
149
|
+
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
150
|
+
const oldPath = i === 1 ? path : `${path}.${i - 1}`;
|
|
151
|
+
const newPath = `${path}.${i}`;
|
|
152
|
+
if (existsSync(oldPath)) {
|
|
153
|
+
if (i === KEEP_ROTATIONS && existsSync(newPath)) {
|
|
154
|
+
try {
|
|
155
|
+
unlinkSync(newPath);
|
|
156
|
+
}
|
|
157
|
+
catch { /* best-effort */ }
|
|
158
|
+
}
|
|
159
|
+
if (existsSync(oldPath)) {
|
|
160
|
+
try {
|
|
161
|
+
renameSync(oldPath, newPath);
|
|
162
|
+
}
|
|
163
|
+
catch { /* best-effort */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* best-effort */ }
|
|
169
|
+
}
|
|
170
|
+
function writeEvent(evt) {
|
|
171
|
+
try {
|
|
172
|
+
ensureLogDir();
|
|
173
|
+
rotateIfNeeded();
|
|
174
|
+
appendFileSync(getLogPath(), JSON.stringify(evt) + '\n');
|
|
175
|
+
}
|
|
176
|
+
catch { /* logging must never throw — swallow */ }
|
|
177
|
+
}
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// CORE LOG FUNCTIONS
|
|
180
|
+
// ============================================================================
|
|
181
|
+
function shouldLog(level) {
|
|
182
|
+
// Errors always log regardless of level (a crash trace is non-negotiable).
|
|
183
|
+
if (level === 'error')
|
|
184
|
+
return true;
|
|
185
|
+
return LEVEL_RANK[level] <= LEVEL_RANK[currentConfig.level];
|
|
186
|
+
}
|
|
187
|
+
function log(level, category, event, msg, fields, err) {
|
|
188
|
+
if (!shouldLog(level))
|
|
189
|
+
return;
|
|
190
|
+
const evt = {
|
|
191
|
+
ts: new Date().toISOString(),
|
|
192
|
+
level,
|
|
193
|
+
category,
|
|
194
|
+
event,
|
|
195
|
+
};
|
|
196
|
+
const reqId = getCurrentRequestId();
|
|
197
|
+
if (reqId)
|
|
198
|
+
evt.requestId = reqId;
|
|
199
|
+
if (msg)
|
|
200
|
+
evt.msg = msg;
|
|
201
|
+
if (fields && Object.keys(fields).length > 0)
|
|
202
|
+
evt.fields = fields;
|
|
203
|
+
if (err)
|
|
204
|
+
evt.err = { message: err.message, stack: err.stack };
|
|
205
|
+
writeEvent(evt);
|
|
206
|
+
}
|
|
207
|
+
export const logger = {
|
|
208
|
+
error(category, event, msg, fields, err) {
|
|
209
|
+
log('error', category, event, msg, fields, err);
|
|
210
|
+
},
|
|
211
|
+
warn(category, event, msg, fields) {
|
|
212
|
+
log('warn', category, event, msg, fields);
|
|
213
|
+
},
|
|
214
|
+
info(category, event, msg, fields) {
|
|
215
|
+
log('info', category, event, msg, fields);
|
|
216
|
+
},
|
|
217
|
+
debug(category, event, msg, fields) {
|
|
218
|
+
log('debug', category, event, msg, fields);
|
|
219
|
+
},
|
|
220
|
+
trace(category, event, msg, fields) {
|
|
221
|
+
log('trace', category, event, msg, fields);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// MIGRATION SHIM — `diagLog` callsites get a clean migration path
|
|
226
|
+
// ============================================================================
|
|
227
|
+
/** Legacy shim — preserves the diagLog(line) API but routes through the
|
|
228
|
+
* structured logger as a plain `info`-level event. New code should use
|
|
229
|
+
* `logger.info/warn/etc(category, event, ...)` directly. */
|
|
230
|
+
export function diagLog(line) {
|
|
231
|
+
// Categorize based on prefix heuristics for free re-tagging.
|
|
232
|
+
let category = 'state';
|
|
233
|
+
if (line.startsWith('[Overlay]'))
|
|
234
|
+
category = 'overlay';
|
|
235
|
+
else if (line.startsWith('[WS]'))
|
|
236
|
+
category = 'ws';
|
|
237
|
+
else if (line.startsWith('[Lock]'))
|
|
238
|
+
category = 'lock';
|
|
239
|
+
else if (line.startsWith('[MCP]'))
|
|
240
|
+
category = 'mcp';
|
|
241
|
+
else if (line.startsWith('[Save]') || line.startsWith('[Disk]'))
|
|
242
|
+
category = 'save';
|
|
243
|
+
else if (line.startsWith('[Watch]') || line.startsWith('[fs.watch]'))
|
|
244
|
+
category = 'watch';
|
|
245
|
+
logger.info(category, 'legacy-diag', line);
|
|
246
|
+
}
|
|
@@ -137,16 +137,19 @@ function collectBlockIds(doc) {
|
|
|
137
137
|
*/
|
|
138
138
|
export function tiptapToMarkdown(doc, title, metadata) {
|
|
139
139
|
const meta = { ...metadata, title };
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
// Disk is canonical only — never emit `pending:` frontmatter. Pending
|
|
141
|
+
// state lives in the sidecar at `_pending/{docId}.json`, separated from
|
|
142
|
+
// the .md file so external markdown editors see clean canonical content.
|
|
143
|
+
//
|
|
144
|
+
// If the caller passed a doc that still has in-memory pending attrs,
|
|
145
|
+
// serialize from a reverted clone so the body is canonical. Callers
|
|
146
|
+
// that have already done the split (writeToDisk's overlay path) pass
|
|
147
|
+
// an already-canonical doc; this revert is a no-op for them.
|
|
148
|
+
// adr: adr/pending-overlay-model.md
|
|
149
|
+
delete meta.pending;
|
|
150
|
+
const canonicalDoc = revertPendingForSerialization(doc);
|
|
148
151
|
// Collect node identity graph (id + fingerprint per block) for next-load matcher
|
|
149
|
-
const nodes = collectNodesFrontmatter(
|
|
152
|
+
const nodes = collectNodesFrontmatter(canonicalDoc);
|
|
150
153
|
if (nodes.length > 0) {
|
|
151
154
|
meta.nodes = nodes;
|
|
152
155
|
}
|
|
@@ -169,12 +172,58 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
169
172
|
delete meta[key];
|
|
170
173
|
}
|
|
171
174
|
const frontmatter = `---\n${JSON.stringify(meta)}\n---\n\n`;
|
|
172
|
-
|
|
175
|
+
// Serialize the body from the canonical (reverted) clone — never from the
|
|
176
|
+
// pending-modified live doc, otherwise the on-disk body would contain
|
|
177
|
+
// rewritten prose without the original anywhere to revert to.
|
|
178
|
+
const body = nodesToMarkdown(canonicalDoc.content || []);
|
|
173
179
|
return frontmatter + body;
|
|
174
180
|
}
|
|
175
|
-
/** Convert TipTap document to markdown body only (no frontmatter).
|
|
181
|
+
/** Convert TipTap document to markdown body only (no frontmatter).
|
|
182
|
+
* Like tiptapToMarkdown, the body is canonical (pending reverted). */
|
|
176
183
|
export function tiptapToBody(doc) {
|
|
177
|
-
|
|
184
|
+
const canonicalDoc = revertPendingForSerialization(doc);
|
|
185
|
+
return nodesToMarkdown(canonicalDoc.content || []);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Deep clone of `doc` with pending decorations reverted, used by the
|
|
189
|
+
* markdown serializer to ensure disk content is canonical. Mirrors
|
|
190
|
+
* state.cloneWithPendingReverted but is local to the serializer to
|
|
191
|
+
* avoid a state.ts → markdown-serialize.ts cycle.
|
|
192
|
+
*
|
|
193
|
+
* - status='insert' → drop the node
|
|
194
|
+
* - status='rewrite' → restore from pendingOriginalContent (or drop if absent)
|
|
195
|
+
* - status='delete' → keep but clear pending attrs
|
|
196
|
+
* - no status → keep, strip stray pending attrs
|
|
197
|
+
*/
|
|
198
|
+
const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
|
|
199
|
+
function revertPendingForSerialization(doc) {
|
|
200
|
+
function clean(node) {
|
|
201
|
+
const clone = JSON.parse(JSON.stringify(node));
|
|
202
|
+
if (clone.attrs) {
|
|
203
|
+
for (const k of PENDING_KEYS)
|
|
204
|
+
delete clone.attrs[k];
|
|
205
|
+
}
|
|
206
|
+
if (clone.content)
|
|
207
|
+
clone.content = walk(clone.content);
|
|
208
|
+
return clone;
|
|
209
|
+
}
|
|
210
|
+
function walk(nodes) {
|
|
211
|
+
const result = [];
|
|
212
|
+
for (const node of nodes || []) {
|
|
213
|
+
const status = node?.attrs?.pendingStatus;
|
|
214
|
+
if (status === 'insert')
|
|
215
|
+
continue;
|
|
216
|
+
if (status === 'rewrite') {
|
|
217
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
218
|
+
if (original)
|
|
219
|
+
result.push(clean(original));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
result.push(clean(node));
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
return { type: 'doc', content: walk(doc?.content || []) };
|
|
178
227
|
}
|
|
179
228
|
function nodesToMarkdown(nodes) {
|
|
180
229
|
let result = '';
|
|
@@ -275,28 +324,76 @@ function taskListToMarkdown(items, indent) {
|
|
|
275
324
|
}
|
|
276
325
|
return result + '\n';
|
|
277
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Serialize a TipTap table node to GFM markdown.
|
|
329
|
+
*
|
|
330
|
+
* Critical invariants (each one's absence causes silent table → paragraph
|
|
331
|
+
* loss on round-trip — observed live as `sync-check FAIL: expected table,
|
|
332
|
+
* got paragraph` on the Beat Sheet doc):
|
|
333
|
+
*
|
|
334
|
+
* 1. ALWAYS emit the header-separator row `| --- | --- |` after the first
|
|
335
|
+
* row, regardless of whether any cell is a `tableHeader`. GFM table
|
|
336
|
+
* recognition requires the delimiter row — without it, markdown-it
|
|
337
|
+
* parses each `| ... |` line as a paragraph and the entire table is
|
|
338
|
+
* dropped. (One-time consequence: a header-less table's first row
|
|
339
|
+
* becomes `tableHeader` cells after the first round-trip. Stable
|
|
340
|
+
* thereafter.)
|
|
341
|
+
*
|
|
342
|
+
* 2. Escape `|` inside cell text as `\|` so it doesn't terminate the cell
|
|
343
|
+
* column.
|
|
344
|
+
*
|
|
345
|
+
* 3. Collapse multi-paragraph cells with `<br>` joiners. The inline
|
|
346
|
+
* cell format can't represent multiple block paragraphs; without
|
|
347
|
+
* collapsing, only the first paragraph round-trips and the rest are
|
|
348
|
+
* silently lost.
|
|
349
|
+
*
|
|
350
|
+
* 4. Ensure a blank line precedes the table block (caller does `\n\n`
|
|
351
|
+
* tailing on prior nodes; we keep the leading newline minimal).
|
|
352
|
+
*/
|
|
278
353
|
function tableToMarkdown(node) {
|
|
279
354
|
const rows = node.content || [];
|
|
280
355
|
if (rows.length === 0)
|
|
281
356
|
return '';
|
|
357
|
+
function cellContentToText(cell) {
|
|
358
|
+
const content = cell.content || [];
|
|
359
|
+
if (content.length === 0)
|
|
360
|
+
return '';
|
|
361
|
+
// Each cell typically holds one paragraph, but a TipTap table can carry
|
|
362
|
+
// multi-paragraph cells (and arbitrary blocks). Concatenate paragraphs
|
|
363
|
+
// with <br> so no inline content is dropped.
|
|
364
|
+
const parts = [];
|
|
365
|
+
for (const child of content) {
|
|
366
|
+
if (child.type === 'paragraph') {
|
|
367
|
+
parts.push(inlineToMarkdown(child.content));
|
|
368
|
+
}
|
|
369
|
+
else if (child.content) {
|
|
370
|
+
// Non-paragraph block (rare in tables) — fall through to inline.
|
|
371
|
+
parts.push(inlineToMarkdown(child.content));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Escape pipes and replace newlines with <br>.
|
|
375
|
+
return parts.join('<br>').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
376
|
+
}
|
|
282
377
|
const lines = [];
|
|
283
|
-
|
|
284
|
-
|
|
378
|
+
const firstRowCells = rows[0]?.content || [];
|
|
379
|
+
const columnCount = firstRowCells.length;
|
|
380
|
+
for (let r = 0; r < rows.length; r++) {
|
|
381
|
+
const row = rows[r];
|
|
285
382
|
const cells = row.content || [];
|
|
286
|
-
const cellTexts = cells.map(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
383
|
+
const cellTexts = cells.map(cellContentToText);
|
|
384
|
+
// Pad short rows so the markdown table has consistent column count.
|
|
385
|
+
while (cellTexts.length < columnCount)
|
|
386
|
+
cellTexts.push('');
|
|
290
387
|
lines.push(`| ${cellTexts.join(' | ')} |`);
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
isFirstRow = false;
|
|
388
|
+
if (r === 0) {
|
|
389
|
+
// ALWAYS emit the separator — GFM parsing requires it for table
|
|
390
|
+
// recognition. This is the load-bearing invariant.
|
|
391
|
+
lines.push(`| ${Array(columnCount).fill('---').join(' | ')} |`);
|
|
297
392
|
}
|
|
298
393
|
}
|
|
299
|
-
|
|
394
|
+
// Leading blank line ensures we're not glued to the prior block (which
|
|
395
|
+
// would cause the table to be consumed as a paragraph continuation).
|
|
396
|
+
return '\n' + lines.join('\n') + '\n\n';
|
|
300
397
|
}
|
|
301
398
|
// ---- Inline mark serialization ----
|
|
302
399
|
const SERIALIZED_MARKS = ['bold', 'italic', 'code', 'strike', 'underline', 'highlight', 'subscript', 'superscript', 'link'];
|