openwriter 0.25.0 → 0.27.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-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/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/documents.js +119 -2
- package/dist/server/index.js +31 -11
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +251 -86
- package/dist/server/peek-outline.js +87 -17
- 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 +60 -22
- 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
|
}
|
|
@@ -229,13 +215,26 @@ let firstTruncationShown = false;
|
|
|
229
215
|
export const TOOL_REGISTRY = [
|
|
230
216
|
{
|
|
231
217
|
name: 'read_pad',
|
|
232
|
-
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node.
|
|
218
|
+
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Default: first ~${READ_PAD_MAX_WORDS} words. Three knobs for longer docs:\n• \`slice: { from, to }\` — read a percentile range (floats in [0,1]). \`{from:0.5, to:1}\` = back half, \`{from:0.25, to:0.75}\` = middle 50%, \`{from:0.0, to:0.1}\` then \`{from:0.1, to:0.2}\` … = sequential 10% chunks. Snaps to top-level node boundaries; subject to the word cap unless force is set.\n• \`force: true\` — bypass the cap, return the full requested region (whole doc or whole slice). Use for full-doc audits/rewrites where you've accepted the cost.\n• When the cap kicks in, the response includes \`lastNodeId\` + continuation hints to \`peek_doc\` / \`outline_doc\` / \`search_docs\` / another \`read_pad\` slice.`,
|
|
233
219
|
schema: {
|
|
234
220
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
// Schemas use z.preprocess to coerce string inputs — some MCP clients
|
|
222
|
+
// serialize complex / boolean params as JSON strings rather than native
|
|
223
|
+
// types. Accepting both forms means agents work regardless of client.
|
|
224
|
+
slice: z.preprocess((v) => (typeof v === 'string' && v.trim().startsWith('{')) ? (() => { try {
|
|
225
|
+
return JSON.parse(v);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return v;
|
|
229
|
+
} })() : v, z.object({
|
|
230
|
+
from: z.number().min(0).max(1).describe('Start of the slice as a fraction of total word count (0 = beginning).'),
|
|
231
|
+
to: z.number().min(0).max(1).describe('End of the slice as a fraction of total word count (1 = end). Must be > from.'),
|
|
232
|
+
}).optional()).describe('Percentile range to read instead of the doc opening. Snaps to top-level node boundaries. Examples: { from: 0.5, to: 1 } = back half; { from: 0.25, to: 0.75 } = middle 50%.'),
|
|
233
|
+
force: z.preprocess((v) => (typeof v === 'string') ? v === 'true' : v, z.boolean().optional()).describe(`Bypass the ~${READ_PAD_MAX_WORDS}-word cap. Returns the full requested region in one call. Use for full-doc audits and rewrites where the cost is acknowledged.`),
|
|
234
|
+
},
|
|
235
|
+
handler: async ({ docId, slice, force }) => {
|
|
237
236
|
const target = resolveDocTarget(docId);
|
|
238
|
-
const trunc = truncateRead(target.document, READ_PAD_MAX_WORDS);
|
|
237
|
+
const trunc = truncateRead(target.document, { maxWords: READ_PAD_MAX_WORDS, slice, force });
|
|
239
238
|
const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
|
|
240
239
|
const localCount = getCommentCount(target.filename);
|
|
241
240
|
const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
|
|
@@ -246,14 +245,29 @@ export const TOOL_REGISTRY = [
|
|
|
246
245
|
hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
247
246
|
if (hint)
|
|
248
247
|
hint += '\n[call get_comments to review]';
|
|
249
|
-
|
|
248
|
+
// Label forced / sliced reads so the response is self-describing.
|
|
249
|
+
if (trunc.forced) {
|
|
250
|
+
const region = trunc.slice
|
|
251
|
+
? `forced slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
|
|
252
|
+
: 'forced full read';
|
|
253
|
+
hint += `\n[${region.toUpperCase()} — ${trunc.returnedWords.toLocaleString()} words returned of ${trunc.totalWords.toLocaleString()} total. Cap bypassed.]`;
|
|
254
|
+
}
|
|
255
|
+
else if (trunc.slice && !trunc.truncated) {
|
|
256
|
+
hint += `\n[SLICE ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}% — ${trunc.returnedWords.toLocaleString()} of ${trunc.totalWords.toLocaleString()} words. Adjacent slices: read_pad({ docId: "${target.docId}", slice: { from: ${trunc.slice.to.toFixed(2)}, to: ${Math.min(1, trunc.slice.to + (trunc.slice.to - trunc.slice.from)).toFixed(2)} } }) for the next region.]`;
|
|
257
|
+
}
|
|
258
|
+
else if (trunc.truncated) {
|
|
250
259
|
if (!firstTruncationShown) {
|
|
251
|
-
hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. This notice shows once per session
|
|
260
|
+
hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. Override per-call with force:true, or read a specific region with slice:{from,to}. This notice shows once per session.]`;
|
|
252
261
|
firstTruncationShown = true;
|
|
253
262
|
}
|
|
254
263
|
const anchor = trunc.lastNodeId ?? '<no-id>';
|
|
255
|
-
|
|
256
|
-
|
|
264
|
+
const sliceLabel = trunc.slice
|
|
265
|
+
? ` of slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
|
|
266
|
+
: '';
|
|
267
|
+
hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned${sliceLabel}, ${trunc.remaining.toLocaleString()} remain. Continue with:`
|
|
268
|
+
+ `\n read_pad({ docId: "${target.docId}", slice: { from: 0.5, to: 1 } }) — read the back half (or any percentile range)`
|
|
269
|
+
+ `\n read_pad({ docId: "${target.docId}", force: true }) — entire body, cap bypassed`
|
|
270
|
+
+ `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation by node`
|
|
257
271
|
+ `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
|
|
258
272
|
+ `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
|
|
259
273
|
}
|
|
@@ -274,18 +288,19 @@ export const TOOL_REGISTRY = [
|
|
|
274
288
|
},
|
|
275
289
|
handler: async ({ changes, docId }) => {
|
|
276
290
|
const filename = resolveDocId(docId);
|
|
277
|
-
const tweetMode = isTweetDoc(filename);
|
|
278
291
|
const processed = changes.map((change) => {
|
|
279
292
|
const resolved = { ...change };
|
|
280
293
|
if (typeof resolved.content === 'string') {
|
|
281
|
-
|
|
282
|
-
if (tweetMode)
|
|
283
|
-
nodes = mergeParagraphsToHardBreaks(nodes);
|
|
284
|
-
resolved.content = nodes;
|
|
285
|
-
}
|
|
286
|
-
else if (tweetMode && Array.isArray(resolved.content)) {
|
|
287
|
-
resolved.content = mergeParagraphsToHardBreaks(resolved.content);
|
|
294
|
+
resolved.content = parseMarkdownContent(resolved.content);
|
|
288
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.
|
|
289
304
|
return resolved;
|
|
290
305
|
});
|
|
291
306
|
// Auto-clean: if doc has only a single empty paragraph and first change is
|
|
@@ -538,6 +553,14 @@ export const TOOL_REGISTRY = [
|
|
|
538
553
|
broadcastDocumentsChanged();
|
|
539
554
|
broadcastWorkspacesChanged();
|
|
540
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
|
+
});
|
|
541
564
|
return {
|
|
542
565
|
content: [{
|
|
543
566
|
type: 'text',
|
|
@@ -564,6 +587,14 @@ export const TOOL_REGISTRY = [
|
|
|
564
587
|
spinnerKey = result.filename;
|
|
565
588
|
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
|
|
566
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
|
+
});
|
|
567
598
|
return {
|
|
568
599
|
content: [{
|
|
569
600
|
type: 'text',
|
|
@@ -590,14 +621,15 @@ export const TOOL_REGISTRY = [
|
|
|
590
621
|
try {
|
|
591
622
|
let doc;
|
|
592
623
|
if (typeof content === 'string') {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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) };
|
|
597
631
|
}
|
|
598
632
|
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
599
|
-
if (isTweetDoc(filename))
|
|
600
|
-
content.content = mergeParagraphsToHardBreaks(content.content);
|
|
601
633
|
doc = content;
|
|
602
634
|
}
|
|
603
635
|
else {
|
|
@@ -628,15 +660,18 @@ export const TOOL_REGISTRY = [
|
|
|
628
660
|
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
629
661
|
markAllNodesAsPending(doc, 'insert');
|
|
630
662
|
}
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
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
|
|
640
675
|
const existingCanonical = getCanonical();
|
|
641
676
|
if (existingCanonical?.content?.length) {
|
|
642
677
|
const incomingIds = new Set(doc.content
|
|
@@ -644,7 +679,10 @@ export const TOOL_REGISTRY = [
|
|
|
644
679
|
.filter((id) => typeof id === 'string'));
|
|
645
680
|
const preserved = existingCanonical.content.filter((n) => {
|
|
646
681
|
const id = n?.attrs?.id;
|
|
647
|
-
|
|
682
|
+
if (!id || incomingIds.has(id))
|
|
683
|
+
return false;
|
|
684
|
+
const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
|
|
685
|
+
return !isEmptyParagraph;
|
|
648
686
|
});
|
|
649
687
|
if (preserved.length > 0) {
|
|
650
688
|
doc.content = [...doc.content, ...preserved];
|
|
@@ -765,6 +803,13 @@ export const TOOL_REGISTRY = [
|
|
|
765
803
|
},
|
|
766
804
|
handler: async ({ docId }) => {
|
|
767
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 */ }
|
|
768
813
|
const result = await deleteDocument(filename);
|
|
769
814
|
removeDocFromAllWorkspaces(filename);
|
|
770
815
|
if (result.switched && result.newDoc) {
|
|
@@ -772,6 +817,14 @@ export const TOOL_REGISTRY = [
|
|
|
772
817
|
}
|
|
773
818
|
broadcastDocumentsChanged();
|
|
774
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
|
+
});
|
|
775
828
|
let text = `Deleted "${filename}" (moved to trash)`;
|
|
776
829
|
if (result.switched && result.newDoc) {
|
|
777
830
|
text += `. Switched to "${result.newDoc.title}"`;
|
|
@@ -847,6 +900,20 @@ export const TOOL_REGISTRY = [
|
|
|
847
900
|
const cleaned = {};
|
|
848
901
|
for (const key of setKeys)
|
|
849
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
|
+
}
|
|
850
917
|
if (target.isActive) {
|
|
851
918
|
// Active doc: use in-memory path
|
|
852
919
|
if (Object.keys(cleaned).length > 0)
|
|
@@ -857,6 +924,7 @@ export const TOOL_REGISTRY = [
|
|
|
857
924
|
save();
|
|
858
925
|
broadcastMetadataChanged(getMetadata());
|
|
859
926
|
if (cleaned.title) {
|
|
927
|
+
// Reached only on temp-file creation titling — hot promote.
|
|
860
928
|
const promoted = promoteTempFile(cleaned.title);
|
|
861
929
|
broadcastTitleChanged(cleaned.title);
|
|
862
930
|
broadcastDocumentsChanged();
|
|
@@ -875,14 +943,16 @@ export const TOOL_REGISTRY = [
|
|
|
875
943
|
}
|
|
876
944
|
for (const key of removed)
|
|
877
945
|
delete meta[key];
|
|
878
|
-
|
|
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;
|
|
879
949
|
const markdown = tiptapToMarkdown(target.document, newTitle, meta);
|
|
880
950
|
atomicWriteFileSync(target.filePath, markdown);
|
|
881
951
|
invalidateDocCache(target.filePath);
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
952
|
+
}
|
|
953
|
+
if (stagedTitle) {
|
|
954
|
+
broadcastPendingMetadataChanged(docId, { title: stagedTitle });
|
|
955
|
+
broadcastPendingDocsChanged();
|
|
886
956
|
}
|
|
887
957
|
const keys = Object.keys(cleaned);
|
|
888
958
|
const parts = [];
|
|
@@ -958,14 +1028,6 @@ export const TOOL_REGISTRY = [
|
|
|
958
1028
|
atomicWriteFileSync(target.filePath, markdown);
|
|
959
1029
|
invalidateDocCache(target.filePath);
|
|
960
1030
|
}
|
|
961
|
-
// Right-rail Activity: one entry per enriched doc. adr: adr/right-rail.md
|
|
962
|
-
broadcastActivityEvent({
|
|
963
|
-
kind: 'enrichment',
|
|
964
|
-
headline: `Enrichment stamped ${target.title || target.filename}`,
|
|
965
|
-
detail: item.logline,
|
|
966
|
-
docId: item.docId,
|
|
967
|
-
filename: target.filename,
|
|
968
|
-
});
|
|
969
1031
|
results.push({ docId: item.docId, ok: true });
|
|
970
1032
|
}
|
|
971
1033
|
catch (err) {
|
|
@@ -979,6 +1041,11 @@ export const TOOL_REGISTRY = [
|
|
|
979
1041
|
const summary = failCount === 0
|
|
980
1042
|
? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
|
|
981
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
|
+
});
|
|
982
1049
|
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
983
1050
|
},
|
|
984
1051
|
},
|
|
@@ -1124,9 +1191,24 @@ export const TOOL_REGISTRY = [
|
|
|
1124
1191
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
1125
1192
|
},
|
|
1126
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 */ }
|
|
1127
1200
|
const result = await deleteWorkspace(filename);
|
|
1128
1201
|
broadcastWorkspacesChanged();
|
|
1129
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
|
+
});
|
|
1130
1212
|
let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
|
|
1131
1213
|
if (result.skippedExternal.length > 0) {
|
|
1132
1214
|
text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
|
|
@@ -1377,13 +1459,17 @@ export const TOOL_REGISTRY = [
|
|
|
1377
1459
|
if (type === 'document') {
|
|
1378
1460
|
if (!docId)
|
|
1379
1461
|
return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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.` }] };
|
|
1385
1471
|
}
|
|
1386
|
-
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.` }] };
|
|
1387
1473
|
}
|
|
1388
1474
|
return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
|
|
1389
1475
|
},
|
|
@@ -2041,6 +2127,30 @@ export const TOOL_REGISTRY = [
|
|
|
2041
2127
|
/** Live MCP server instance — used to register plugin tools dynamically. */
|
|
2042
2128
|
let mcpServerInstance = null;
|
|
2043
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
|
+
}
|
|
2044
2154
|
function jsonSchemaToZodShape(inputSchema) {
|
|
2045
2155
|
const properties = (inputSchema.properties || {});
|
|
2046
2156
|
const required = new Set((inputSchema.required || []));
|
|
@@ -2054,6 +2164,30 @@ function jsonSchemaToZodShape(inputSchema) {
|
|
|
2054
2164
|
case 'boolean':
|
|
2055
2165
|
field = z.boolean();
|
|
2056
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;
|
|
2057
2191
|
default:
|
|
2058
2192
|
field = z.string();
|
|
2059
2193
|
break;
|
|
@@ -2069,6 +2203,11 @@ function jsonSchemaToZodShape(inputSchema) {
|
|
|
2069
2203
|
/** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
|
|
2070
2204
|
export function registerPluginTools(tools) {
|
|
2071
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
|
+
}
|
|
2072
2211
|
const zodShape = jsonSchemaToZodShape(tool.inputSchema);
|
|
2073
2212
|
const toolDef = {
|
|
2074
2213
|
name: tool.name,
|
|
@@ -2080,9 +2219,17 @@ export function registerPluginTools(tools) {
|
|
|
2080
2219
|
},
|
|
2081
2220
|
};
|
|
2082
2221
|
TOOL_REGISTRY.push(toolDef);
|
|
2083
|
-
// 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).
|
|
2084
2225
|
if (mcpServerInstance) {
|
|
2085
|
-
|
|
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
|
+
}
|
|
2086
2233
|
}
|
|
2087
2234
|
}
|
|
2088
2235
|
// Notify connected clients that the tool list changed
|
|
@@ -2098,7 +2245,25 @@ export function removePluginTools(names) {
|
|
|
2098
2245
|
TOOL_REGISTRY.splice(i, 1);
|
|
2099
2246
|
}
|
|
2100
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.
|
|
2101
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
|
+
}
|
|
2102
2267
|
mcpServerInstance.server.sendToolListChanged().catch(() => { });
|
|
2103
2268
|
}
|
|
2104
2269
|
}
|