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/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.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.11.2"
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. **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,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 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
- ```
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` | Fetch specific nodes by ID |
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 full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), workspace-level vocab/schema, plus context (characters, settings, rules) |
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 crawl pattern is `crawl({ status: "canonical" })` — that's the trusted-shelf query.
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
- | `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. |
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
- - Always `read_pad` before editing to get fresh node IDs
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
- ### Single document
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. read_pad → get full document with node IDs + docId
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 → see all docs with title + [docId]
386
- 2. read_pad({ docId: "e5f6a7b8" }) → reads that doc directly, no switch needed
387
- 3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
388
- edits go to the identified doc, no view switch needed
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