openwriter 0.23.0 → 0.25.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 +185 -56
- package/dist/server/peek-outline.js +370 -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 +89 -41
- 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.25.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.15.0"
|
|
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,24 @@ 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)` — first ~2,000 words of the body, ALWAYS truncated above the cap
|
|
90
|
+
|
|
91
|
+
`read_pad` is a fixed-window tool by contract. Docs ≤ ~2,000 words return in full. Above the cap, you get the doc opening (title + intro + first few sections — the most context-rich slice) plus a `lastNodeId` and a continuation hint pointing at `peek_doc({ around: lastNodeId, after: N })`, `outline_doc`, or `search_docs({ query, docId })`. There is no `force` flag — the cap is the contract.
|
|
92
|
+
|
|
93
|
+
**Implication for doc structure:** monolith docs (8k+ words in one file) push you up the ladder on every read. Splitting into chapters, sections, or topic-sized docs makes everything cheaper — outline_doc shows the whole shape, browse_docs returns concept-level summaries, and individual reads come back complete. The cap is friction designed to surface monoliths as the wrong unit for AI-assisted writing in this era.
|
|
62
94
|
|
|
63
95
|
## Setup — Which Path?
|
|
64
96
|
|
|
@@ -84,36 +116,10 @@ Skip to [Writing Strategy](#writing-strategy) below.
|
|
|
84
116
|
|
|
85
117
|
### MCP tools are NOT available (needs setup)
|
|
86
118
|
|
|
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
|
-
```
|
|
119
|
+
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
120
|
|
|
115
121
|
After setup, tell the user:
|
|
116
|
-
1. Restart your Claude Code session (MCP servers load on startup)
|
|
122
|
+
1. Restart your Claude Code or OpenCode session (MCP servers load on startup)
|
|
117
123
|
2. Open http://localhost:5050 in your browser
|
|
118
124
|
|
|
119
125
|
## Document Identity: Titles vs DocIds
|
|
@@ -137,7 +143,10 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
137
143
|
| `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
|
|
138
144
|
| `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
|
|
139
145
|
| `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
|
|
140
|
-
| `get_nodes` | `nodeIds` |
|
|
146
|
+
| `get_nodes` | `nodeIds` | DEPRECATED — use `peek_doc({ nodes: [ids] })`. Alias kept for one release. |
|
|
147
|
+
| `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. |
|
|
148
|
+
| `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. |
|
|
149
|
+
| `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
150
|
| `get_metadata` | — | Get frontmatter metadata for the active document |
|
|
142
151
|
| `set_metadata` | `metadata` | Update frontmatter metadata (merge, set key to null to remove) |
|
|
143
152
|
|
|
@@ -166,7 +175,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
166
175
|
| `list_workspaces` | List all workspaces with title and doc count |
|
|
167
176
|
| `create_workspace` | Create a new workspace |
|
|
168
177
|
| `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
|
|
169
|
-
| `get_workspace_structure` | Get
|
|
178
|
+
| `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
179
|
| `get_item_context` | Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
|
|
171
180
|
| `update_workspace_context` | Update workspace context (characters, settings, rules) |
|
|
172
181
|
|
|
@@ -197,13 +206,23 @@ OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-h
|
|
|
197
206
|
- Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
|
|
198
207
|
- 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
208
|
- Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
|
|
200
|
-
- The common
|
|
209
|
+
- The common browse pattern is `browse_docs({ status: "canonical" })` — that's the trusted-shelf query.
|
|
201
210
|
|
|
202
211
|
| Tool | Key Params | Description |
|
|
203
212
|
|------|-----------|-------------|
|
|
204
213
|
| `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
214
|
| `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
|
-
| `
|
|
215
|
+
| `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. |
|
|
216
|
+
|
|
217
|
+
### Sort Requests
|
|
218
|
+
|
|
219
|
+
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.
|
|
220
|
+
|
|
221
|
+
| Tool | Key Params | Description |
|
|
222
|
+
|------|-----------|-------------|
|
|
223
|
+
| `list_pending_sorts` | `workspaceFile?` | List docs the user has marked for sorting. Returns identity + current location + optional `proposal` (already written by a prior pass). |
|
|
224
|
+
| `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). |
|
|
225
|
+
| `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
226
|
|
|
208
227
|
### Comments
|
|
209
228
|
|
|
@@ -256,7 +275,7 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
256
275
|
|
|
257
276
|
- Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
|
|
258
277
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
259
|
-
-
|
|
278
|
+
- 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
279
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
261
280
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
262
281
|
- **`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,27 +386,56 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
|
|
|
367
386
|
|
|
368
387
|
## Workflow
|
|
369
388
|
|
|
370
|
-
###
|
|
389
|
+
### Research (read-only, no edits coming)
|
|
390
|
+
|
|
391
|
+
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`.
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
1. search_docs({ query: "X" }) → ranked docs across workspace
|
|
395
|
+
OR
|
|
396
|
+
browse_docs({ status: "canonical" }) → shelf-level scan of one workspace
|
|
397
|
+
2. outline_doc({ docId }) → heading skeleton (~5 tokens/heading)
|
|
398
|
+
Use underHeading to drill into one section.
|
|
399
|
+
3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
|
|
400
|
+
OR pick a heading nodeId from step 2.
|
|
401
|
+
4. peek_doc({ docId, target: { around, before, after } })
|
|
402
|
+
→ read the windowed slice
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read_pad`. Use the ladder.
|
|
406
|
+
|
|
407
|
+
### Single document (editing)
|
|
371
408
|
|
|
372
409
|
```
|
|
373
410
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
374
|
-
2.
|
|
411
|
+
2. Orient on the doc:
|
|
412
|
+
- Short doc (≤ ~2,000 words): read_pad returns the full body — node IDs included
|
|
413
|
+
- Long doc (above the cap): outline_doc({ docId }) for shape, then
|
|
414
|
+
peek_doc({ around: nodeId, before, after }) around the area you'll edit
|
|
415
|
+
(only need fresh IDs for the region you're touching)
|
|
416
|
+
- You already know the anchor (from a prior search_docs or deep-link click):
|
|
417
|
+
skip straight to peek_doc({ around: anchor }) — no full-body read needed
|
|
375
418
|
3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
|
|
376
419
|
4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
|
|
377
420
|
5. Wait → user accepts/rejects in browser
|
|
378
421
|
```
|
|
379
422
|
|
|
423
|
+
`read_pad` always returns the doc opening up to ~2,000 words. For broader work on a long doc, walk the outline + peek pages — never assume you got the whole body from one read_pad call. The truncation response includes a `lastNodeId` and continuation hint pointing at exactly which tool to call next.
|
|
424
|
+
|
|
380
425
|
**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
426
|
|
|
382
427
|
### Multi-document
|
|
383
428
|
|
|
384
429
|
```
|
|
385
|
-
1. list_documents
|
|
386
|
-
2.
|
|
387
|
-
|
|
388
|
-
|
|
430
|
+
1. list_documents → see all docs with title + [docId] + wordCount
|
|
431
|
+
2. For each target doc, orient first:
|
|
432
|
+
- Short doc: read_pad({ docId }) returns full body
|
|
433
|
+
- Long doc: outline_doc({ docId }) → peek_doc({ docId, target: {...} })
|
|
434
|
+
3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
|
|
389
435
|
```
|
|
390
436
|
|
|
437
|
+
The wordCount on `list_documents` tells you up-front which docs will return in full from `read_pad` and which will truncate. Use it to plan: a 500-word doc is one round trip; an 8,000-word doc is outline + a peek or two.
|
|
438
|
+
|
|
391
439
|
### Creating new content (two-step)
|
|
392
440
|
|
|
393
441
|
```
|
|
@@ -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
|