openwriter 0.14.0 → 0.16.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-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- 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)
|
|
@@ -136,6 +136,21 @@ export function installSkill() {
|
|
|
136
136
|
}
|
|
137
137
|
log(` ✓ Skill docs copied to ${docsTarget}`);
|
|
138
138
|
}
|
|
139
|
+
// Install custom Claude Code subagents to ~/.claude/agents/. These have
|
|
140
|
+
// allowlist-restricted tools so the main agent can dispatch them without
|
|
141
|
+
// loading the full MCP tool registry into the subagent's context
|
|
142
|
+
// (~50K tokens of overhead avoided per spawn).
|
|
143
|
+
const agentsSource = path.join(__dirname, '../../skill/agents');
|
|
144
|
+
if (fs.existsSync(agentsSource)) {
|
|
145
|
+
const agentsTarget = path.join(os.homedir(), '.claude', 'agents');
|
|
146
|
+
fs.mkdirSync(agentsTarget, { recursive: true });
|
|
147
|
+
for (const file of fs.readdirSync(agentsSource)) {
|
|
148
|
+
if (!file.endsWith('.md'))
|
|
149
|
+
continue;
|
|
150
|
+
fs.copyFileSync(path.join(agentsSource, file), path.join(agentsTarget, file));
|
|
151
|
+
log(` ✓ Subagent installed: ${file}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
139
154
|
// Step 2: Global install or update
|
|
140
155
|
let useNpx = false;
|
|
141
156
|
const currentVersion = getGlobalVersion();
|
|
@@ -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
|
+
}
|
|
@@ -21,6 +21,7 @@ import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
|
21
21
|
import { nodeText } from './markdown-serialize.js';
|
|
22
22
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
23
23
|
import { matchNodes } from './node-matcher.js';
|
|
24
|
+
import { enrichEntries, enrichSlimArray, fingerprintAll, isLegacyRawEntry, anyLegacyRaw, } from './node-fingerprint.js';
|
|
24
25
|
// ============================================================================
|
|
25
26
|
// Markdown -> TipTap
|
|
26
27
|
// ============================================================================
|
|
@@ -122,14 +123,15 @@ export function markdownToTiptap(markdown) {
|
|
|
122
123
|
type: 'doc',
|
|
123
124
|
content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
124
125
|
};
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
// Resolve identity graph from frontmatter. Two on-disk formats live in the
|
|
127
|
+
// wild: ultra-lean slim tuples (current) and legacy verbose objects (v0.14
|
|
128
|
+
// and v0.15). Legacy entries get positionally re-fingerprinted from the
|
|
129
|
+
// freshly-parsed body — the body IS the previous state at load time, and
|
|
130
|
+
// re-fingerprinting produces hashes the matcher can pin against cleanly.
|
|
131
|
+
// adr: adr/node-identity-matcher.md
|
|
132
|
+
const blocksForEnrich = tiptapToBlocks(doc);
|
|
133
|
+
const previousNodes = resolvePreviousNodes(data.nodes, blocksForEnrich);
|
|
134
|
+
const graveyard = resolveGraveyard(data.graveyard);
|
|
133
135
|
if (previousNodes.length > 0) {
|
|
134
136
|
applyMatcher(doc, previousNodes, graveyard);
|
|
135
137
|
}
|
|
@@ -151,13 +153,68 @@ export function markdownToTiptap(markdown) {
|
|
|
151
153
|
previousNodes,
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Resolve `nodes:` frontmatter into rich NodeEntry[] suitable for the matcher.
|
|
158
|
+
*
|
|
159
|
+
* Two on-disk formats:
|
|
160
|
+
* - Ultra-lean: each entry is an array tuple. enrichSlimArray derives all
|
|
161
|
+
* positional/structural fields from the slim array itself — no body parse
|
|
162
|
+
* needed. The slim array IS the previous state (position = array index,
|
|
163
|
+
* parent = most-recent unfilled container, neighbors = slim[i±1]).
|
|
164
|
+
* - Legacy (v0.14/v0.15): each entry is an object with `id` and `fp` keys.
|
|
165
|
+
* We re-fingerprint positionally from the body — the body IS the previous
|
|
166
|
+
* state at load time, and a fresh fingerprint over the same body produces
|
|
167
|
+
* hashes the matcher can pin against. After the next save, disk is in the
|
|
168
|
+
* ultra-lean format and the body-parse cost drops away.
|
|
169
|
+
*
|
|
170
|
+
* `blocks` is only consulted for the legacy path; slim path ignores it. Pass
|
|
171
|
+
* an empty array when you only have slim input to avoid the body parse cost.
|
|
172
|
+
*/
|
|
173
|
+
export function resolvePreviousNodes(raw, blocks) {
|
|
174
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
157
175
|
return [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
if (anyLegacyRaw(raw)) {
|
|
177
|
+
// Positional re-fingerprint: take each legacy entry's id, assign it to a
|
|
178
|
+
// freshly-computed fingerprint at the same position in the body.
|
|
179
|
+
const freshFps = fingerprintAll(blocks);
|
|
180
|
+
const out = [];
|
|
181
|
+
for (let i = 0; i < raw.length; i++) {
|
|
182
|
+
const r = raw[i];
|
|
183
|
+
const id = isLegacyRawEntry(r) ? r.id : (Array.isArray(r) ? r[0] : null);
|
|
184
|
+
if (!id || typeof id !== 'string' || !freshFps[i])
|
|
185
|
+
continue;
|
|
186
|
+
out.push({ id, fingerprint: freshFps[i] });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
// Ultra-lean: walk the slim array directly. No body parse required.
|
|
191
|
+
return enrichSlimArray(raw).map((e) => ({
|
|
192
|
+
id: e.id,
|
|
193
|
+
fingerprint: e.fingerprint,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolve `graveyard:` frontmatter into rich NodeEntry[].
|
|
198
|
+
*
|
|
199
|
+
* Ultra-lean tuples enrich without block context (deleted blocks have no
|
|
200
|
+
* body). Derived fields default to safe values; matcher rules for graveyard
|
|
201
|
+
* restore only consult type + sentences + structureSig + childTypes, all
|
|
202
|
+
* carried in slim. Legacy graveyard entries are dropped — their stored
|
|
203
|
+
* fingerprints don't translate to the new hash semantics (terminator is now
|
|
204
|
+
* folded into the hash), so they'd never match a fresh paste-back anyway.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveGraveyard(raw) {
|
|
207
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
208
|
+
return [];
|
|
209
|
+
if (anyLegacyRaw(raw)) {
|
|
210
|
+
// Mixed input: drop legacy entries, enrich slim ones.
|
|
211
|
+
const slimOnly = raw.filter((r) => Array.isArray(r));
|
|
212
|
+
return enrichEntries(slimOnly, []).map((e) => ({ id: e.id, fingerprint: e.fingerprint }));
|
|
213
|
+
}
|
|
214
|
+
return enrichEntries(raw, []).map((e) => ({
|
|
215
|
+
id: e.id,
|
|
216
|
+
fingerprint: e.fingerprint,
|
|
217
|
+
}));
|
|
161
218
|
}
|
|
162
219
|
/**
|
|
163
220
|
* Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
|