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/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
- filename: target?.wsFilename,
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.23.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.11.2"
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. **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:
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 has this skill but hasn't set up the MCP server yet. One command does everything:
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` | Fetch specific nodes by ID |
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 full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), workspace-level vocab/schema, plus context (characters, settings, rules) |
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 crawl pattern is `crawl({ status: "canonical" })` — that's the trusted-shelf query.
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
- | `crawl` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies. v0.19.0 dropped `domain` / `concepts` / `docRole` filters (their fields had no authority discipline); `status` is the replacement axis for the common load-bearing-vs-working query. |
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
- - Always `read_pad` before editing to get fresh node IDs
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
- ### Single document
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