openwriter 0.23.0 → 0.24.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-AWIKUHJ_.css +1 -0
- package/dist/client/assets/{index-C65mFCh7.js → index-DmHLFNTs.js} +59 -59
- package/dist/client/index.html +2 -2
- package/dist/server/activity-log.js +2 -0
- package/dist/server/documents.js +97 -3
- package/dist/server/index.js +135 -3
- package/dist/server/mcp.js +160 -54
- package/dist/server/peek-outline.js +304 -0
- package/dist/server/state.js +117 -0
- package/dist/server/title-from-body.js +125 -0
- package/dist/server/workspaces.js +23 -0
- package/dist/server/ws.js +136 -6
- package/package.json +1 -1
- package/skill/SKILL.md +73 -36
- package/skill/agents/openwriter-enrichment-minion.md +7 -0
- package/skill/docs/setup.md +62 -0
- package/dist/client/assets/index-Ch3Z898_.css +0 -1
package/dist/server/ws.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, isAgentStub, unmarkAgentStub, } from './state.js';
|
|
6
|
-
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
5
|
+
import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
|
|
6
|
+
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
import { canonicalizeIdentifier } from './helpers.js';
|
|
9
9
|
import { nodeTextPreview, diagLog } from './pending-overlay.js';
|
|
@@ -182,6 +182,18 @@ export function setupWebSocket(server) {
|
|
|
182
182
|
}
|
|
183
183
|
console.warn(`[WS] Broadcast external-write-conflict for ${filename}`);
|
|
184
184
|
});
|
|
185
|
+
// Auto-title applied: the save() pipeline derived a title from body
|
|
186
|
+
// content because the doc was still on a default title. Rename the
|
|
187
|
+
// file on disk if it was a temp file, then broadcast so the sidebar
|
|
188
|
+
// and active editor reflect the new title without a page reload.
|
|
189
|
+
onAutoTitleApplied((newTitle) => {
|
|
190
|
+
const promoted = promoteTempFile(newTitle);
|
|
191
|
+
if (promoted) {
|
|
192
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
|
|
193
|
+
}
|
|
194
|
+
broadcastMetadataChanged(getMetadata());
|
|
195
|
+
broadcastDocumentsChanged();
|
|
196
|
+
});
|
|
185
197
|
wss.on('connection', (ws) => {
|
|
186
198
|
clients.add(ws);
|
|
187
199
|
console.log(`[WS] Client connected (total: ${clients.size})`);
|
|
@@ -213,10 +225,13 @@ export function setupWebSocket(server) {
|
|
|
213
225
|
}));
|
|
214
226
|
// Seed the right-rail Activity tab with persisted history (newest-first).
|
|
215
227
|
// The disk log is the source of truth; the client mirrors what we send.
|
|
228
|
+
// Backfilled before send so older entries (recorded before the writing-
|
|
229
|
+
// started path threaded an explicit disk filename) pick up a filename
|
|
230
|
+
// from the headline-title lookup and become clickable.
|
|
216
231
|
// adr: adr/right-rail.md
|
|
217
232
|
ws.send(JSON.stringify({
|
|
218
233
|
type: 'activity-log',
|
|
219
|
-
entries: loadActivityTail(),
|
|
234
|
+
entries: backfillActivityFilenames(loadActivityTail()),
|
|
220
235
|
}));
|
|
221
236
|
// Rehydrate in-flight writing spinners across app refreshes
|
|
222
237
|
const pendingWritesSnapshot = getPendingWritesSnapshot();
|
|
@@ -549,7 +564,26 @@ export function broadcastAgentStatus(connected) {
|
|
|
549
564
|
let lastSyncStatus = null;
|
|
550
565
|
const pendingWrites = new Map();
|
|
551
566
|
const WRITING_TIMEOUT_MS = 60_000;
|
|
552
|
-
export function broadcastWritingStarted(title, target, key
|
|
567
|
+
export function broadcastWritingStarted(title, target, key,
|
|
568
|
+
/**
|
|
569
|
+
* Disk filename to record on the Activity-tab entry so the row links to the
|
|
570
|
+
* doc on click. Independent of `target.wsFilename` (which is the workspace
|
|
571
|
+
* anchor filename used for sidebar spinner positioning and is empty/absent
|
|
572
|
+
* for orphan docs and sidebar-action writes). Falls back to
|
|
573
|
+
* target?.wsFilename, then to `key` when it looks like a disk filename
|
|
574
|
+
* (create/declare paths already pass result.filename as the key).
|
|
575
|
+
* Filename is back-compat only — the client prefers docId.
|
|
576
|
+
* adr: adr/right-rail.md
|
|
577
|
+
*/
|
|
578
|
+
activityFilename,
|
|
579
|
+
/**
|
|
580
|
+
* Stable docId for the activity entry. Preferred over filename — the
|
|
581
|
+
* client resolves it to a current filename at click time, so renames
|
|
582
|
+
* don't break the link. Most callers can pass this from their result
|
|
583
|
+
* object; pass undefined if not yet known (sidebar-action passes the
|
|
584
|
+
* source doc's id here).
|
|
585
|
+
*/
|
|
586
|
+
activityDocId) {
|
|
553
587
|
const writeKey = key || target?.wsFilename || `write:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
554
588
|
const existing = pendingWrites.get(writeKey);
|
|
555
589
|
if (existing)
|
|
@@ -573,10 +607,13 @@ export function broadcastWritingStarted(title, target, key) {
|
|
|
573
607
|
// Right-rail Activity: each agent write produces one entry, emitted at
|
|
574
608
|
// start (target info is richest here, and the spinner-in-sidebar already
|
|
575
609
|
// signals in-progress completion). adr: adr/right-rail.md
|
|
610
|
+
const wsFn = target?.wsFilename && target.wsFilename.length > 0 ? target.wsFilename : undefined;
|
|
611
|
+
const keyAsFilename = key && key.endsWith('.md') ? key : undefined;
|
|
576
612
|
broadcastActivityEvent({
|
|
577
613
|
kind: 'writing-started',
|
|
578
614
|
headline: `Agent wrote in ${title || 'Untitled'}`,
|
|
579
|
-
|
|
615
|
+
docId: activityDocId,
|
|
616
|
+
filename: activityFilename ?? wsFn ?? keyAsFilename,
|
|
580
617
|
});
|
|
581
618
|
return writeKey;
|
|
582
619
|
}
|
|
@@ -658,12 +695,105 @@ export function broadcastActivityEvent(partial) {
|
|
|
658
695
|
* adr: adr/right-rail.md
|
|
659
696
|
*/
|
|
660
697
|
export function broadcastActivityLogSeed() {
|
|
661
|
-
const msg = JSON.stringify({ type: 'activity-log', entries: loadActivityTail() });
|
|
698
|
+
const msg = JSON.stringify({ type: 'activity-log', entries: backfillActivityFilenames(loadActivityTail()) });
|
|
662
699
|
for (const ws of clients) {
|
|
663
700
|
if (ws.readyState === WebSocket.OPEN)
|
|
664
701
|
ws.send(msg);
|
|
665
702
|
}
|
|
666
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Patch activity entries before handing the seed to the client. The disk log
|
|
706
|
+
* stays append-only (we don't rewrite it); the patch only affects the
|
|
707
|
+
* in-flight payload.
|
|
708
|
+
*
|
|
709
|
+
* Three passes per entry:
|
|
710
|
+
* 1. If `filename` is set but `docId` isn't, look up docId from the current
|
|
711
|
+
* document list (filename → docId) and stamp it on the entry.
|
|
712
|
+
* 2. If neither is set, parse the headline ("Agent wrote in <title>",
|
|
713
|
+
* "Enrichment stamped <title>") and resolve via the title index.
|
|
714
|
+
* 3. If the entry has a docId (originally or via passes 1–2), refresh the
|
|
715
|
+
* headline's title to the doc's *current* title. This fixes
|
|
716
|
+
* "Agent wrote in Untitled" rows once the doc has earned a real title,
|
|
717
|
+
* and keeps rename-stale headlines in sync. Headline is display-only —
|
|
718
|
+
* the click still resolves via docId — so rewriting in the seed is safe.
|
|
719
|
+
*
|
|
720
|
+
* The client navigates by docId (resolving to the current filename at click
|
|
721
|
+
* time, so renames don't break the link), so backfilling docId is what makes
|
|
722
|
+
* historical entries clickable; the headline refresh is the polish on top.
|
|
723
|
+
*
|
|
724
|
+
* Entries whose original title was "Untitled" AND whose docId still can't be
|
|
725
|
+
* resolved stay un-resolved — no unique target. The client renders them dim,
|
|
726
|
+
* non-clickable, with a tooltip explaining why.
|
|
727
|
+
* adr: adr/right-rail.md
|
|
728
|
+
*/
|
|
729
|
+
function backfillActivityFilenames(entries) {
|
|
730
|
+
if (entries.length === 0)
|
|
731
|
+
return entries;
|
|
732
|
+
let titleToDoc = null;
|
|
733
|
+
let filenameToDocId = null;
|
|
734
|
+
let docIdToTitle = null;
|
|
735
|
+
try {
|
|
736
|
+
const docs = listDocuments();
|
|
737
|
+
titleToDoc = new Map();
|
|
738
|
+
filenameToDocId = new Map();
|
|
739
|
+
docIdToTitle = new Map();
|
|
740
|
+
for (const d of docs) {
|
|
741
|
+
if (d.docId) {
|
|
742
|
+
filenameToDocId.set(d.filename, d.docId);
|
|
743
|
+
if (d.title)
|
|
744
|
+
docIdToTitle.set(d.docId, d.title);
|
|
745
|
+
}
|
|
746
|
+
if (d.title && !titleToDoc.has(d.title))
|
|
747
|
+
titleToDoc.set(d.title, { docId: d.docId, filename: d.filename });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
return entries;
|
|
752
|
+
}
|
|
753
|
+
const HEADLINE_PREFIXES = ['Agent wrote in ', 'Enrichment stamped '];
|
|
754
|
+
const resolveTitle = (headline) => {
|
|
755
|
+
for (const prefix of HEADLINE_PREFIXES) {
|
|
756
|
+
if (headline.startsWith(prefix))
|
|
757
|
+
return headline.slice(prefix.length);
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
};
|
|
761
|
+
const rewriteHeadline = (headline, newTitle) => {
|
|
762
|
+
for (const prefix of HEADLINE_PREFIXES) {
|
|
763
|
+
if (headline.startsWith(prefix))
|
|
764
|
+
return prefix + newTitle;
|
|
765
|
+
}
|
|
766
|
+
return headline;
|
|
767
|
+
};
|
|
768
|
+
return entries.map((e) => {
|
|
769
|
+
let next = e;
|
|
770
|
+
// Pass 1 & 2 — fill in docId if missing.
|
|
771
|
+
if (!next.docId && (next.kind === 'writing-started' || next.kind === 'enrichment' || next.kind === 'backlinks-added')) {
|
|
772
|
+
if (next.filename) {
|
|
773
|
+
const docId = filenameToDocId.get(next.filename);
|
|
774
|
+
if (docId)
|
|
775
|
+
next = { ...next, docId };
|
|
776
|
+
}
|
|
777
|
+
if (!next.docId) {
|
|
778
|
+
const title = resolveTitle(next.headline);
|
|
779
|
+
if (title && title !== 'Untitled') {
|
|
780
|
+
const hit = titleToDoc.get(title);
|
|
781
|
+
if (hit)
|
|
782
|
+
next = { ...next, docId: hit.docId, filename: next.filename ?? hit.filename };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// Pass 3 — refresh headline title from current doc state when docId is known.
|
|
787
|
+
if (next.docId) {
|
|
788
|
+
const currentTitle = docIdToTitle.get(next.docId);
|
|
789
|
+
const oldTitle = resolveTitle(next.headline);
|
|
790
|
+
if (currentTitle && oldTitle !== null && oldTitle !== currentTitle) {
|
|
791
|
+
next = { ...next, headline: rewriteHeadline(next.headline, currentTitle) };
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return next;
|
|
795
|
+
});
|
|
796
|
+
}
|
|
667
797
|
export function broadcastSyncStatus(status) {
|
|
668
798
|
lastSyncStatus = status;
|
|
669
799
|
const msg = JSON.stringify({ type: 'sync-status', ...status });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
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.14.1"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -43,7 +43,21 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
43
43
|
**If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-enrichment-minion' not found`. Tell the user once: "OpenWriter has stale docs but the enrichment minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
|
|
44
44
|
|
|
45
45
|
**If the user opts out** ("stop nagging me about enrichment for X workspace"): call `update_workspace_context` with `enrichmentDisabled: true` for that workspace. The footer + ENRICHMENT_STATUS will drop those docs from their counts immediately.
|
|
46
|
-
6. **
|
|
46
|
+
6. **Handle sort requests inline when openwriter surfaces them.** The user marks docs in the sidebar with "Request sort" when they don't know where a doc belongs and want you to file it. OpenWriter surfaces pending sorts two ways: (a) `SORT_STATUS: N docs awaiting sort` in the MCP server's session-start instructions; (b) a `⚠ N docs awaiting sort` footer on `list_documents` / `list_workspaces` / `get_workspace_structure`. **No minion.** Sorting is a judgment call (which workspace, which container, why) — handle it yourself in conversation.
|
|
47
|
+
|
|
48
|
+
**The procedure per pending doc:**
|
|
49
|
+
1. `list_pending_sorts` — returns identity + current location + any prior proposal.
|
|
50
|
+
2. `outline_doc(docId)` first to orient. If the doc has headings, the skeleton + a hit-targeted `peek_doc({ around })` is enough. Fall back to `read_pad` only when the doc has no structure or you genuinely need everything.
|
|
51
|
+
3. `get_workspace_structure` — find candidate destination containers. Look for a `purpose:` hint on containers/workspaces (strong signal — author told you what belongs there). If absent, use `browse_docs` to see what other docs in a candidate container are about.
|
|
52
|
+
4. Pick a destination. **Bias toward asking the user** when a doc could plausibly live in two places. **Never auto-execute** — every sort move needs human confirmation, either via chat ("moving Notes-on-X into Reference, good?") or via the UI accept/reject popover.
|
|
53
|
+
5. Execute. Two paths:
|
|
54
|
+
- **1–3 docs (chat flow):** discuss inline → `move_item` on confirmation → `mark_sorted({ docs: [...] })`.
|
|
55
|
+
- **Many docs (batch flow):** `propose_sort({ proposals: [...] })` writes one proposal per doc back into frontmatter. The sidebar flips each doc's badge to "proposal ready" and the user accepts/rejects via the in-menu popover — that triggers the move + mark_sorted on the backend automatically.
|
|
56
|
+
|
|
57
|
+
**Surfacing to the user:** treat sort surfacing like an inbox item, not a notification. On first surface in a session: "You've got 3 pending sorts — two obvious moves and one I want to check on." Then propose destinations and walk through them. Don't ask permission to start; just engage with the actual destination decisions.
|
|
58
|
+
|
|
59
|
+
**Skip the doc** ("not now"): `mark_sorted` it anyway with no move — clears the marker without filing. Or leave it pending if the user wants to think on it.
|
|
60
|
+
7. **Emit deep links whenever you cite a docId.** Any time you reference a specific document in chat — naming it, summarizing it, pointing the user at a beat or paragraph inside it — call `get_doc_link` and render the result using this exact presentation pattern:
|
|
47
61
|
|
|
48
62
|
**Doc level** (one link, header bold):
|
|
49
63
|
```
|
|
@@ -59,6 +73,22 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
59
73
|
```
|
|
60
74
|
|
|
61
75
|
Use the doc title as the link label for doc-level links. Use the beat label or a short description of the block for node-level bullets — never just "node" or a raw ID. When citing multiple nodes from the same doc, group them under one **Node level** header. When citing nodes across multiple docs, use a separate block per doc. The cost is one `get_doc_link` call per cited doc; the payoff is the user goes from "where is that?" to "right there" in one click.
|
|
76
|
+
8. **Orient by content first; pick by nodeId second.** Never call `peek_doc` or `get_nodes` with cold nodeIds. Node-targeting without prior content orientation is meaningless — IDs are byproducts of orientation, never the starting point. The two legitimate entry paths into a doc:
|
|
77
|
+
|
|
78
|
+
- **Content entry** — `search_docs(query, { docId })` returns matching nodes with their IDs inside the doc. Use when you know roughly what you're looking for.
|
|
79
|
+
- **Structural entry** — `outline_doc(docId)` returns the heading tree (or top-level previews if no headings). Use when you want to see what the doc IS before reading any of it.
|
|
80
|
+
|
|
81
|
+
From either entry you get nodeIds; then `peek_doc` reads windowed slices around them. Skipping the orientation step and calling `peek_doc({ node: 'abc123' })` from nowhere is a footgun — you don't know what abc123 IS or whether it's the right place to read.
|
|
82
|
+
|
|
83
|
+
**The read ladder by cost** (use the cheapest tier that answers your question):
|
|
84
|
+
1. `search_docs(query)` — workspace content search (~50 tokens per hit)
|
|
85
|
+
2. `browse_docs({ workspaceFile })` — concept-level shelf scan (~60 tokens per doc)
|
|
86
|
+
3. `outline_doc(docId)` — heading tree (~5 tokens per heading)
|
|
87
|
+
4. `search_docs(query, { docId })` — in-doc content search → matching nodeIds
|
|
88
|
+
5. `peek_doc(docId, target)` — windowed node read
|
|
89
|
+
6. `read_pad(docId)` — full body (escape hatch for "I need everything")
|
|
90
|
+
|
|
91
|
+
For a 1000-node doc, steps 1–5 cost combined are typically <2k tokens; `read_pad` would be 80k+. Use the ladder.
|
|
62
92
|
|
|
63
93
|
## Setup — Which Path?
|
|
64
94
|
|
|
@@ -84,36 +114,10 @@ Skip to [Writing Strategy](#writing-strategy) below.
|
|
|
84
114
|
|
|
85
115
|
### MCP tools are NOT available (needs setup)
|
|
86
116
|
|
|
87
|
-
The user
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
npx openwriter install-skill
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
|
|
94
|
-
|
|
95
|
-
**Fallback (if the command above fails):** Do it manually:
|
|
96
|
-
|
|
97
|
-
```bash
|
|
98
|
-
npm install -g openwriter
|
|
99
|
-
claude mcp add -s user openwriter -- openwriter --no-open
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
|
|
103
|
-
|
|
104
|
-
```json
|
|
105
|
-
{
|
|
106
|
-
"mcpServers": {
|
|
107
|
-
"openwriter": {
|
|
108
|
-
"command": "openwriter",
|
|
109
|
-
"args": ["--no-open"]
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
```
|
|
117
|
+
The user hasn't set up the MCP server yet. See `docs/setup.md` for install commands and platform-specific config (Claude Code, OpenCode, etc.).
|
|
114
118
|
|
|
115
119
|
After setup, tell the user:
|
|
116
|
-
1. Restart your Claude Code session (MCP servers load on startup)
|
|
120
|
+
1. Restart your Claude Code or OpenCode session (MCP servers load on startup)
|
|
117
121
|
2. Open http://localhost:5050 in your browser
|
|
118
122
|
|
|
119
123
|
## Document Identity: Titles vs DocIds
|
|
@@ -137,7 +141,10 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
137
141
|
| `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
|
|
138
142
|
| `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
|
|
139
143
|
| `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
|
|
140
|
-
| `get_nodes` | `nodeIds` |
|
|
144
|
+
| `get_nodes` | `nodeIds` | DEPRECATED — use `peek_doc({ nodes: [ids] })`. Alias kept for one release. |
|
|
145
|
+
| `outline_doc` | `docId`, `underHeading?`, `depth?`, `offset?`, `limit?` | Structural skeleton — heading tree by default (~5 tokens/heading). Drill into a section with `underHeading`. Block-preview fallback for docs without headings. The cheap orientation tool before any body read. |
|
|
146
|
+
| `peek_doc` | `docId`, `target` (one of: `{node}` / `{nodes}` / `{around,before,after}` / `{from,to}` / `{first}` / `{last}` / `{position,span}`) | Windowed node read once oriented. Six target shapes for different access patterns. Use this instead of `read_pad` whenever you only need part of a doc. |
|
|
147
|
+
| `search_docs` | `query`, `docId?`, `limit?` | Full-text search. Default: ranked docs across the workspace (snippets). With `docId`: matching nodes inside that doc (nodeId + type + snippet). The content-to-node bridge — pairs with `peek_doc` for the read. |
|
|
141
148
|
| `get_metadata` | — | Get frontmatter metadata for the active document |
|
|
142
149
|
| `set_metadata` | `metadata` | Update frontmatter metadata (merge, set key to null to remove) |
|
|
143
150
|
|
|
@@ -166,7 +173,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
166
173
|
| `list_workspaces` | List all workspaces with title and doc count |
|
|
167
174
|
| `create_workspace` | Create a new workspace |
|
|
168
175
|
| `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
|
|
169
|
-
| `get_workspace_structure` | Get
|
|
176
|
+
| `get_workspace_structure` | Get the workspace tree shape: containers + their IDs, docs + their filenames, workspace-level structural fields (vocab, schema, enrichment flag), plus context (characters, settings, rules). **Tree shape only** — per-doc loglines, status, tags, and stale flag are NOT here. Use this when you need a destination container (sort, move) or to understand nesting. For "what is each doc about" call `browse_docs`. |
|
|
170
177
|
| `get_item_context` | Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
|
|
171
178
|
| `update_workspace_context` | Update workspace context (characters, settings, rules) |
|
|
172
179
|
|
|
@@ -197,13 +204,23 @@ OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-h
|
|
|
197
204
|
- Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
|
|
198
205
|
- Flip to `canonical` when the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth).
|
|
199
206
|
- Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
|
|
200
|
-
- The common
|
|
207
|
+
- The common browse pattern is `browse_docs({ status: "canonical" })` — that's the trusted-shelf query.
|
|
201
208
|
|
|
202
209
|
| Tool | Key Params | Description |
|
|
203
210
|
|------|-----------|-------------|
|
|
204
211
|
| `list_dirty_docs` | `workspaceFile?` | List docs that need enrichment (never enriched OR explicitly flagged stale). Returns identity + reason only — no bodies. Optionally scoped to one workspace. Docs in opted-out workspaces (`enrichmentDisabled: true`) are excluded. |
|
|
205
212
|
| `mark_enriched` | `docs: [{docId, logline}]` | Stamp one or more docs as freshly enriched. **Strict schema** — passing `domain` / `concepts` / `docRole` / `status` fails validation. OpenWriter auto-computes baselines (`lastEnrichedAt`, `lastEnrichedCharCount`, `lastEnrichedSentences`), clears `enrichmentStale`, and retires legacy fields from frontmatter. The minion calls this once at the end of its run with the full batch. |
|
|
206
|
-
| `
|
|
213
|
+
| `browse_docs` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read concept-level frontmatter per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies, no tree shape. Pairs with `get_workspace_structure` (tree shape), `outline_doc` (skeleton), `peek_doc` (windowed read), and `read_pad` (full body) as the read ladder. Renamed from `crawl` / `browse` — both kept as DEPRECATED aliases for one release. |
|
|
214
|
+
|
|
215
|
+
### Sort Requests
|
|
216
|
+
|
|
217
|
+
User-triggered file-this-for-me marker. See firm rule 6 for the full procedure. The agent picks up pending sorts via the surfacing footer / SORT_STATUS notice and handles them inline.
|
|
218
|
+
|
|
219
|
+
| Tool | Key Params | Description |
|
|
220
|
+
|------|-----------|-------------|
|
|
221
|
+
| `list_pending_sorts` | `workspaceFile?` | List docs the user has marked for sorting. Returns identity + current location + optional `proposal` (already written by a prior pass). |
|
|
222
|
+
| `propose_sort` | `proposals: [{docId, wsFilename, containerId, reasoning}]` | Write a proposal back to one or more docs (batch flow). The sidebar flips each doc's badge to "proposal ready"; the user accepts or rejects via the in-menu popover (server applies the move on accept). |
|
|
223
|
+
| `mark_sorted` | `docs: [{docId}]` | Clear the sortRequest marker after a chat-flow move (`move_item` first) or after deciding the doc should stay where it is. Bulk-friendly. |
|
|
207
224
|
|
|
208
225
|
### Comments
|
|
209
226
|
|
|
@@ -256,7 +273,7 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
256
273
|
|
|
257
274
|
- Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
|
|
258
275
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
259
|
-
-
|
|
276
|
+
- Get fresh node IDs before editing. For **broad edits** spanning the doc, `read_pad` is the right call. For **surgical edits** where you already know the target area (from a prior `outline_doc`, `search_docs`, or deep-link click), `peek_doc` around the anchor returns just the nodes you need with current IDs — much cheaper on long docs.
|
|
260
277
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
261
278
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
262
279
|
- **`rewrite` preserves the target node's type.** Sending plain prose to rewrite a heading keeps it a heading; the same for list items and blockquotes. To intentionally change a node's type, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
|
|
@@ -367,7 +384,25 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
|
|
|
367
384
|
|
|
368
385
|
## Workflow
|
|
369
386
|
|
|
370
|
-
###
|
|
387
|
+
### Research (read-only, no edits coming)
|
|
388
|
+
|
|
389
|
+
When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not `read_pad`.
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
1. search_docs({ query: "X" }) → ranked docs across workspace
|
|
393
|
+
OR
|
|
394
|
+
browse_docs({ status: "canonical" }) → shelf-level scan of one workspace
|
|
395
|
+
2. outline_doc({ docId }) → heading skeleton (~5 tokens/heading)
|
|
396
|
+
Use underHeading to drill into one section.
|
|
397
|
+
3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
|
|
398
|
+
OR pick a heading nodeId from step 2.
|
|
399
|
+
4. peek_doc({ docId, target: { around, before, after } })
|
|
400
|
+
→ read the windowed slice
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read_pad`. Use the ladder.
|
|
404
|
+
|
|
405
|
+
### Single document (editing)
|
|
371
406
|
|
|
372
407
|
```
|
|
373
408
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
@@ -377,6 +412,8 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
|
|
|
377
412
|
5. Wait → user accepts/rejects in browser
|
|
378
413
|
```
|
|
379
414
|
|
|
415
|
+
For surgical edits (you already know the anchor nodeId from prior orientation), substitute step 2 with `peek_doc({ around: nodeId, before, after })` to grab just the relevant region's current node IDs without re-paying for the whole body.
|
|
416
|
+
|
|
380
417
|
**For tweet/article docs:** step 3 gives you the parent tweet URL (in `tweetContext.url`) and mode (`reply`/`quote`/`tweet`). Use this URL with fxtwitter to read the parent tweet for free — never search externally for it.
|
|
381
418
|
|
|
382
419
|
### Multi-document
|
|
@@ -10,6 +10,13 @@ description: |
|
|
|
10
10
|
model: haiku
|
|
11
11
|
maxTurns: 500
|
|
12
12
|
tools: mcp__openwriter__list_dirty_docs, mcp__openwriter__read_pad, mcp__openwriter__mark_enriched
|
|
13
|
+
# OpenCode compatibility
|
|
14
|
+
mode: subagent
|
|
15
|
+
steps: 500
|
|
16
|
+
permission:
|
|
17
|
+
openwriter_list_dirty_docs: allow
|
|
18
|
+
openwriter_read_pad: allow
|
|
19
|
+
openwriter_mark_enriched: allow
|
|
13
20
|
---
|
|
14
21
|
|
|
15
22
|
# OpenWriter Enrichment Minion
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# OpenWriter Setup
|
|
2
|
+
|
|
3
|
+
## Quick install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx openwriter install-skill
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
|
|
10
|
+
|
|
11
|
+
## Claude Code
|
|
12
|
+
|
|
13
|
+
**Fallback (if the command above fails):** Do it manually:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g openwriter
|
|
17
|
+
claude mcp add -s user openwriter -- openwriter --no-open
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"openwriter": {
|
|
26
|
+
"command": "openwriter",
|
|
27
|
+
"args": ["--no-open"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## OpenCode
|
|
34
|
+
|
|
35
|
+
Same binary, different config format. Add to `opencode.json` at the project root:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"$schema": "https://opencode.ai/config.json",
|
|
40
|
+
"mcp": {
|
|
41
|
+
"openwriter": {
|
|
42
|
+
"type": "local",
|
|
43
|
+
"command": ["openwriter", "--no-open"],
|
|
44
|
+
"enabled": true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
OpenCode auto-discovers the skill at `~/.claude/skills/openwriter/SKILL.md` — no copy needed.
|
|
51
|
+
|
|
52
|
+
The enrichment minion is NOT auto-discovered. Place it at one of:
|
|
53
|
+
|
|
54
|
+
- `~/.config/opencode/agents/openwriter-enrichment-minion.md` (global, all projects)
|
|
55
|
+
- `.opencode/agents/openwriter-enrichment-minion.md` (this project only, repo root)
|
|
56
|
+
|
|
57
|
+
Source file lives at `~/.claude/skills/openwriter/agents/openwriter-enrichment-minion.md` after `npx openwriter install-skill`. Copy it to one of the paths above and restart OpenCode. The filename becomes the agent name OpenCode resolves when the parent dispatches it.
|
|
58
|
+
|
|
59
|
+
## After setup
|
|
60
|
+
|
|
61
|
+
1. Restart your Claude Code or OpenCode session (MCP servers load on startup)
|
|
62
|
+
2. Open http://localhost:5050 in your browser
|