openwriter 0.13.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 +71 -29
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +96 -45
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +144 -5
- package/dist/server/markdown-serialize.js +214 -30
- package/dist/server/markdown.js +32 -0
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +274 -0
- package/dist/server/node-fingerprint.js +264 -0
- package/dist/server/node-matcher.js +616 -0
- package/dist/server/node-sync-check.js +110 -0
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +1139 -110
- 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 +31 -19
- package/dist/client/assets/index-BlLnLdoc.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();
|
|
@@ -167,12 +181,9 @@ export async function startHttpServer(options = {}) {
|
|
|
167
181
|
if (isActiveDoc) {
|
|
168
182
|
if (enabled) {
|
|
169
183
|
stripPendingAttrs(); // accept any pending changes
|
|
170
|
-
setMetadata({ autoAccept: true });
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
const meta = getMetadata();
|
|
174
|
-
delete meta.autoAccept;
|
|
175
184
|
}
|
|
185
|
+
// Explicit boolean (not delete) — false overrides workspace inheritance.
|
|
186
|
+
setMetadata({ autoAccept: enabled });
|
|
176
187
|
save();
|
|
177
188
|
updatePendingCacheForActiveDoc();
|
|
178
189
|
broadcastMetadataChanged(getMetadata());
|
|
@@ -248,7 +259,7 @@ export async function startHttpServer(options = {}) {
|
|
|
248
259
|
// Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
|
|
249
260
|
app.post('/api/flush', (req, res) => {
|
|
250
261
|
try {
|
|
251
|
-
if (isAgentLocked()) {
|
|
262
|
+
if (isAgentLocked(getActiveFilename())) {
|
|
252
263
|
console.log('[Flush] Blocked (agent write lock active)');
|
|
253
264
|
res.status(204).end();
|
|
254
265
|
return;
|
|
@@ -394,8 +405,9 @@ export async function startHttpServer(options = {}) {
|
|
|
394
405
|
save();
|
|
395
406
|
}
|
|
396
407
|
if (req.body.agentCreated) {
|
|
397
|
-
|
|
398
|
-
|
|
408
|
+
// In-memory stub registry — not persisted to disk frontmatter.
|
|
409
|
+
// adr: adr/agent-stub-model.md
|
|
410
|
+
markAsAgentStub(result.filename);
|
|
399
411
|
}
|
|
400
412
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
401
413
|
if (req.body.markPending || req.body.agentCreated) {
|
|
@@ -614,64 +626,103 @@ export async function startHttpServer(options = {}) {
|
|
|
614
626
|
res.status(400).json({ error: err.message });
|
|
615
627
|
}
|
|
616
628
|
});
|
|
617
|
-
//
|
|
618
|
-
app.post('/api/
|
|
629
|
+
// Comments (formerly "agent marks")
|
|
630
|
+
app.post('/api/comments', (req, res) => {
|
|
619
631
|
try {
|
|
620
632
|
const { filename, text, note, nodeId, nodeIds } = req.body;
|
|
621
633
|
if (!filename || !text || !nodeId) {
|
|
622
634
|
res.status(400).json({ error: 'filename, text, and nodeId are required' });
|
|
623
635
|
return;
|
|
624
636
|
}
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
res.json({ success: true,
|
|
637
|
+
const comment = addComment(filename, text, note || '', nodeId, nodeIds);
|
|
638
|
+
broadcastCommentsChanged(filename);
|
|
639
|
+
res.json({ success: true, comment });
|
|
628
640
|
}
|
|
629
641
|
catch (err) {
|
|
630
642
|
res.status(500).json({ error: err.message });
|
|
631
643
|
}
|
|
632
644
|
});
|
|
633
|
-
app.get('/api/
|
|
645
|
+
app.get('/api/comments/:filename', (req, res) => {
|
|
634
646
|
try {
|
|
635
|
-
const
|
|
636
|
-
res.json({
|
|
647
|
+
const byFile = getComments(req.params.filename);
|
|
648
|
+
res.json({ comments: byFile[req.params.filename] || [] });
|
|
637
649
|
}
|
|
638
650
|
catch (err) {
|
|
639
651
|
res.status(500).json({ error: err.message });
|
|
640
652
|
}
|
|
641
653
|
});
|
|
642
|
-
app.patch('/api/
|
|
654
|
+
app.patch('/api/comments', (req, res) => {
|
|
643
655
|
try {
|
|
644
656
|
const { filename, id, note } = req.body;
|
|
645
657
|
if (!filename || !id || typeof note !== 'string') {
|
|
646
658
|
res.status(400).json({ error: 'filename, id, and note are required' });
|
|
647
659
|
return;
|
|
648
660
|
}
|
|
649
|
-
const
|
|
650
|
-
if (!
|
|
651
|
-
res.status(404).json({ error: '
|
|
661
|
+
const comment = editComment(filename, id, note);
|
|
662
|
+
if (!comment) {
|
|
663
|
+
res.status(404).json({ error: 'comment not found' });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
broadcastCommentsChanged(filename);
|
|
667
|
+
res.json({ success: true, comment });
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
res.status(500).json({ error: err.message });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
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) => {
|
|
676
|
+
try {
|
|
677
|
+
const { ids } = req.body;
|
|
678
|
+
if (!Array.isArray(ids)) {
|
|
679
|
+
res.status(400).json({ error: 'ids must be an array' });
|
|
652
680
|
return;
|
|
653
681
|
}
|
|
654
|
-
|
|
655
|
-
|
|
682
|
+
const deleted = deleteComments(ids);
|
|
683
|
+
const activeFilename = getActiveFilename();
|
|
684
|
+
broadcastCommentsChanged(activeFilename);
|
|
685
|
+
res.json({ success: true, deleted });
|
|
656
686
|
}
|
|
657
687
|
catch (err) {
|
|
658
688
|
res.status(500).json({ error: err.message });
|
|
659
689
|
}
|
|
660
690
|
});
|
|
661
|
-
|
|
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) => {
|
|
662
694
|
try {
|
|
663
695
|
const { ids } = req.body;
|
|
664
696
|
if (!Array.isArray(ids)) {
|
|
665
697
|
res.status(400).json({ error: 'ids must be an array' });
|
|
666
698
|
return;
|
|
667
699
|
}
|
|
668
|
-
const resolved =
|
|
700
|
+
const resolved = resolveComments(ids);
|
|
701
|
+
const activeFilename = getActiveFilename();
|
|
702
|
+
broadcastCommentsChanged(activeFilename);
|
|
669
703
|
res.json({ success: true, resolved });
|
|
670
704
|
}
|
|
671
705
|
catch (err) {
|
|
672
706
|
res.status(500).json({ error: err.message });
|
|
673
707
|
}
|
|
674
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
|
+
});
|
|
675
726
|
// Mount workspace CRUD + doc/container routes
|
|
676
727
|
app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
|
|
677
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
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown -> TipTap JSON parsing.
|
|
3
3
|
* Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
|
|
4
|
+
*
|
|
5
|
+
* Node identity is reassigned via the matcher when the frontmatter carries a
|
|
6
|
+
* `nodes` array (the new path). For legacy docs without `nodes`, trailing
|
|
7
|
+
* `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
|
|
8
|
+
* recognized as ID sources so existing files keep their identities through
|
|
9
|
+
* the migration. Once a migrated doc is saved, the body is clean and all
|
|
10
|
+
* identity lives in frontmatter.
|
|
11
|
+
*
|
|
12
|
+
* adr: adr/node-identity-matcher.md
|
|
4
13
|
*/
|
|
5
14
|
import MarkdownIt from 'markdown-it';
|
|
6
15
|
import matter from 'gray-matter';
|
|
@@ -10,6 +19,8 @@ import markdownItSub from 'markdown-it-sub';
|
|
|
10
19
|
import markdownItSup from 'markdown-it-sup';
|
|
11
20
|
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
12
21
|
import { nodeText } from './markdown-serialize.js';
|
|
22
|
+
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
23
|
+
import { matchNodes } from './node-matcher.js';
|
|
13
24
|
// ============================================================================
|
|
14
25
|
// Markdown -> TipTap
|
|
15
26
|
// ============================================================================
|
|
@@ -19,24 +30,152 @@ md.use(markdownItIns);
|
|
|
19
30
|
md.use(markdownItMark);
|
|
20
31
|
md.use(markdownItSub);
|
|
21
32
|
md.use(markdownItSup);
|
|
33
|
+
/**
|
|
34
|
+
* Normalize blank lines INSIDE markdown tables before parsing.
|
|
35
|
+
*
|
|
36
|
+
* Per CommonMark, a blank line terminates a table block. Agents writing
|
|
37
|
+
* markdown content frequently insert blank lines between table rows for
|
|
38
|
+
* readability (e.g. `| row |\n\n| row |`), which the strict parser then
|
|
39
|
+
* splits into "table with 1 header row" + N orphan paragraphs that happen
|
|
40
|
+
* to contain pipe characters. The broken structure persists across saves
|
|
41
|
+
* because every serialize → re-parse cycle re-breaks it.
|
|
42
|
+
*
|
|
43
|
+
* This pre-pass detects "table region" (saw a separator row `| --- |`,
|
|
44
|
+
* haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
|
|
45
|
+
* inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
|
|
46
|
+
* inside them stay untouched.
|
|
47
|
+
*
|
|
48
|
+
* Self-healing: a doc on disk with blank-separated rows loads as a proper
|
|
49
|
+
* N-row table; the next save writes contiguous markdown. No migration
|
|
50
|
+
* script needed.
|
|
51
|
+
*/
|
|
52
|
+
function normalizeTableBlankLines(markdown) {
|
|
53
|
+
if (!markdown.includes('|'))
|
|
54
|
+
return markdown;
|
|
55
|
+
const lines = markdown.split('\n');
|
|
56
|
+
const out = [];
|
|
57
|
+
let inFence = false;
|
|
58
|
+
let inTable = false; // true between a separator row and the next non-pipe-row
|
|
59
|
+
const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
|
|
60
|
+
const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
|
|
61
|
+
const isBlank = (s) => s.trim() === '';
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i];
|
|
64
|
+
// Code-fence toggle — pipes inside fences stay verbatim
|
|
65
|
+
if (/^[`~]{3,}/.test(line)) {
|
|
66
|
+
inFence = !inFence;
|
|
67
|
+
inTable = false;
|
|
68
|
+
out.push(line);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (inFence) {
|
|
72
|
+
out.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (isSeparator(line)) {
|
|
76
|
+
// Separator confirms we're in a table region from here on
|
|
77
|
+
inTable = true;
|
|
78
|
+
out.push(line);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (inTable && isBlank(line)) {
|
|
82
|
+
// Look ahead past additional blanks for the next non-blank line
|
|
83
|
+
let j = i + 1;
|
|
84
|
+
while (j < lines.length && lines[j].trim() === '')
|
|
85
|
+
j++;
|
|
86
|
+
// If the next non-blank line is another pipe-row, it's EITHER a
|
|
87
|
+
// continuation row (merge across the blank) OR the header of a NEW
|
|
88
|
+
// table (the blank is the boundary, don't merge). We tell them apart
|
|
89
|
+
// by peeking one further: if line j+1 is a separator row, j is a new
|
|
90
|
+
// table's header — preserve the blank and exit the current table region.
|
|
91
|
+
if (j < lines.length && isPipeRow(lines[j])) {
|
|
92
|
+
if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
|
|
93
|
+
// New table starting — keep the boundary blank, end current region
|
|
94
|
+
inTable = false;
|
|
95
|
+
out.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Continuation row — drop the blank
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Lookahead found non-pipe content — table region is ending
|
|
102
|
+
inTable = false;
|
|
103
|
+
out.push(line);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (inTable && !isPipeRow(line)) {
|
|
107
|
+
// Non-pipe content ends the table region
|
|
108
|
+
inTable = false;
|
|
109
|
+
}
|
|
110
|
+
out.push(line);
|
|
111
|
+
}
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
}
|
|
22
114
|
export function markdownToTiptap(markdown) {
|
|
23
115
|
const result = matter(markdown);
|
|
24
116
|
const { data, content } = result;
|
|
25
117
|
const title = data.title || 'Untitled';
|
|
26
|
-
const
|
|
118
|
+
const normalizedContent = normalizeTableBlankLines(content);
|
|
119
|
+
const tokens = md.parse(normalizedContent, {});
|
|
27
120
|
const docContent = tokensToTiptap(tokens);
|
|
28
121
|
const doc = {
|
|
29
122
|
type: 'doc',
|
|
30
123
|
content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
31
124
|
};
|
|
125
|
+
// Extract identity graph from frontmatter — these become the matcher's
|
|
126
|
+
// previousNodes input on both the load-time pass below AND on every
|
|
127
|
+
// subsequent save-time pass while the doc stays loaded.
|
|
128
|
+
const previousNodes = normalizeNodeEntries(data.nodes);
|
|
129
|
+
const graveyard = normalizeNodeEntries(data.graveyard);
|
|
130
|
+
// Load-time matcher pass — when frontmatter carries `nodes`, reassign IDs
|
|
131
|
+
// based on fingerprint match. Legacy docs (no `nodes` field) keep whatever
|
|
132
|
+
// IDs the body parser extracted from caret anchors or minted fresh.
|
|
133
|
+
if (previousNodes.length > 0) {
|
|
134
|
+
applyMatcher(doc, previousNodes, graveyard);
|
|
135
|
+
}
|
|
32
136
|
// Rehydrate pending state from frontmatter into node attrs
|
|
33
137
|
if (data.pending) {
|
|
34
138
|
rehydratePendingState(doc, data.pending);
|
|
35
139
|
}
|
|
36
|
-
// Strip
|
|
140
|
+
// Strip consumed keys from returned metadata
|
|
37
141
|
const metadata = { ...data };
|
|
38
142
|
delete metadata.pending;
|
|
39
|
-
|
|
143
|
+
delete metadata.nodes;
|
|
144
|
+
delete metadata.graveyard;
|
|
145
|
+
return {
|
|
146
|
+
title,
|
|
147
|
+
metadata,
|
|
148
|
+
document: doc,
|
|
149
|
+
rawFrontmatter: result.matter || null,
|
|
150
|
+
graveyard,
|
|
151
|
+
previousNodes,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Defensive parse of frontmatter node entries — drops any malformed rows. */
|
|
155
|
+
function normalizeNodeEntries(raw) {
|
|
156
|
+
if (!Array.isArray(raw))
|
|
157
|
+
return [];
|
|
158
|
+
return raw
|
|
159
|
+
.filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
|
|
160
|
+
.map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
|
|
164
|
+
* the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
|
|
165
|
+
*
|
|
166
|
+
* Graveyard is passed through so paste-back of recently-deleted content
|
|
167
|
+
* restores the original ID (matched by exact fingerprint).
|
|
168
|
+
*/
|
|
169
|
+
function applyMatcher(doc, previousNodes, graveyard) {
|
|
170
|
+
if (previousNodes.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
const newBlocks = tiptapToBlocks(doc);
|
|
173
|
+
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
|
|
174
|
+
const pinnedByPosition = new Map();
|
|
175
|
+
for (const p of matchResult.pinned) {
|
|
176
|
+
pinnedByPosition.set(p.position, p.id);
|
|
177
|
+
}
|
|
178
|
+
applyIdsToTiptap(doc, pinnedByPosition);
|
|
40
179
|
}
|
|
41
180
|
/**
|
|
42
181
|
* Rehydrate pending state from frontmatter into leaf block node attrs.
|
|
@@ -453,8 +592,8 @@ function popMarkByType(stack, type) {
|
|
|
453
592
|
/**
|
|
454
593
|
* Extract a trailing nodeId anchor from inline content.
|
|
455
594
|
* Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
|
|
456
|
-
*
|
|
457
|
-
*
|
|
595
|
+
* Strips the marker from the visible text and returns the captured id.
|
|
596
|
+
* Returns id=null if no anchor found.
|
|
458
597
|
*
|
|
459
598
|
* Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
|
|
460
599
|
* will be interpreted as an anchor. Vanishingly rare in real writing.
|