openwriter 0.26.0 → 0.27.1
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-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +5 -9
- package/dist/plugins/authors-voice/dist/index.js +17 -130
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/autoplug-enroll.js +71 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +49 -13
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +215 -78
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +49 -19
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
package/dist/server/mcp.js
CHANGED
|
@@ -10,22 +10,23 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
|
|
13
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
13
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, getIsTemp, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
15
|
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
16
16
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
17
|
-
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename,
|
|
17
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions, stagePendingTitle } from './documents.js';
|
|
18
18
|
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
19
19
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
20
|
-
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
|
|
20
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastPendingMetadataChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
|
|
21
21
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
22
22
|
import { findDocNode } from './workspace-tree.js';
|
|
23
23
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
24
|
-
import { toCompactFormat, compactNodes, parseMarkdownContent
|
|
24
|
+
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
25
25
|
import matter from 'gray-matter';
|
|
26
26
|
import { getUpdateInfo } from './update-check.js';
|
|
27
27
|
import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
|
|
28
|
-
import {
|
|
28
|
+
import { tiptapToMarkdown } from './markdown.js';
|
|
29
|
+
import { loadDocFromDisk } from './pending-overlay.js';
|
|
29
30
|
import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
|
|
30
31
|
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
31
32
|
/** Map a content type string to its frontmatter metadata object. */
|
|
@@ -41,24 +42,6 @@ function resolveTypeMeta(type, url) {
|
|
|
41
42
|
default: return undefined;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
/** Check if a document is in tweet compose mode (has tweetContext metadata). */
|
|
45
|
-
function isTweetDoc(filename) {
|
|
46
|
-
if (!filename || filename === getActiveFilename()) {
|
|
47
|
-
return !!getMetadata()?.tweetContext;
|
|
48
|
-
}
|
|
49
|
-
const targetPath = resolveDocPath(filename);
|
|
50
|
-
const cached = getCachedDocument(targetPath);
|
|
51
|
-
if (cached)
|
|
52
|
-
return !!cached.metadata?.tweetContext;
|
|
53
|
-
try {
|
|
54
|
-
const raw = readFileSync(targetPath, 'utf-8');
|
|
55
|
-
const { data } = matter(raw);
|
|
56
|
-
return !!data?.tweetContext;
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
45
|
/** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
|
|
63
46
|
function resolveDocTarget(docId) {
|
|
64
47
|
const filename = resolveDocId(docId);
|
|
@@ -96,24 +79,27 @@ function resolveDocTarget(docId) {
|
|
|
96
79
|
lastModified: cached.lastModified,
|
|
97
80
|
};
|
|
98
81
|
}
|
|
99
|
-
// Read from disk
|
|
82
|
+
// Read from disk — load the MERGED view (canonical body + sidecar overlay).
|
|
83
|
+
// The bare markdownToTiptap returns canonical-only; without applying the
|
|
84
|
+
// sidecar, a doc that has pending content (typical after populate_document
|
|
85
|
+
// or write_to_pad on a non-active doc) would surface here with 0 words and
|
|
86
|
+
// 0 pending — silently dropping the user's just-written content. The
|
|
87
|
+
// overlay-aware loader closes that asymmetry. adr: adr/pending-overlay-model.md
|
|
100
88
|
if (!existsSync(filePath))
|
|
101
89
|
throw new Error(`Document file not found: ${filename}`);
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const resolvedDocId = meta.docId || docId;
|
|
106
|
-
const text = extractText(parsed.document.content);
|
|
90
|
+
const loaded = loadDocFromDisk(filename);
|
|
91
|
+
const resolvedDocId = loaded.docId || docId;
|
|
92
|
+
const text = extractText(loaded.document.content);
|
|
107
93
|
return {
|
|
108
94
|
filename,
|
|
109
95
|
filePath,
|
|
110
96
|
docId: resolvedDocId,
|
|
111
97
|
isActive: false,
|
|
112
|
-
document:
|
|
113
|
-
title:
|
|
114
|
-
metadata:
|
|
98
|
+
document: loaded.document,
|
|
99
|
+
title: loaded.title,
|
|
100
|
+
metadata: loaded.metadata || {},
|
|
115
101
|
wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
|
|
116
|
-
pendingCount: countPending(
|
|
102
|
+
pendingCount: countPending(loaded.document.content),
|
|
117
103
|
lastModified: statSync(filePath).mtime,
|
|
118
104
|
};
|
|
119
105
|
}
|
|
@@ -302,18 +288,19 @@ export const TOOL_REGISTRY = [
|
|
|
302
288
|
},
|
|
303
289
|
handler: async ({ changes, docId }) => {
|
|
304
290
|
const filename = resolveDocId(docId);
|
|
305
|
-
const tweetMode = isTweetDoc(filename);
|
|
306
291
|
const processed = changes.map((change) => {
|
|
307
292
|
const resolved = { ...change };
|
|
308
293
|
if (typeof resolved.content === 'string') {
|
|
309
|
-
|
|
310
|
-
if (tweetMode)
|
|
311
|
-
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
312
|
-
resolved.content = nodes;
|
|
313
|
-
}
|
|
314
|
-
else if (tweetMode && Array.isArray(resolved.content)) {
|
|
315
|
-
resolved.content = mergeParagraphsToHardBreaks(resolved.content);
|
|
294
|
+
resolved.content = parseMarkdownContent(resolved.content);
|
|
316
295
|
}
|
|
296
|
+
// Tweet docs used to collapse multi-paragraph content into a single
|
|
297
|
+
// paragraph with hardBreaks (mergeParagraphsToHardBreaks). That made
|
|
298
|
+
// every multi-paragraph write land as ONE pending-insert decoration
|
|
299
|
+
// for review, which destroys per-paragraph accept/reject. Each
|
|
300
|
+
// paragraph the agent writes is a distinct review unit; preserve
|
|
301
|
+
// that structure. Thread separation (multiple tweets in a thread)
|
|
302
|
+
// is signaled explicitly by horizontalRule nodes from the agent,
|
|
303
|
+
// not implied by paragraph count.
|
|
317
304
|
return resolved;
|
|
318
305
|
});
|
|
319
306
|
// Auto-clean: if doc has only a single empty paragraph and first change is
|
|
@@ -566,6 +553,14 @@ export const TOOL_REGISTRY = [
|
|
|
566
553
|
broadcastDocumentsChanged();
|
|
567
554
|
broadcastWorkspacesChanged();
|
|
568
555
|
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
|
|
556
|
+
// Right-rail Activity: one entry per agent-created doc. adr: adr/right-rail.md
|
|
557
|
+
broadcastActivityEvent({
|
|
558
|
+
kind: 'doc-created',
|
|
559
|
+
headline: `Created ${result.title || 'Untitled'}`,
|
|
560
|
+
detail: content_type && content_type !== 'document' ? content_type : undefined,
|
|
561
|
+
docId: newDocId,
|
|
562
|
+
filename: result.filename,
|
|
563
|
+
});
|
|
569
564
|
return {
|
|
570
565
|
content: [{
|
|
571
566
|
type: 'text',
|
|
@@ -592,6 +587,14 @@ export const TOOL_REGISTRY = [
|
|
|
592
587
|
spinnerKey = result.filename;
|
|
593
588
|
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
|
|
594
589
|
broadcastDocumentsChanged();
|
|
590
|
+
// Right-rail Activity: one entry per agent-created doc. adr: adr/right-rail.md
|
|
591
|
+
broadcastActivityEvent({
|
|
592
|
+
kind: 'doc-created',
|
|
593
|
+
headline: `Created ${result.title || 'Untitled'}`,
|
|
594
|
+
detail: content_type && content_type !== 'document' ? content_type : undefined,
|
|
595
|
+
docId: result.docId,
|
|
596
|
+
filename: result.filename,
|
|
597
|
+
});
|
|
595
598
|
return {
|
|
596
599
|
content: [{
|
|
597
600
|
type: 'text',
|
|
@@ -618,14 +621,15 @@ export const TOOL_REGISTRY = [
|
|
|
618
621
|
try {
|
|
619
622
|
let doc;
|
|
620
623
|
if (typeof content === 'string') {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
624
|
+
// Don't collapse multi-paragraph content to a single hardBreak-
|
|
625
|
+
// separated paragraph for tweet docs. Each paragraph is a
|
|
626
|
+
// distinct review unit (its own pending-insert decoration); the
|
|
627
|
+
// user accepts/rejects per paragraph. Explicit thread structure
|
|
628
|
+
// (multiple tweets in a thread) comes from horizontalRule nodes
|
|
629
|
+
// in the agent's input, not from paragraph count.
|
|
630
|
+
doc = { type: 'doc', content: parseMarkdownContent(content) };
|
|
625
631
|
}
|
|
626
632
|
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
627
|
-
if (isTweetDoc(filename))
|
|
628
|
-
content.content = mergeParagraphsToHardBreaks(content.content);
|
|
629
633
|
doc = content;
|
|
630
634
|
}
|
|
631
635
|
else {
|
|
@@ -656,15 +660,18 @@ export const TOOL_REGISTRY = [
|
|
|
656
660
|
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
657
661
|
markAllNodesAsPending(doc, 'insert');
|
|
658
662
|
}
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
663
|
+
// Preserve any pre-existing real content from canonical that the
|
|
664
|
+
// incoming populate doesn't already cover, so a re-populate doesn't
|
|
665
|
+
// clobber prior content. updateDocument(doc) overwrites
|
|
666
|
+
// state.canonical wholesale; without this preserve step, anything
|
|
667
|
+
// not in `doc.content` after this point disappears.
|
|
668
|
+
//
|
|
669
|
+
// Empty paragraphs are explicitly NOT preserved. createDocumentFile
|
|
670
|
+
// mints a trailing empty paragraph as a TipTap "doc must have at
|
|
671
|
+
// least one node" stub, and TipTap itself maintains a trailing
|
|
672
|
+
// empty paragraph for cursor-landing. Preserving those during
|
|
673
|
+
// populate leaves phantom empty paragraphs accumulating at the
|
|
674
|
+
// end of the doc. adr: adr/pending-overlay-model.md
|
|
668
675
|
const existingCanonical = getCanonical();
|
|
669
676
|
if (existingCanonical?.content?.length) {
|
|
670
677
|
const incomingIds = new Set(doc.content
|
|
@@ -672,7 +679,10 @@ export const TOOL_REGISTRY = [
|
|
|
672
679
|
.filter((id) => typeof id === 'string'));
|
|
673
680
|
const preserved = existingCanonical.content.filter((n) => {
|
|
674
681
|
const id = n?.attrs?.id;
|
|
675
|
-
|
|
682
|
+
if (!id || incomingIds.has(id))
|
|
683
|
+
return false;
|
|
684
|
+
const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
|
|
685
|
+
return !isEmptyParagraph;
|
|
676
686
|
});
|
|
677
687
|
if (preserved.length > 0) {
|
|
678
688
|
doc.content = [...doc.content, ...preserved];
|
|
@@ -793,6 +803,13 @@ export const TOOL_REGISTRY = [
|
|
|
793
803
|
},
|
|
794
804
|
handler: async ({ docId }) => {
|
|
795
805
|
const filename = resolveDocId(docId);
|
|
806
|
+
// Capture title before the file is moved to trash — resolveDocTarget reads
|
|
807
|
+
// from disk and won't work post-delete.
|
|
808
|
+
let deletedTitle = filename.replace(/\.md$/, '');
|
|
809
|
+
try {
|
|
810
|
+
deletedTitle = resolveDocTarget(docId).title || deletedTitle;
|
|
811
|
+
}
|
|
812
|
+
catch { /* fallback to filename */ }
|
|
796
813
|
const result = await deleteDocument(filename);
|
|
797
814
|
removeDocFromAllWorkspaces(filename);
|
|
798
815
|
if (result.switched && result.newDoc) {
|
|
@@ -800,6 +817,14 @@ export const TOOL_REGISTRY = [
|
|
|
800
817
|
}
|
|
801
818
|
broadcastDocumentsChanged();
|
|
802
819
|
broadcastWorkspacesChanged();
|
|
820
|
+
// Right-rail Activity: one entry per agent-deleted doc. docId is recorded
|
|
821
|
+
// but the client treats doc-deleted entries as non-clickable. adr: adr/right-rail.md
|
|
822
|
+
broadcastActivityEvent({
|
|
823
|
+
kind: 'doc-deleted',
|
|
824
|
+
headline: `Deleted ${deletedTitle}`,
|
|
825
|
+
docId,
|
|
826
|
+
filename,
|
|
827
|
+
});
|
|
803
828
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
804
829
|
if (result.switched && result.newDoc) {
|
|
805
830
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -875,6 +900,20 @@ export const TOOL_REGISTRY = [
|
|
|
875
900
|
const cleaned = {};
|
|
876
901
|
for (const key of setKeys)
|
|
877
902
|
cleaned[key] = updates[key];
|
|
903
|
+
// Title is gated through pending-overlay unless this is a temp file
|
|
904
|
+
// being titled for the first time (creation path — promoteTempFile
|
|
905
|
+
// must run synchronously so the file gets a real filename on disk).
|
|
906
|
+
// adr: adr/pending-overlay-model.md
|
|
907
|
+
let stagedTitle = null;
|
|
908
|
+
const isCreationTitleSet = cleaned.title && target.isActive && getIsTemp();
|
|
909
|
+
const wantsTitlePending = cleaned.title && !isCreationTitleSet;
|
|
910
|
+
if (wantsTitlePending) {
|
|
911
|
+
const staged = stagePendingTitle(docId, cleaned.title);
|
|
912
|
+
if (staged.from !== staged.to) {
|
|
913
|
+
stagedTitle = { from: staged.from, to: staged.to };
|
|
914
|
+
}
|
|
915
|
+
delete cleaned.title; // remove from the hot-write path
|
|
916
|
+
}
|
|
878
917
|
if (target.isActive) {
|
|
879
918
|
// Active doc: use in-memory path
|
|
880
919
|
if (Object.keys(cleaned).length > 0)
|
|
@@ -885,6 +924,7 @@ export const TOOL_REGISTRY = [
|
|
|
885
924
|
save();
|
|
886
925
|
broadcastMetadataChanged(getMetadata());
|
|
887
926
|
if (cleaned.title) {
|
|
927
|
+
// Reached only on temp-file creation titling — hot promote.
|
|
888
928
|
const promoted = promoteTempFile(cleaned.title);
|
|
889
929
|
broadcastTitleChanged(cleaned.title);
|
|
890
930
|
broadcastDocumentsChanged();
|
|
@@ -903,14 +943,16 @@ export const TOOL_REGISTRY = [
|
|
|
903
943
|
}
|
|
904
944
|
for (const key of removed)
|
|
905
945
|
delete meta[key];
|
|
906
|
-
|
|
946
|
+
// Title may have been stripped out for pending-staging; preserve the
|
|
947
|
+
// canonical (on-disk) title in the rewrite.
|
|
948
|
+
const newTitle = meta.title || target.title;
|
|
907
949
|
const markdown = tiptapToMarkdown(target.document, newTitle, meta);
|
|
908
950
|
atomicWriteFileSync(target.filePath, markdown);
|
|
909
951
|
invalidateDocCache(target.filePath);
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
952
|
+
}
|
|
953
|
+
if (stagedTitle) {
|
|
954
|
+
broadcastPendingMetadataChanged(docId, { title: stagedTitle });
|
|
955
|
+
broadcastPendingDocsChanged();
|
|
914
956
|
}
|
|
915
957
|
const keys = Object.keys(cleaned);
|
|
916
958
|
const parts = [];
|
|
@@ -986,14 +1028,6 @@ export const TOOL_REGISTRY = [
|
|
|
986
1028
|
atomicWriteFileSync(target.filePath, markdown);
|
|
987
1029
|
invalidateDocCache(target.filePath);
|
|
988
1030
|
}
|
|
989
|
-
// Right-rail Activity: one entry per enriched doc. adr: adr/right-rail.md
|
|
990
|
-
broadcastActivityEvent({
|
|
991
|
-
kind: 'enrichment',
|
|
992
|
-
headline: `Enrichment stamped ${target.title || target.filename}`,
|
|
993
|
-
detail: item.logline,
|
|
994
|
-
docId: item.docId,
|
|
995
|
-
filename: target.filename,
|
|
996
|
-
});
|
|
997
1031
|
results.push({ docId: item.docId, ok: true });
|
|
998
1032
|
}
|
|
999
1033
|
catch (err) {
|
|
@@ -1007,6 +1041,11 @@ export const TOOL_REGISTRY = [
|
|
|
1007
1041
|
const summary = failCount === 0
|
|
1008
1042
|
? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
|
|
1009
1043
|
: `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
1044
|
+
// Right-rail Activity: single summary line for the whole batch. adr: adr/right-rail.md
|
|
1045
|
+
broadcastActivityEvent({
|
|
1046
|
+
kind: 'enrichment',
|
|
1047
|
+
headline: summary,
|
|
1048
|
+
});
|
|
1010
1049
|
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
1011
1050
|
},
|
|
1012
1051
|
},
|
|
@@ -1152,9 +1191,24 @@ export const TOOL_REGISTRY = [
|
|
|
1152
1191
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
1153
1192
|
},
|
|
1154
1193
|
handler: async ({ filename }) => {
|
|
1194
|
+
// Capture workspace title before delete so the activity entry can name it.
|
|
1195
|
+
let wsTitle = filename.replace(/\.json$/, '');
|
|
1196
|
+
try {
|
|
1197
|
+
wsTitle = getWorkspace(filename).title || wsTitle;
|
|
1198
|
+
}
|
|
1199
|
+
catch { /* fallback to filename */ }
|
|
1155
1200
|
const result = await deleteWorkspace(filename);
|
|
1156
1201
|
broadcastWorkspacesChanged();
|
|
1157
1202
|
broadcastDocumentsChanged();
|
|
1203
|
+
// Right-rail Activity: single summary entry for the cascade. Per-doc
|
|
1204
|
+
// entries would flood the log for large workspaces; one entry naming
|
|
1205
|
+
// the workspace + doc count is the better default. adr: adr/right-rail.md
|
|
1206
|
+
const n = result.deletedFiles.length;
|
|
1207
|
+
broadcastActivityEvent({
|
|
1208
|
+
kind: 'doc-deleted',
|
|
1209
|
+
headline: `Deleted workspace ${wsTitle}`,
|
|
1210
|
+
detail: `${n} doc${n === 1 ? '' : 's'}`,
|
|
1211
|
+
});
|
|
1158
1212
|
let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
|
|
1159
1213
|
if (result.skippedExternal.length > 0) {
|
|
1160
1214
|
text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
|
|
@@ -1405,13 +1459,17 @@ export const TOOL_REGISTRY = [
|
|
|
1405
1459
|
if (type === 'document') {
|
|
1406
1460
|
if (!docId)
|
|
1407
1461
|
return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1462
|
+
// Agent-initiated rename: stage as pending. The user accepts/rejects
|
|
1463
|
+
// via the title-bar inline diff in the browser. The .md file on disk
|
|
1464
|
+
// is NOT modified until accept. adr: adr/pending-overlay-model.md
|
|
1465
|
+
const staged = stagePendingTitle(docId, newName);
|
|
1466
|
+
broadcastPendingMetadataChanged(docId, { title: { from: staged.from, to: staged.to } });
|
|
1467
|
+
// Sidebar / docs list shows a pending indicator for this doc.
|
|
1468
|
+
broadcastPendingDocsChanged();
|
|
1469
|
+
if (staged.from === newName) {
|
|
1470
|
+
return { content: [{ type: 'text', text: `Document [${docId}] already titled "${newName}" — no change staged.` }] };
|
|
1413
1471
|
}
|
|
1414
|
-
return { content: [{ type: 'text', text: `
|
|
1472
|
+
return { content: [{ type: 'text', text: `Staged title rename for [${docId}]: "${staged.from}" → "${newName}". User will accept or reject in the editor.` }] };
|
|
1415
1473
|
}
|
|
1416
1474
|
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
1417
1475
|
},
|
|
@@ -2069,6 +2127,30 @@ export const TOOL_REGISTRY = [
|
|
|
2069
2127
|
/** Live MCP server instance — used to register plugin tools dynamically. */
|
|
2070
2128
|
let mcpServerInstance = null;
|
|
2071
2129
|
/** Convert a JSON Schema properties object to a Zod shape for MCP tool registration. */
|
|
2130
|
+
/**
|
|
2131
|
+
* Some MCP clients (notably Claude Code's tool-call serialization for
|
|
2132
|
+
* nested object/array parameters) send JSON-encoded strings instead of
|
|
2133
|
+
* the actual structured value. Wrap object/array schemas with a
|
|
2134
|
+
* preprocess step that opportunistically parses string input as JSON
|
|
2135
|
+
* before validation.
|
|
2136
|
+
*/
|
|
2137
|
+
function jsonCoerce(inner) {
|
|
2138
|
+
return z.preprocess((val) => {
|
|
2139
|
+
if (typeof val !== 'string')
|
|
2140
|
+
return val;
|
|
2141
|
+
const trimmed = val.trim();
|
|
2142
|
+
if (!trimmed)
|
|
2143
|
+
return val;
|
|
2144
|
+
if (!(trimmed.startsWith('{') || trimmed.startsWith('[')))
|
|
2145
|
+
return val;
|
|
2146
|
+
try {
|
|
2147
|
+
return JSON.parse(trimmed);
|
|
2148
|
+
}
|
|
2149
|
+
catch {
|
|
2150
|
+
return val;
|
|
2151
|
+
}
|
|
2152
|
+
}, inner);
|
|
2153
|
+
}
|
|
2072
2154
|
function jsonSchemaToZodShape(inputSchema) {
|
|
2073
2155
|
const properties = (inputSchema.properties || {});
|
|
2074
2156
|
const required = new Set((inputSchema.required || []));
|
|
@@ -2082,6 +2164,30 @@ function jsonSchemaToZodShape(inputSchema) {
|
|
|
2082
2164
|
case 'boolean':
|
|
2083
2165
|
field = z.boolean();
|
|
2084
2166
|
break;
|
|
2167
|
+
case 'object':
|
|
2168
|
+
// Pass-through any record-like object. Inner key validation is
|
|
2169
|
+
// the handler's job — keep this loose so plugins can ship typed
|
|
2170
|
+
// shapes through MCP without per-key zod schemas.
|
|
2171
|
+
// Wrap in jsonCoerce so JSON-string-encoded objects are auto-parsed.
|
|
2172
|
+
field = jsonCoerce(z.record(z.string(), z.any()));
|
|
2173
|
+
break;
|
|
2174
|
+
case 'array':
|
|
2175
|
+
// Use items.type for inner validation when provided
|
|
2176
|
+
switch (prop.items?.type) {
|
|
2177
|
+
case 'string':
|
|
2178
|
+
field = jsonCoerce(z.array(z.string()));
|
|
2179
|
+
break;
|
|
2180
|
+
case 'number':
|
|
2181
|
+
field = jsonCoerce(z.array(z.number()));
|
|
2182
|
+
break;
|
|
2183
|
+
case 'boolean':
|
|
2184
|
+
field = jsonCoerce(z.array(z.boolean()));
|
|
2185
|
+
break;
|
|
2186
|
+
default:
|
|
2187
|
+
field = jsonCoerce(z.array(z.any()));
|
|
2188
|
+
break;
|
|
2189
|
+
}
|
|
2190
|
+
break;
|
|
2085
2191
|
default:
|
|
2086
2192
|
field = z.string();
|
|
2087
2193
|
break;
|
|
@@ -2097,6 +2203,11 @@ function jsonSchemaToZodShape(inputSchema) {
|
|
|
2097
2203
|
/** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
|
|
2098
2204
|
export function registerPluginTools(tools) {
|
|
2099
2205
|
for (const tool of tools) {
|
|
2206
|
+
// Skip if already in TOOL_REGISTRY (e.g. called twice due to a bug)
|
|
2207
|
+
if (TOOL_REGISTRY.some((t) => t.name === tool.name)) {
|
|
2208
|
+
console.warn(`[PluginManager] registerPluginTools: skipping duplicate tool "${tool.name}"`);
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2100
2211
|
const zodShape = jsonSchemaToZodShape(tool.inputSchema);
|
|
2101
2212
|
const toolDef = {
|
|
2102
2213
|
name: tool.name,
|
|
@@ -2108,9 +2219,17 @@ export function registerPluginTools(tools) {
|
|
|
2108
2219
|
},
|
|
2109
2220
|
};
|
|
2110
2221
|
TOOL_REGISTRY.push(toolDef);
|
|
2111
|
-
// Register on live MCP server so existing sessions see it immediately
|
|
2222
|
+
// Register on live MCP server so existing sessions see it immediately.
|
|
2223
|
+
// Guard against "already registered" throws — can happen when two plugins
|
|
2224
|
+
// ship a tool with the same name (e.g. an old stale dist vs a new dist).
|
|
2112
2225
|
if (mcpServerInstance) {
|
|
2113
|
-
|
|
2226
|
+
const registered = mcpServerInstance._registeredTools;
|
|
2227
|
+
if (registered && registered[tool.name]) {
|
|
2228
|
+
console.warn(`[PluginManager] registerPluginTools: skipping MCP-duplicate tool "${tool.name}"`);
|
|
2229
|
+
}
|
|
2230
|
+
else {
|
|
2231
|
+
mcpServerInstance.tool(tool.name, tool.description, zodShape, toolDef.handler);
|
|
2232
|
+
}
|
|
2114
2233
|
}
|
|
2115
2234
|
}
|
|
2116
2235
|
// Notify connected clients that the tool list changed
|
|
@@ -2126,7 +2245,25 @@ export function removePluginTools(names) {
|
|
|
2126
2245
|
TOOL_REGISTRY.splice(i, 1);
|
|
2127
2246
|
}
|
|
2128
2247
|
}
|
|
2248
|
+
// Also remove from the live MCP server's internal registry so re-enable
|
|
2249
|
+
// doesn't hit "Tool already registered". The SDK doesn't expose a public
|
|
2250
|
+
// unregister API — use the registered tool handle's .remove() if available,
|
|
2251
|
+
// otherwise fall back to deleting from _registeredTools directly.
|
|
2129
2252
|
if (mcpServerInstance) {
|
|
2253
|
+
const registered = mcpServerInstance._registeredTools;
|
|
2254
|
+
if (registered) {
|
|
2255
|
+
for (const name of nameSet) {
|
|
2256
|
+
const handle = registered[name];
|
|
2257
|
+
if (handle) {
|
|
2258
|
+
if (typeof handle.remove === 'function') {
|
|
2259
|
+
handle.remove();
|
|
2260
|
+
}
|
|
2261
|
+
else {
|
|
2262
|
+
delete registered[name];
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2130
2267
|
mcpServerInstance.server.sendToolListChanged().catch(() => { });
|
|
2131
2268
|
}
|
|
2132
2269
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending metadata — sibling of pending-overlay.ts for frontmatter-level
|
|
3
|
+
* staging. Where the overlay holds proposed BLOCK changes (insert / rewrite
|
|
4
|
+
* / delete on TipTap nodes), this module holds proposed METADATA changes —
|
|
5
|
+
* for now just the document title.
|
|
6
|
+
*
|
|
7
|
+
* Architectural model:
|
|
8
|
+
* - Pending metadata lives in the SAME sidecar file as the block overlay
|
|
9
|
+
* (_pending/{docId}.json), under a top-level `metadata:` key. The
|
|
10
|
+
* pending-overlay module owns the `entries:` key; this module owns the
|
|
11
|
+
* `metadata:` key. Each preserves the other's slot when it writes.
|
|
12
|
+
* - The canonical disk .md file's frontmatter is NEVER modified by a
|
|
13
|
+
* pending stage — the title only changes on disk after the user accepts.
|
|
14
|
+
* Reject discards the proposal; nothing on disk moves.
|
|
15
|
+
* - The active document's pending metadata is mirrored into state.ts as
|
|
16
|
+
* `state.pendingMetadata` so WS broadcasts and getters expose it without
|
|
17
|
+
* a disk read on every poll.
|
|
18
|
+
*
|
|
19
|
+
* Why a separate module from pending-overlay.ts:
|
|
20
|
+
* - The overlay model is keyed by nodeId and assumes a TipTap tree. Title
|
|
21
|
+
* is not in the tree — it's a YAML frontmatter field. Forcing it into a
|
|
22
|
+
* fake "node" would corrupt the overlay invariants (nodeId stability,
|
|
23
|
+
* splitMergedDoc tree-walk, applyOverlayPure idempotency).
|
|
24
|
+
* - Future metadata-staging (tags, status, custom frontmatter fields) lands
|
|
25
|
+
* here without further churn to the overlay code path.
|
|
26
|
+
*
|
|
27
|
+
* Scope (phase 1): document title only. tag_doc / untag_doc / set_metadata
|
|
28
|
+
* for non-title fields / mark_enriched still write hot. Workspace and
|
|
29
|
+
* container renames also still write hot — they live in workspace manifests,
|
|
30
|
+
* not per-doc sidecars, and need a separate decision.
|
|
31
|
+
*
|
|
32
|
+
* adr: adr/pending-overlay-model.md
|
|
33
|
+
*/
|
|
34
|
+
import { readSidecarRaw, writeSidecarRaw } from './pending-overlay.js';
|
|
35
|
+
import { logger } from './logger.js';
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// SIDECAR I/O
|
|
38
|
+
// ============================================================================
|
|
39
|
+
/** Read the pending-metadata slot from the per-doc sidecar. Returns null if
|
|
40
|
+
* the sidecar is missing or has no metadata slot. */
|
|
41
|
+
export function loadPendingMetadata(docId) {
|
|
42
|
+
if (!docId)
|
|
43
|
+
return null;
|
|
44
|
+
const raw = readSidecarRaw(docId);
|
|
45
|
+
if (!raw?.metadata || Object.keys(raw.metadata).length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return raw.metadata;
|
|
48
|
+
}
|
|
49
|
+
/** Persist the pending-metadata slot. Preserves the sidecar's existing
|
|
50
|
+
* `entries:` slot. Passing null (or an empty object) clears the metadata
|
|
51
|
+
* and may delete the sidecar entirely if entries are also empty. */
|
|
52
|
+
export function savePendingMetadata(docId, meta) {
|
|
53
|
+
if (!docId)
|
|
54
|
+
return;
|
|
55
|
+
const raw = readSidecarRaw(docId);
|
|
56
|
+
const entries = Array.isArray(raw?.entries) ? raw.entries : [];
|
|
57
|
+
const cleaned = meta && Object.keys(meta).length > 0 ? meta : null;
|
|
58
|
+
writeSidecarRaw(docId, { entries, metadata: cleaned || undefined });
|
|
59
|
+
if (cleaned?.title) {
|
|
60
|
+
logger.info('overlay', 'meta-stage', `docId=${docId} field=title from="${cleaned.title.from}" to="${cleaned.title.to}"`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
logger.info('overlay', 'meta-clear', `docId=${docId}`);
|
|
64
|
+
}
|
|
65
|
+
}
|