openwriter 0.40.1 → 0.40.3
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-Dxbv2n2m.js → index-BjaX2BWN.js} +49 -49
- package/dist/client/index.html +1 -1
- package/dist/plugins/authors-voice/dist/index.d.ts +48 -0
- package/dist/plugins/authors-voice/dist/index.js +235 -0
- package/dist/plugins/authors-voice/package.json +24 -0
- package/dist/plugins/authors-voice/skill/LICENSE +21 -0
- package/dist/plugins/authors-voice/skill/README.md +126 -0
- package/dist/plugins/authors-voice/skill/SKILL.md +151 -0
- package/dist/plugins/authors-voice/skill/catalog/ai-tells.md +144 -0
- package/dist/plugins/authors-voice/skill/catalog/anchor-prompt.md +189 -0
- package/dist/plugins/authors-voice/skill/catalog/author-hints.md +119 -0
- package/dist/plugins/authors-voice/skill/catalog/fingerprints.md +175 -0
- package/dist/plugins/authors-voice/skill/catalog/hurdle.md +76 -0
- package/dist/plugins/authors-voice/skill/catalog/post-write-audit.md +105 -0
- package/dist/plugins/authors-voice/skill/docs/analysis.md +31 -0
- package/dist/plugins/authors-voice/skill/docs/anchor-iteration.md +176 -0
- package/dist/plugins/authors-voice/skill/docs/api/import.md +78 -0
- package/dist/plugins/authors-voice/skill/docs/api/protocol.md +140 -0
- package/dist/plugins/authors-voice/skill/docs/api/setup.md +37 -0
- package/dist/plugins/authors-voice/skill/docs/api/tools.md +102 -0
- package/dist/plugins/authors-voice/skill/docs/api/troubleshooting.md +7 -0
- package/dist/plugins/authors-voice/skill/docs/apply-protocol-deep.md +191 -0
- package/dist/plugins/authors-voice/skill/docs/context-hygiene.md +33 -0
- package/dist/plugins/authors-voice/skill/docs/setup.md +74 -0
- package/dist/plugins/authors-voice/skill/docs/tiers.md +13 -0
- package/dist/plugins/authors-voice/skill/package.json +35 -0
- package/dist/plugins/authors-voice/skill/prompts/skeleton.md +29 -0
- package/dist/plugins/authors-voice/skill/voice/README.md +51 -0
- package/dist/plugins/authors-voice/skill/voice/corpus/.gitkeep +0 -0
- package/dist/plugins/github/dist/blog-tools.d.ts +84 -0
- package/dist/plugins/github/dist/blog-tools.js +1208 -0
- package/dist/plugins/github/dist/git-sync.d.ts +46 -0
- package/dist/plugins/github/dist/git-sync.js +335 -0
- package/dist/plugins/github/dist/helpers.d.ts +127 -0
- package/dist/plugins/github/dist/helpers.js +67 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +112 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +149 -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 +1156 -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 +368 -0
- package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
- package/dist/plugins/x-api/dist/server-bridge.js +43 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/blog-routes.js +160 -0
- package/dist/server/documents.js +56 -9
- package/dist/server/git-sync.js +273 -0
- package/dist/server/marks.js +182 -0
- package/dist/server/mcp.js +68 -18
- package/dist/server/sync-routes.js +75 -0
- package/package.json +1 -1
- package/skill/SKILL.md +15 -7
- package/skill/docs/enrichment.md +180 -0
- package/skill/docs/footnotes.md +178 -0
- package/skill/docs/setup.md +62 -0
- package/skill/docs/welcome.md +21 -0
package/dist/server/mcp.js
CHANGED
|
@@ -23,7 +23,7 @@ import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBackl
|
|
|
23
23
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
24
24
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastPendingMetadataChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
|
|
25
25
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
26
|
-
import { findDocNode } from './workspace-tree.js';
|
|
26
|
+
import { findDocNode, findContainer } from './workspace-tree.js';
|
|
27
27
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
28
28
|
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
29
29
|
import matter from 'gray-matter';
|
|
@@ -484,12 +484,14 @@ export const TOOL_REGISTRY = [
|
|
|
484
484
|
},
|
|
485
485
|
{
|
|
486
486
|
name: 'create_document',
|
|
487
|
-
description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad.
|
|
487
|
+
description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. Placement accepts EITHER convention: name-based auto-create (workspace title + container name, created if absent) OR id-based targeting of existing items (workspaceFile + containerId, the same ids move_item/get_workspace_structure use). A placement param that cannot be honored (unknown workspaceFile/containerId, or a container with no workspace) is a hard error — the doc is never silently created unplaced. The result always states where the doc landed, or "UNFILED".',
|
|
488
488
|
schema: {
|
|
489
489
|
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
490
490
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
491
491
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
492
492
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
|
|
493
|
+
workspaceFile: z.string().optional().describe('Existing workspace by manifest filename (the *.json id used by move_item / get_workspace_structure). Id-based alternative to "workspace" (title). Must already exist — errors if not found. Use when you already hold the workspaceFile from another tool.'),
|
|
494
|
+
containerId: z.string().optional().describe('Existing container by id (8-char hex, as used by move_item / get_workspace_structure). Id-based alternative to "container" (name). Must already exist in the resolved workspace — errors if not found. Requires a workspace (workspaceFile or workspace).'),
|
|
493
495
|
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.'),
|
|
494
496
|
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically. "manuscript" = a binding doc whose body is an ordered list of [text](doc:ID) pointers under ## chapter headings; populate it with the manifest, then it compiles to EPUB/DOCX via the manuscript routes.'),
|
|
495
497
|
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.'),
|
|
@@ -498,7 +500,7 @@ export const TOOL_REGISTRY = [
|
|
|
498
500
|
masterDocId: z.string().optional().describe('Make this doc a VARIANT of another doc. Pass the master doc\'s docId (8-char hex). The variant nests under its master in the sidebar (expandable tree). Use when creating a derivative — e.g. a tweet thread from a blog post. Pair with variantType. See docs/variants.md.'),
|
|
499
501
|
variantType: z.string().optional().describe('Label for what kind of variant this is (e.g. "tweet", "blog", "linkedin"). Shows as a badge in the sidebar. Only meaningful alongside masterDocId.'),
|
|
500
502
|
},
|
|
501
|
-
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
|
|
503
|
+
handler: async ({ title, path, workspace, container, workspaceFile, containerId, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
|
|
502
504
|
// Require url for reply/quote
|
|
503
505
|
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
504
506
|
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
@@ -511,17 +513,69 @@ export const TOOL_REGISTRY = [
|
|
|
511
513
|
};
|
|
512
514
|
title = typeDefaults[content_type];
|
|
513
515
|
}
|
|
514
|
-
// Resolve
|
|
516
|
+
// Resolve placement up front so the spinner renders in the right place.
|
|
517
|
+
// Accept BOTH addressing conventions: the id-based one used across the rest
|
|
518
|
+
// of the API (workspaceFile + containerId, targeting EXISTING items) and the
|
|
519
|
+
// name-based auto-create one (workspace title + container name). Whichever is
|
|
520
|
+
// given is resolved; a placement param that cannot be honored is a HARD ERROR
|
|
521
|
+
// rather than a silent drop that orphans the doc.
|
|
522
|
+
// adr: adr/create-document-placement-contract.md
|
|
515
523
|
let wsTarget;
|
|
516
|
-
if (workspace) {
|
|
517
|
-
|
|
518
|
-
let
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
524
|
+
if (workspace || workspaceFile || container || containerId) {
|
|
525
|
+
// 1. Resolve the workspace. Prefer the explicit id (workspaceFile).
|
|
526
|
+
let wsFilenameResolved = null;
|
|
527
|
+
if (workspaceFile) {
|
|
528
|
+
try {
|
|
529
|
+
getWorkspace(workspaceFile);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return { content: [{ type: 'text', text: `Error: create_document workspaceFile "${workspaceFile}" not found. Pass an existing workspace .json filename, or use "workspace" (title) to create one by name.` }] };
|
|
533
|
+
}
|
|
534
|
+
wsFilenameResolved = workspaceFile;
|
|
535
|
+
}
|
|
536
|
+
else if (workspace) {
|
|
537
|
+
wsFilenameResolved = findOrCreateWorkspace(workspace).filename;
|
|
538
|
+
}
|
|
539
|
+
// 2. A container was requested but the workspace couldn't be resolved.
|
|
540
|
+
// Previously silently dropped — now a hard error.
|
|
541
|
+
if ((container || containerId) && !wsFilenameResolved) {
|
|
542
|
+
return { content: [{ type: 'text', text: `Error: create_document was given a container but no workspace. Pass "workspaceFile" (existing) or "workspace" (title, auto-created) alongside the container.` }] };
|
|
543
|
+
}
|
|
544
|
+
// 3. Resolve the container within that workspace.
|
|
545
|
+
let resolvedContainerId = null;
|
|
546
|
+
if (wsFilenameResolved) {
|
|
547
|
+
if (containerId) {
|
|
548
|
+
const wsObj = getWorkspace(wsFilenameResolved);
|
|
549
|
+
if (!findContainer(wsObj.root, containerId)) {
|
|
550
|
+
return { content: [{ type: 'text', text: `Error: create_document containerId "${containerId}" not found in workspace ${wsFilenameResolved}. Pass an existing containerId, or use "container" (name) to create one.` }] };
|
|
551
|
+
}
|
|
552
|
+
resolvedContainerId = containerId;
|
|
553
|
+
}
|
|
554
|
+
else if (container) {
|
|
555
|
+
resolvedContainerId = findOrCreateContainer(wsFilenameResolved, container).containerId;
|
|
556
|
+
}
|
|
557
|
+
wsTarget = { wsFilename: wsFilenameResolved, containerId: resolvedContainerId };
|
|
558
|
+
broadcastWorkspacesChanged(); // Browser sees container structure before spinner
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Always state the final placement in the result, so a doc that lands in no
|
|
562
|
+
// workspace says so loudly (UNFILED) — an unplaced doc can never read as a
|
|
563
|
+
// bland success again. adr: brief 2026-07-09-create-document-placement-contract.
|
|
564
|
+
let placement = ' (UNFILED — not added to any workspace; lives at ~/.openwriter)';
|
|
565
|
+
if (wsTarget) {
|
|
566
|
+
let wsLabel = wsTarget.wsFilename;
|
|
567
|
+
let cLabel = '';
|
|
568
|
+
try {
|
|
569
|
+
const wsObj = getWorkspace(wsTarget.wsFilename);
|
|
570
|
+
wsLabel = wsObj.title || wsTarget.wsFilename;
|
|
571
|
+
if (wsTarget.containerId) {
|
|
572
|
+
const found = findContainer(wsObj.root, wsTarget.containerId);
|
|
573
|
+
if (found)
|
|
574
|
+
cLabel = ` / ${found.node.name}`;
|
|
575
|
+
}
|
|
522
576
|
}
|
|
523
|
-
|
|
524
|
-
|
|
577
|
+
catch { /* keep filename */ }
|
|
578
|
+
placement = ` → workspace "${wsLabel}"${cLabel}`;
|
|
525
579
|
}
|
|
526
580
|
// Track the spinner key so catch can clear exactly this entry
|
|
527
581
|
// (not siblings from a concurrent declare_writes).
|
|
@@ -552,13 +606,11 @@ export const TOOL_REGISTRY = [
|
|
|
552
606
|
Object.assign(initMeta, typeMeta);
|
|
553
607
|
}
|
|
554
608
|
setMetadata(initMeta);
|
|
555
|
-
let wsInfo = '';
|
|
556
609
|
if (wsTarget) {
|
|
557
610
|
// Resolve afterId: it may be a docId (8-char hex) or containerId.
|
|
558
611
|
// filenameByDocId resolves docId→filename; if null, treat as containerId.
|
|
559
612
|
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
560
613
|
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
561
|
-
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
562
614
|
}
|
|
563
615
|
const newDocId = getDocId();
|
|
564
616
|
save('agent');
|
|
@@ -576,7 +628,7 @@ export const TOOL_REGISTRY = [
|
|
|
576
628
|
return {
|
|
577
629
|
content: [{
|
|
578
630
|
type: 'text',
|
|
579
|
-
text: `Created "${result.title}" [${newDocId}]${
|
|
631
|
+
text: `Created "${result.title}" [${newDocId}]${placement}${content_type ? ` (${content_type})` : ''} — ready.`,
|
|
580
632
|
}],
|
|
581
633
|
};
|
|
582
634
|
}
|
|
@@ -587,11 +639,9 @@ export const TOOL_REGISTRY = [
|
|
|
587
639
|
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
588
640
|
const initialMeta = { ...statusMeta, ...variantMeta, ...(typeMeta || {}) };
|
|
589
641
|
const result = createDocumentFile(title, path, initialMeta);
|
|
590
|
-
let wsInfo = '';
|
|
591
642
|
if (wsTarget) {
|
|
592
643
|
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
593
644
|
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
594
|
-
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
595
645
|
}
|
|
596
646
|
// Broadcast spinner keyed by filename so populate_document can clear exactly
|
|
597
647
|
// this entry. Fires after the file exists, so documents-changed arrives with
|
|
@@ -610,7 +660,7 @@ export const TOOL_REGISTRY = [
|
|
|
610
660
|
return {
|
|
611
661
|
content: [{
|
|
612
662
|
type: 'text',
|
|
613
|
-
text: `Created "${result.title}" [${result.docId}]${
|
|
663
|
+
text: `Created "${result.title}" [${result.docId}]${placement} — empty. Call populate_document with docId "${result.docId}" to add content.`,
|
|
614
664
|
}],
|
|
615
665
|
};
|
|
616
666
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express routes for GitHub sync.
|
|
3
|
+
* Mounted in index.ts — follows version-routes.ts pattern.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { getSyncStatus, getCapabilities, getPendingFiles, setupWithGh, setupWithPat, connectExisting, pushSync, } from './git-sync.js';
|
|
7
|
+
export function createSyncRouter(broadcastSyncStatus) {
|
|
8
|
+
const router = Router();
|
|
9
|
+
router.get('/api/sync/status', async (_req, res) => {
|
|
10
|
+
try {
|
|
11
|
+
res.json(await getSyncStatus());
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
router.get('/api/sync/capabilities', async (_req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
res.json(await getCapabilities());
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
res.status(500).json({ error: err.message });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
router.get('/api/sync/pending', async (_req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
res.json(await getPendingFiles());
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
res.status(500).json({ error: err.message });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
router.post('/api/sync/setup', async (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const { method, repoName, remoteUrl, pat, isPrivate } = req.body;
|
|
36
|
+
if (method === 'gh') {
|
|
37
|
+
await setupWithGh(repoName || 'openwriter-docs', isPrivate !== false);
|
|
38
|
+
}
|
|
39
|
+
else if (method === 'pat') {
|
|
40
|
+
if (!pat) {
|
|
41
|
+
res.status(400).json({ error: 'PAT is required' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await setupWithPat(pat, repoName || 'openwriter-docs', isPrivate !== false);
|
|
45
|
+
}
|
|
46
|
+
else if (method === 'connect') {
|
|
47
|
+
if (!remoteUrl) {
|
|
48
|
+
res.status(400).json({ error: 'Remote URL is required' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await connectExisting(remoteUrl, pat);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
res.status(400).json({ error: 'Invalid method. Use: gh, pat, or connect' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const status = await getSyncStatus();
|
|
58
|
+
broadcastSyncStatus(status);
|
|
59
|
+
res.json({ success: true, status });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
res.status(500).json({ error: err.message });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
router.post('/api/sync/push', async (_req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await pushSync(broadcastSyncStatus);
|
|
68
|
+
res.json(result);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return router;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.3",
|
|
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",
|
package/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.19.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -201,7 +201,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
201
201
|
| `delete_container` | Delete a container from a workspace (doc files stay on disk) |
|
|
202
202
|
| `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
|
|
203
203
|
| `untag_doc` | Remove a tag from a document by docId |
|
|
204
|
-
| `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
|
|
204
|
+
| `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace). To nest a doc into a container: `move_item({ type: 'doc', workspaceFile, itemId: <docId>, targetContainerId: <containerId>, afterId? })`. The target param is **`targetContainerId`** — passing `containerId`/`container` instead is silently ignored and the doc lands at workspace root. |
|
|
205
205
|
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
206
206
|
|
|
207
207
|
### Enrichment (three-field schema — v0.19.0)
|
|
@@ -328,21 +328,29 @@ The user can turn on **auto-accept** on a per-doc basis (right-click the doc in
|
|
|
328
328
|
|
|
329
329
|
### Workspace-Integrated Creation
|
|
330
330
|
|
|
331
|
-
`create_document`
|
|
331
|
+
`create_document` takes placement in **either** convention (unified 2026-07-09):
|
|
332
332
|
|
|
333
333
|
```
|
|
334
334
|
create_document({
|
|
335
335
|
title: "Opening Chapter",
|
|
336
336
|
content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
|
|
337
|
-
workspace: "The Immortal", ← creates workspace if it doesn't exist
|
|
338
|
-
container: "Chapters" ← creates container if it doesn't exist
|
|
337
|
+
workspace: "The Immortal", ← name-based: creates workspace if it doesn't exist
|
|
338
|
+
container: "Chapters" ← name-based: creates container if it doesn't exist
|
|
339
339
|
})
|
|
340
340
|
```
|
|
341
341
|
|
|
342
|
+
**Name-based (auto-create):**
|
|
342
343
|
- **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
|
|
343
344
|
- **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
|
|
344
|
-
|
|
345
|
-
-
|
|
345
|
+
|
|
346
|
+
**Id-based (target existing items — the same ids `move_item`/`get_workspace_structure` use):**
|
|
347
|
+
- **`workspaceFile`** (string) — existing workspace manifest filename (`*.json`). Must already exist. Alternative to `workspace`.
|
|
348
|
+
- **`containerId`** (string) — existing container id (8-char hex). Must already exist in the resolved workspace. Alternative to `container`. Requires a workspace param.
|
|
349
|
+
|
|
350
|
+
**Both conventions:**
|
|
351
|
+
- **`afterId`** (string, optional) — docId (8-char hex) or containerId to place the new doc immediately after. Omit and the doc lands at the **bottom** of its parent (the default since 0.18.0, matching the ascending-order convention: oldest at top, newest at bottom). `afterId` alone does NOT set a workspace — pass a workspace param too.
|
|
352
|
+
- Omit all placement params for a standalone doc — the result then says **UNFILED** (a doc created in no workspace announces it, rather than reading as a bland success).
|
|
353
|
+
- A placement that can't be honored (unknown `workspaceFile`/`containerId`, or a container with no workspace) is a **hard error** — the doc is never silently created unplaced. The result always states where it landed.
|
|
346
354
|
|
|
347
355
|
This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace. The default-bottom landing also eliminates the need for a follow-up `move_item` pass to fix sidebar order after every create — the doc lands in convention position the first time.
|
|
348
356
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Enrichment Dispatch — Detailed Procedure
|
|
2
|
+
|
|
3
|
+
OpenWriter's frontmatter enrichment is dispatched via the
|
|
4
|
+
`openwriter-enrichment-minion` custom subagent. SKILL.md firm rule 5
|
|
5
|
+
covers the common case (single minion, small/medium batch). This doc
|
|
6
|
+
handles the **large-corpus case** where one minion isn't enough — and
|
|
7
|
+
the parallel-dispatch pattern that scales it.
|
|
8
|
+
|
|
9
|
+
## When to chunk
|
|
10
|
+
|
|
11
|
+
| Dirty docs (N) | Dispatch shape | Wall time |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| 1–30 | Single minion. Default prompt. | ~10–45 seconds |
|
|
14
|
+
| 31+ | Chunked parallel minions. | ~30 seconds (regardless of N) |
|
|
15
|
+
|
|
16
|
+
The minion's turn budget (`maxTurns: 500` in its frontmatter) can handle
|
|
17
|
+
~50 docs serially, but at that size the wall-clock cost (3+ minutes)
|
|
18
|
+
becomes visible to the user. Parallel dispatch keeps total wall time
|
|
19
|
+
under ~30 seconds for any corpus size up to a few hundred docs.
|
|
20
|
+
|
|
21
|
+
## Step-by-step (large corpus)
|
|
22
|
+
|
|
23
|
+
### 1. Inventory the work
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
mcp__openwriter__list_dirty_docs()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Returns every dirty doc across all workspaces with `docId`, `title`,
|
|
30
|
+
`workspaceFile`, `reason`. If `total ≤ 30`, stop — single minion path
|
|
31
|
+
(firm rule 5) is correct. If `total > 30`, continue.
|
|
32
|
+
|
|
33
|
+
### 2. Chunk the work
|
|
34
|
+
|
|
35
|
+
v0.19.0 simplified the minion to logline-only — workspace vocab is no
|
|
36
|
+
longer relevant (the `domain` field that used it was dropped). You can
|
|
37
|
+
group chunks however you want; workspace-grouping is no longer required.
|
|
38
|
+
Practical defaults:
|
|
39
|
+
|
|
40
|
+
**Target: 12–15 docs per chunk.**
|
|
41
|
+
|
|
42
|
+
- **Very large dirty list (>100 docs):** split into chunks of ~15.
|
|
43
|
+
- **Workspace-grouped is still fine** if it makes the dispatch prompts
|
|
44
|
+
easier to read, but it's no longer a performance concern.
|
|
45
|
+
|
|
46
|
+
You'll typically land on 4–10 chunks. Don't exceed ~10 parallel —
|
|
47
|
+
Anthropic per-account rate limits kick in beyond that and you get
|
|
48
|
+
serialized anyway.
|
|
49
|
+
|
|
50
|
+
### 3. Dispatch all chunks in one message
|
|
51
|
+
|
|
52
|
+
Send **every chunk in a single assistant message** with multiple `Agent`
|
|
53
|
+
tool uses. This is the only way they actually run in parallel —
|
|
54
|
+
sequential `Agent` calls block each other.
|
|
55
|
+
|
|
56
|
+
Use `run_in_background: true` so you can keep talking to the user while
|
|
57
|
+
the minions work. You'll receive a `<task-notification>` per chunk as
|
|
58
|
+
each one finishes.
|
|
59
|
+
|
|
60
|
+
### 4. Prompt format (explicit-list mode)
|
|
61
|
+
|
|
62
|
+
The minion's agent file (`~/.claude/agents/openwriter-enrichment-minion.md`)
|
|
63
|
+
supports an explicit-list mode — pass docIds in the prompt and the minion
|
|
64
|
+
skips `list_dirty_docs` and uses your list directly.
|
|
65
|
+
|
|
66
|
+
Example prompt for one chunk (v0.19.0 — logline-only):
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Enrich these specific openwriter docs:
|
|
70
|
+
|
|
71
|
+
- a1b2c3d4 — Onboarding Email Sequence
|
|
72
|
+
- e5f6a7b8 — Why We Sleep — Ch 2 Notes
|
|
73
|
+
- 9z8y7x6w — Product Launch Checklist
|
|
74
|
+
- 1q2w3e4r — Ch 3 — Beats
|
|
75
|
+
- 5t6y7u8i — Ch 4 — Draft
|
|
76
|
+
|
|
77
|
+
For each: read_pad to get the body, write a logline ≤150 chars, then
|
|
78
|
+
bulk mark_enriched at the end with { docId, logline } per entry.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Keep prompts short. The minion already knows the procedure from its
|
|
82
|
+
agent file — you're just handing it the work list. The minion's tool
|
|
83
|
+
allowlist (v0.19.0) is `list_dirty_docs`, `read_pad`, `mark_enriched`
|
|
84
|
+
— `get_workspace_structure` is no longer needed because there's no
|
|
85
|
+
workspace-vocab dependency.
|
|
86
|
+
|
|
87
|
+
### 5. Surface to the user (large-batch phrasing)
|
|
88
|
+
|
|
89
|
+
Before dispatching, tell the user what's happening. Firm rule 5's
|
|
90
|
+
"large batch" tier (N > 20) requires a heads-up. Example:
|
|
91
|
+
|
|
92
|
+
> OpenWriter detected 73 docs that haven't been summarized yet —
|
|
93
|
+
> first-time setup. Refreshing them in 6 parallel batches in the
|
|
94
|
+
> background; this'll take ~30 seconds and a few cents of Haiku usage.
|
|
95
|
+
|
|
96
|
+
Then dispatch. Stay silent as notifications come in unless one fails.
|
|
97
|
+
When all are done, report once:
|
|
98
|
+
|
|
99
|
+
> Enrichment complete: 73 docs across 8 workspaces. Cost: ~$0.15.
|
|
100
|
+
|
|
101
|
+
### 6. Verify completion
|
|
102
|
+
|
|
103
|
+
After every chunk has notified, call `list_dirty_docs` once more. If
|
|
104
|
+
`total > 0`, some docs slipped — usually because a minion errored on a
|
|
105
|
+
specific doc or a doc was modified mid-enrichment. Re-dispatch a single
|
|
106
|
+
minion for the stragglers; don't redo the whole batch.
|
|
107
|
+
|
|
108
|
+
## Why this shape
|
|
109
|
+
|
|
110
|
+
**Why parallel, not serial single-minion at maxTurns: 500?**
|
|
111
|
+
A single minion processing 100 docs takes 3+ minutes wall time. The
|
|
112
|
+
user sits in silence. Six parallel minions of ~15 docs each finish in
|
|
113
|
+
~30 seconds. Same total token cost — much better UX.
|
|
114
|
+
|
|
115
|
+
**Why explicit docId list instead of letting each minion call `list_dirty_docs`?**
|
|
116
|
+
Race conditions. If you spawn 6 minions and they all call
|
|
117
|
+
`list_dirty_docs`, they all see the same 100 dirty docs and try to
|
|
118
|
+
enrich the same docs in parallel. Most enrichments succeed (last write
|
|
119
|
+
wins on the frontmatter), but it's wasteful and the per-doc baselines
|
|
120
|
+
get computed multiple times. Explicit lists partition the work cleanly.
|
|
121
|
+
|
|
122
|
+
**Why 12–15 docs per chunk and not 50?**
|
|
123
|
+
Two reasons: (1) turn budget — each doc costs ~1 turn (one `read_pad`
|
|
124
|
+
call); ~15 docs leaves headroom inside the 500-turn ceiling even with
|
|
125
|
+
retries. (2) failure isolation — if one minion's batch errors, you lose
|
|
126
|
+
15 docs of work, not 50.
|
|
127
|
+
|
|
128
|
+
**Why dispatch in one message, not sequential Agent calls?**
|
|
129
|
+
Sequential `Agent` calls block each other. Only multiple `Agent` tool
|
|
130
|
+
uses in the **same assistant message** run truly in parallel.
|
|
131
|
+
|
|
132
|
+
## Cost ballpark
|
|
133
|
+
|
|
134
|
+
Haiku token cost per doc: ~1.5K–3K in v0.19.0 (one read_pad + one
|
|
135
|
+
logline synthesis + share of mark_enriched). Roughly half what it cost
|
|
136
|
+
under v0.16's five-field schema.
|
|
137
|
+
|
|
138
|
+
| Corpus size | Approx cost (v0.19.0) |
|
|
139
|
+
|---|---|
|
|
140
|
+
| 30 docs | ~$0.02 |
|
|
141
|
+
| 100 docs | ~$0.08 |
|
|
142
|
+
| 500 docs | ~$0.40 |
|
|
143
|
+
|
|
144
|
+
Compare to ~$5.00 per doc if you used the general-purpose subagent with
|
|
145
|
+
full MCP tool registry (~50K token overhead per spawn). The custom
|
|
146
|
+
minion's tool allowlist (3 tools in v0.19.0: `list_dirty_docs`,
|
|
147
|
+
`read_pad`, `mark_enriched`) is what makes the math work.
|
|
148
|
+
|
|
149
|
+
## Failure modes
|
|
150
|
+
|
|
151
|
+
- **Minion returns with no `mark_enriched` call** — almost always means
|
|
152
|
+
it hit the turn ceiling. Confirm its agent file has `maxTurns: 500`
|
|
153
|
+
in the frontmatter, then reduce chunk size to ~10 docs and re-dispatch
|
|
154
|
+
that chunk.
|
|
155
|
+
- **Minion reports "No enrichment work pending"** — its assigned docs
|
|
156
|
+
got enriched by a sibling minion first (race condition from
|
|
157
|
+
`list_dirty_docs` mode, not explicit-list mode). Benign; the other
|
|
158
|
+
minion's work landed correctly.
|
|
159
|
+
- **`<task-notification>` reports an error** — re-dispatch just that
|
|
160
|
+
one chunk. Don't restart the whole batch.
|
|
161
|
+
- **Logline cap violations** — the minion's agent file enforces a
|
|
162
|
+
150-char hard cap. If you spot longer loglines on disk after the
|
|
163
|
+
fact, it's a minion regression — flag for agent-file revision rather
|
|
164
|
+
than re-enriching.
|
|
165
|
+
|
|
166
|
+
## When NOT to chunk
|
|
167
|
+
|
|
168
|
+
If `list_dirty_docs` returns ≤30 docs, dispatch a single minion with
|
|
169
|
+
the default prompt:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
Agent({
|
|
173
|
+
subagent_type: "openwriter-enrichment-minion",
|
|
174
|
+
description: "Enrich stale openwriter docs",
|
|
175
|
+
prompt: "Enrich all currently stale openwriter docs."
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The minion calls `list_dirty_docs` itself, processes everything in one
|
|
180
|
+
pass, and reports back. Chunking ≤30 docs is overhead, not gain.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Footnotes — Author Guide for Agents
|
|
2
|
+
|
|
3
|
+
OpenWriter supports CommonMark / Pandoc footnote syntax for citation-heavy
|
|
4
|
+
long-form writing. The editor renders inline references as superscript
|
|
5
|
+
chips and corrals definitions into an end-of-doc "Footnotes" section.
|
|
6
|
+
|
|
7
|
+
This doc explains how to write footnotes via MCP tools and what to expect
|
|
8
|
+
on disk and in the editor.
|
|
9
|
+
|
|
10
|
+
## The syntax (Pandoc / CommonMark)
|
|
11
|
+
|
|
12
|
+
Two parts:
|
|
13
|
+
|
|
14
|
+
- **Reference** (inline): `text[^N]` — appears in the prose
|
|
15
|
+
- **Definition** (block): `[^N]: footnote text` — appears at end of doc
|
|
16
|
+
|
|
17
|
+
Labels can be numeric or mnemonic:
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
The body repairs itself during deep sleep[^1] too.
|
|
21
|
+
|
|
22
|
+
Per Sapolsky[^sapolsky2017], stress responses follow a pattern.
|
|
23
|
+
|
|
24
|
+
[^1]: Eibl-Eibesfeldt 1973, replicated and extended by Galati et al. 2003.
|
|
25
|
+
|
|
26
|
+
[^sapolsky2017]: Sapolsky, R. (2017). *Behave*. Penguin Press.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The author label (`1` or `sapolsky2017`) is what pairs reference to
|
|
30
|
+
definition. **Display numbering is automatic** — the editor's CSS counter
|
|
31
|
+
shows sequential `[1] [2] [3]` regardless of label. Mnemonic labels stay
|
|
32
|
+
on disk for human-readable file diffs.
|
|
33
|
+
|
|
34
|
+
## How to write footnotes from MCP
|
|
35
|
+
|
|
36
|
+
Just include the syntax in your markdown content — no special tool needed.
|
|
37
|
+
|
|
38
|
+
### populate_document (initial draft)
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
populate_document({
|
|
42
|
+
docId: "abc12345",
|
|
43
|
+
content: `# Chapter 1
|
|
44
|
+
|
|
45
|
+
Theory of mind develops late[^1] in non-human primates.
|
|
46
|
+
|
|
47
|
+
[^1]: Premack & Woodruff (1978), Behavioral and Brain Sciences 1: 515–526.
|
|
48
|
+
`
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The parser handles `[^1]` references and `[^1]: ...` definitions. The
|
|
53
|
+
editor renders the reference as a superscript chip and the definition
|
|
54
|
+
inside an end-of-doc "Footnotes" section.
|
|
55
|
+
|
|
56
|
+
### write_to_pad (adding to existing doc)
|
|
57
|
+
|
|
58
|
+
To add a new footnote to existing prose, you have two paths.
|
|
59
|
+
|
|
60
|
+
**Append a new reference + definition together** (recommended):
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// Step 1: rewrite the paragraph to add the reference
|
|
64
|
+
write_to_pad({
|
|
65
|
+
docId: "abc12345",
|
|
66
|
+
changes: [
|
|
67
|
+
{
|
|
68
|
+
operation: "rewrite",
|
|
69
|
+
nodeId: "para_id",
|
|
70
|
+
content: "The same sentence now with a new claim[^2]."
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Step 2: append the definition. If the doc already has a footnoteSection,
|
|
76
|
+
// you can insert the definition inside it via afterNodeId pointing at the
|
|
77
|
+
// last definition. If the doc has no footnotes yet, the parser auto-creates
|
|
78
|
+
// the section when it sees `[^N]: ...` at the end of the markdown body.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Simpler: just include both the reference and the definition in one
|
|
82
|
+
write_to_pad call**:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
write_to_pad({
|
|
86
|
+
docId: "abc12345",
|
|
87
|
+
changes: [
|
|
88
|
+
{
|
|
89
|
+
operation: "rewrite",
|
|
90
|
+
nodeId: "para_id",
|
|
91
|
+
content: "Sentence with new claim[^2]."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
operation: "insert",
|
|
95
|
+
afterNodeId: "end",
|
|
96
|
+
content: "[^2]: Smith et al. (2020), Nature 580: 142–148."
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The serializer normalizes definitions to the end-of-doc `footnoteSection`
|
|
103
|
+
regardless of where they're inserted in the tree.
|
|
104
|
+
|
|
105
|
+
## What you see in `read_pad`
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
title: My Chapter
|
|
109
|
+
id: abc12345
|
|
110
|
+
words: 423
|
|
111
|
+
pending: 0
|
|
112
|
+
---
|
|
113
|
+
[h1:aa0001] Chapter 1
|
|
114
|
+
[p:bb0002] Theory of mind develops late[^1] in non-human primates.
|
|
115
|
+
[fnsec:cc0003]
|
|
116
|
+
[fndef:dd0004] [^1]: Premack & Woodruff (1978), Behavioral and Brain Sciences 1: 515–526.
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The `[^N]` in the body is the inline reference. `[fnsec:...]` is the
|
|
120
|
+
end-of-doc section. `[fndef:...]` is each definition.
|
|
121
|
+
|
|
122
|
+
## Per-doc scope — important
|
|
123
|
+
|
|
124
|
+
**Footnote labels are local to each doc.** Chapter 3's `[^1]` does not
|
|
125
|
+
refer to Chapter 4's `[^1]`. Each chapter is its own `.md` file with its
|
|
126
|
+
own numbering. Cross-chapter references are not supported at the editor
|
|
127
|
+
level (a future book-export pipeline will handle global numbering at
|
|
128
|
+
typeset time).
|
|
129
|
+
|
|
130
|
+
If the author writes "see Ch 1 note 4" they're writing prose, not a
|
|
131
|
+
cross-doc footnote link.
|
|
132
|
+
|
|
133
|
+
## Multi-paragraph definitions
|
|
134
|
+
|
|
135
|
+
Pandoc allows multi-paragraph footnotes via 4-space-indented continuation:
|
|
136
|
+
|
|
137
|
+
```markdown
|
|
138
|
+
[^1]: First paragraph of the definition.
|
|
139
|
+
|
|
140
|
+
Continuation paragraph, indented 4 spaces.
|
|
141
|
+
|
|
142
|
+
Another continuation.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The editor preserves the multi-paragraph structure inside the definition.
|
|
146
|
+
Use this for footnotes that need substantial explanation (lengthy
|
|
147
|
+
methodology notes, multi-source citations, etc.).
|
|
148
|
+
|
|
149
|
+
## What's NOT supported (yet)
|
|
150
|
+
|
|
151
|
+
- **Cross-doc footnote references.** Each doc has its own numbering.
|
|
152
|
+
- **Bibliography auto-generation.** Authors manage citation text inline.
|
|
153
|
+
Zotero / Mendeley / BibTeX integration is a future enhancement.
|
|
154
|
+
- **DOI auto-resolution.** Footnote text is plain — paste a DOI manually.
|
|
155
|
+
- **Per-page footnotes (Phase 3).** The editor uses end-of-doc placement;
|
|
156
|
+
per-page placement is a print-layout concern handled at book-export
|
|
157
|
+
time, not in the editor.
|
|
158
|
+
|
|
159
|
+
## When to use footnotes vs inline parentheticals
|
|
160
|
+
|
|
161
|
+
Use footnotes when:
|
|
162
|
+
- The citation count exceeds ~3 per ~500 words (inline parentheticals
|
|
163
|
+
start visibly disrupting the prose at that density)
|
|
164
|
+
- The audience expects an academic register (popular nonfiction in the
|
|
165
|
+
Sapolsky / Wrangham / Pinker lineage)
|
|
166
|
+
- The work targets book-class output (cumulative citation load at book
|
|
167
|
+
scale destroys readability under inline parentheticals)
|
|
168
|
+
|
|
169
|
+
Use inline parentheticals when:
|
|
170
|
+
- Citations are sparse (<1 per 500 words) and short
|
|
171
|
+
- The author prefers a journalistic register
|
|
172
|
+
- The work is short-form (tweet thread, blog post) where there's no
|
|
173
|
+
end-of-doc section to defer to
|
|
174
|
+
|
|
175
|
+
## Reference docs (for the editor maintainers, not agents)
|
|
176
|
+
|
|
177
|
+
- `docs/footnotes.md` (in the openwriter repo): full architecture
|
|
178
|
+
- `adr/footnote-system.md`: load-bearing invariants + decision log
|