openwriter 0.17.0 → 0.18.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/server/documents.js +12 -5
- package/dist/server/mcp.js +18 -7
- package/dist/server/workspace-tree.js +18 -3
- package/dist/server/workspaces.js +2 -2
- package/package.json +1 -1
package/dist/server/documents.js
CHANGED
|
@@ -257,13 +257,15 @@ export function listArchivedDocuments() {
|
|
|
257
257
|
// ============================================================================
|
|
258
258
|
/** One-line footer the high-frequency MCP discovery tools (list_documents,
|
|
259
259
|
* list_workspaces, get_workspace_structure) append when dirty docs exist.
|
|
260
|
-
* Constant pressure that doesn't require hook setup. The
|
|
261
|
-
*
|
|
260
|
+
* Constant pressure that doesn't require hook setup. The footer includes
|
|
261
|
+
* the exact dispatch call so the acting agent's burden collapses to one
|
|
262
|
+
* paste — the minion is orchestrator-mode by default and self-discovers
|
|
263
|
+
* via list_dirty_docs once it boots. */
|
|
262
264
|
export function enrichmentFooter() {
|
|
263
265
|
const count = listDirtyDocs().length;
|
|
264
266
|
if (count === 0)
|
|
265
267
|
return '';
|
|
266
|
-
return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} need enrichment
|
|
268
|
+
return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} need enrichment. Dispatch:\nAgent(\n subagent_type: "openwriter-enrichment-minion",\n description: "Drain stale docs",\n prompt: "Enrich stale docs.",\n run_in_background: true\n)`;
|
|
267
269
|
}
|
|
268
270
|
/** Session-start enrichment notice injected into the MCP server's
|
|
269
271
|
* `instructions` field at boot. Every MCP client (Claude Code, Cursor, etc.)
|
|
@@ -292,8 +294,13 @@ export function buildEnrichmentInstructions() {
|
|
|
292
294
|
return [
|
|
293
295
|
'',
|
|
294
296
|
`ENRICHMENT_STATUS: ${dirty.length} doc${dirty.length === 1 ? '' : 's'} need enrichment (${parts.join(', ')}).`,
|
|
295
|
-
'
|
|
296
|
-
'
|
|
297
|
+
'Dispatch the minion now — it self-discovers via list_dirty_docs and self-bounds the batch:',
|
|
298
|
+
'Agent(',
|
|
299
|
+
' subagent_type: "openwriter-enrichment-minion",',
|
|
300
|
+
' description: "Drain stale docs",',
|
|
301
|
+
' prompt: "Enrich stale docs.",',
|
|
302
|
+
' run_in_background: true',
|
|
303
|
+
')',
|
|
297
304
|
].join('\n');
|
|
298
305
|
}
|
|
299
306
|
/** Build a Set of filenames inside workspaces with enrichmentDisabled: true.
|
package/dist/server/mcp.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteF
|
|
|
13
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, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
15
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
16
|
-
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
16
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
17
17
|
import { extractForwardLinks } from './backlinks.js';
|
|
18
18
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
19
19
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
@@ -414,8 +414,9 @@ export const TOOL_REGISTRY = [
|
|
|
414
414
|
empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
|
|
415
415
|
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
|
|
416
416
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
417
|
+
afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
|
|
417
418
|
},
|
|
418
|
-
handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
|
|
419
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId }) => {
|
|
419
420
|
// Require url for reply/quote
|
|
420
421
|
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
421
422
|
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
@@ -457,7 +458,10 @@ export const TOOL_REGISTRY = [
|
|
|
457
458
|
}
|
|
458
459
|
let wsInfo = '';
|
|
459
460
|
if (wsTarget) {
|
|
460
|
-
|
|
461
|
+
// Resolve afterId: it may be a docId (8-char hex) or containerId.
|
|
462
|
+
// filenameByDocId resolves docId→filename; if null, treat as containerId.
|
|
463
|
+
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
464
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
461
465
|
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
462
466
|
}
|
|
463
467
|
const newDocId = getDocId();
|
|
@@ -478,7 +482,8 @@ export const TOOL_REGISTRY = [
|
|
|
478
482
|
const result = createDocumentFile(title, path, typeMeta);
|
|
479
483
|
let wsInfo = '';
|
|
480
484
|
if (wsTarget) {
|
|
481
|
-
|
|
485
|
+
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
486
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
482
487
|
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
483
488
|
}
|
|
484
489
|
// Broadcast spinner keyed by filename so populate_document can clear exactly
|
|
@@ -586,6 +591,7 @@ export const TOOL_REGISTRY = [
|
|
|
586
591
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
|
|
587
592
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
|
|
588
593
|
path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
|
|
594
|
+
afterId: z.string().optional().describe('Place the new doc immediately after this docId or containerId inside its parent. Omit to append to the bottom (default, ascending-order convention). Requires workspace.'),
|
|
589
595
|
})).min(1).describe('List of documents to declare (minimum 1).'),
|
|
590
596
|
},
|
|
591
597
|
handler: async ({ writes }) => {
|
|
@@ -612,7 +618,8 @@ export const TOOL_REGISTRY = [
|
|
|
612
618
|
const typeMeta = resolveTypeMeta(w.content_type, w.url);
|
|
613
619
|
const result = createDocumentFile(w.title, w.path, typeMeta);
|
|
614
620
|
if (wsTarget) {
|
|
615
|
-
|
|
621
|
+
const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
|
|
622
|
+
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
616
623
|
}
|
|
617
624
|
broadcastWritingStarted(w.title, wsTarget, result.filename);
|
|
618
625
|
broadcastedKeys.push(result.filename);
|
|
@@ -1090,9 +1097,13 @@ export const TOOL_REGISTRY = [
|
|
|
1090
1097
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
1091
1098
|
name: z.string().describe('Container name (e.g. "Chapters", "Research")'),
|
|
1092
1099
|
parentContainerId: z.string().optional().describe('Parent container ID for nesting (null = root level)'),
|
|
1100
|
+
afterId: z.string().optional().describe('Place the new container immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention).'),
|
|
1093
1101
|
},
|
|
1094
|
-
handler: async ({ workspaceFile, name, parentContainerId }) => {
|
|
1095
|
-
|
|
1102
|
+
handler: async ({ workspaceFile, name, parentContainerId, afterId }) => {
|
|
1103
|
+
// Resolve afterId: may be docId (8-char hex) or containerId. filenameByDocId
|
|
1104
|
+
// resolves docId→filename; if null, treat as containerId.
|
|
1105
|
+
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
1106
|
+
const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name, afterRef);
|
|
1096
1107
|
broadcastWorkspacesChanged();
|
|
1097
1108
|
return { content: [{ type: 'text', text: `Created container "${name}" (id:${result.containerId})` }] };
|
|
1098
1109
|
},
|
|
@@ -89,10 +89,13 @@ export function addDocToContainer(root, containerId, file, title, afterIdentifie
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
else {
|
|
92
|
-
|
|
92
|
+
// Default: append to the bottom of the parent's child list. This matches
|
|
93
|
+
// the ascending-order convention (newest at bottom, oldest at top). Callers
|
|
94
|
+
// that want top-insertion must pass an explicit afterIdentifier.
|
|
95
|
+
target.push(doc);
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
|
-
export function addContainer(root, parentContainerId, name) {
|
|
98
|
+
export function addContainer(root, parentContainerId, name, afterIdentifier) {
|
|
96
99
|
const depth = getContainerDepth(root, parentContainerId);
|
|
97
100
|
if (depth >= MAX_DEPTH) {
|
|
98
101
|
throw new Error(`Maximum nesting depth (${MAX_DEPTH}) reached`);
|
|
@@ -106,7 +109,19 @@ export function addContainer(root, parentContainerId, name) {
|
|
|
106
109
|
name,
|
|
107
110
|
items: [],
|
|
108
111
|
};
|
|
109
|
-
|
|
112
|
+
if (afterIdentifier) {
|
|
113
|
+
const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
|
|
114
|
+
if (afterIdx === -1) {
|
|
115
|
+
target.push(container);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
target.splice(afterIdx + 1, 0, container);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Default: append to the bottom (ascending-order convention).
|
|
123
|
+
target.push(container);
|
|
124
|
+
}
|
|
110
125
|
return container;
|
|
111
126
|
}
|
|
112
127
|
// ============================================================================
|
|
@@ -210,9 +210,9 @@ export function reorderDoc(wsFile, file, afterFile) {
|
|
|
210
210
|
// ============================================================================
|
|
211
211
|
// CONTAINER OPERATIONS
|
|
212
212
|
// ============================================================================
|
|
213
|
-
export function addContainerToWorkspace(wsFile, parentContainerId, name) {
|
|
213
|
+
export function addContainerToWorkspace(wsFile, parentContainerId, name, afterIdentifier) {
|
|
214
214
|
const ws = getWorkspace(wsFile);
|
|
215
|
-
const container = addContainerToTree(ws.root, parentContainerId, name);
|
|
215
|
+
const container = addContainerToTree(ws.root, parentContainerId, name, afterIdentifier ?? null);
|
|
216
216
|
writeWorkspace(wsFile, ws);
|
|
217
217
|
return { workspace: ws, containerId: container.id };
|
|
218
218
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|