openwriter 0.26.0 → 0.27.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-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +31 -11
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +215 -78
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +49 -19
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
package/dist/server/ws.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
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';
|
|
6
|
+
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } 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';
|
|
@@ -48,6 +48,29 @@ function debouncedBroadcastDocumentsChanged() {
|
|
|
48
48
|
broadcastDocumentsChanged();
|
|
49
49
|
}, 2100);
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a document-switched payload object that includes pendingMetadata
|
|
53
|
+
* (currently a staged title rename, if any) for the active doc. Every
|
|
54
|
+
* call site that emits a document-switched message — initial connect,
|
|
55
|
+
* request-document, tweet-thread HR resync, switchDocument result —
|
|
56
|
+
* must use this so the title-bar inline diff renders consistently
|
|
57
|
+
* regardless of which path delivered the doc state.
|
|
58
|
+
* adr: adr/pending-overlay-model.md
|
|
59
|
+
*/
|
|
60
|
+
function buildDocumentSwitchedPayload(document, title, filename, metadata) {
|
|
61
|
+
const docId = getDocId();
|
|
62
|
+
const pendingTitle = docId ? getPendingTitle(docId) : null;
|
|
63
|
+
const pendingMetadata = pendingTitle ? { title: { from: pendingTitle.from, to: pendingTitle.to } } : null;
|
|
64
|
+
return {
|
|
65
|
+
type: 'document-switched',
|
|
66
|
+
document,
|
|
67
|
+
title,
|
|
68
|
+
filename,
|
|
69
|
+
docId,
|
|
70
|
+
metadata,
|
|
71
|
+
pendingMetadata,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
51
74
|
export function setupWebSocket(server) {
|
|
52
75
|
const wss = new WebSocketServer({
|
|
53
76
|
server,
|
|
@@ -89,14 +112,7 @@ export function setupWebSocket(server) {
|
|
|
89
112
|
setAgentLockActive();
|
|
90
113
|
const filePath = getFilePath();
|
|
91
114
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
92
|
-
const msg = JSON.stringify(
|
|
93
|
-
type: 'document-switched',
|
|
94
|
-
document: getDocument(),
|
|
95
|
-
title: getTitle(),
|
|
96
|
-
filename,
|
|
97
|
-
docId: getDocId(),
|
|
98
|
-
metadata,
|
|
99
|
-
});
|
|
115
|
+
const msg = JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, metadata));
|
|
100
116
|
for (const ws of clients) {
|
|
101
117
|
if (ws.readyState === WebSocket.OPEN)
|
|
102
118
|
ws.send(msg);
|
|
@@ -210,14 +226,7 @@ export function setupWebSocket(server) {
|
|
|
210
226
|
const docOnConnect = getDocument();
|
|
211
227
|
const pendingOnConnect = pendingSummary(docOnConnect);
|
|
212
228
|
diagLog(`[WS] document-switched SEND on-connect docId=${getDocId()} v=${getDocVersion()} pending=[${pendingOnConnect}]`);
|
|
213
|
-
ws.send(JSON.stringify(
|
|
214
|
-
type: 'document-switched',
|
|
215
|
-
document: docOnConnect,
|
|
216
|
-
title: getTitle(),
|
|
217
|
-
filename,
|
|
218
|
-
docId: getDocId(),
|
|
219
|
-
metadata: getMetadata(),
|
|
220
|
-
}));
|
|
229
|
+
ws.send(JSON.stringify(buildDocumentSwitchedPayload(docOnConnect, getTitle(), filename, getMetadata())));
|
|
221
230
|
// Send pending docs info on connect
|
|
222
231
|
ws.send(JSON.stringify({
|
|
223
232
|
type: 'pending-docs-changed',
|
|
@@ -319,16 +328,21 @@ export function setupWebSocket(server) {
|
|
|
319
328
|
if (msg.type === 'request-document') {
|
|
320
329
|
const filePath = getFilePath();
|
|
321
330
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
322
|
-
ws.send(JSON.stringify(
|
|
323
|
-
type: 'document-switched',
|
|
324
|
-
document: getDocument(),
|
|
325
|
-
title: getTitle(),
|
|
326
|
-
filename,
|
|
327
|
-
docId: getDocId(),
|
|
328
|
-
metadata: getMetadata(),
|
|
329
|
-
}));
|
|
331
|
+
ws.send(JSON.stringify(buildDocumentSwitchedPayload(getDocument(), getTitle(), filename, getMetadata())));
|
|
330
332
|
}
|
|
331
333
|
if (msg.type === 'title-update' && msg.title) {
|
|
334
|
+
// User typed directly in the title bar — this is "the user disposing."
|
|
335
|
+
// It always lands hot. If an agent had a pending title proposal, the
|
|
336
|
+
// user's direct edit supersedes it; clear the pending entry so the
|
|
337
|
+
// diff UI dismisses. adr: adr/pending-overlay-model.md
|
|
338
|
+
const activeDocId = getDocId();
|
|
339
|
+
if (activeDocId) {
|
|
340
|
+
const existing = getPendingTitle(activeDocId);
|
|
341
|
+
if (existing) {
|
|
342
|
+
rejectPendingTitle(activeDocId);
|
|
343
|
+
broadcastPendingMetadataChanged(activeDocId, null);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
332
346
|
setMetadata({ title: msg.title });
|
|
333
347
|
const promoted = promoteTempFile(msg.title);
|
|
334
348
|
if (promoted) {
|
|
@@ -341,6 +355,36 @@ export function setupWebSocket(server) {
|
|
|
341
355
|
debouncedBroadcastDocumentsChanged();
|
|
342
356
|
}
|
|
343
357
|
}
|
|
358
|
+
if (msg.type === 'accept-pending-title' && msg.docId) {
|
|
359
|
+
try {
|
|
360
|
+
const result = acceptPendingTitle(msg.docId);
|
|
361
|
+
broadcastPendingMetadataChanged(msg.docId, null);
|
|
362
|
+
if (result) {
|
|
363
|
+
// updateDocumentTitle (inside acceptPendingTitle) re-runs
|
|
364
|
+
// setActiveDocument when the doc is active, which clears state
|
|
365
|
+
// and rebroadcasts. We also explicitly fire title-changed +
|
|
366
|
+
// documents-changed to update sidebar + title bar.
|
|
367
|
+
if (result.filename === getActiveFilename()) {
|
|
368
|
+
broadcastTitleChanged(result.to);
|
|
369
|
+
}
|
|
370
|
+
broadcastDocumentsChanged();
|
|
371
|
+
broadcastPendingDocsChanged();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error('[WS] accept-pending-title failed:', err.message);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (msg.type === 'reject-pending-title' && msg.docId) {
|
|
379
|
+
try {
|
|
380
|
+
rejectPendingTitle(msg.docId);
|
|
381
|
+
broadcastPendingMetadataChanged(msg.docId, null);
|
|
382
|
+
broadcastPendingDocsChanged();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
console.error('[WS] reject-pending-title failed:', err.message);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
344
388
|
if (msg.type === 'save') {
|
|
345
389
|
save();
|
|
346
390
|
}
|
|
@@ -491,7 +535,7 @@ export function setupWebSocket(server) {
|
|
|
491
535
|
}
|
|
492
536
|
export function broadcastDocumentSwitched(document, title, filename, metadata) {
|
|
493
537
|
const resolvedMeta = metadata ?? getMetadata();
|
|
494
|
-
const msg = JSON.stringify(
|
|
538
|
+
const msg = JSON.stringify(buildDocumentSwitchedPayload(document, title, filename, resolvedMeta));
|
|
495
539
|
for (const ws of clients) {
|
|
496
540
|
if (ws.readyState === WebSocket.OPEN) {
|
|
497
541
|
ws.send(msg);
|
|
@@ -527,6 +571,21 @@ export function broadcastTitleChanged(title) {
|
|
|
527
571
|
ws.send(msg);
|
|
528
572
|
}
|
|
529
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* Broadcast a pending-metadata change for a specific docId. The client reads
|
|
576
|
+
* this and updates the title bar / sidebar to render the proposal-in-review
|
|
577
|
+
* decoration. Passing `pendingMetadata: null` signals "the proposal cleared"
|
|
578
|
+
* (either accepted, rejected, or matched canonical).
|
|
579
|
+
*
|
|
580
|
+
* adr: adr/pending-overlay-model.md
|
|
581
|
+
*/
|
|
582
|
+
export function broadcastPendingMetadataChanged(docId, pendingMetadata) {
|
|
583
|
+
const msg = JSON.stringify({ type: 'pending-metadata-changed', docId, pendingMetadata });
|
|
584
|
+
for (const ws of clients) {
|
|
585
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
586
|
+
ws.send(msg);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
530
589
|
// Debounced: coalesces rapid agent writes into a single broadcast.
|
|
531
590
|
let pendingDocsTimer = null;
|
|
532
591
|
const PENDING_DOCS_DEBOUNCE_MS = 500;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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.16.
|
|
19
|
+
version: "0.16.3"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -73,6 +73,8 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
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
|
+
|
|
77
|
+
**The URL must come from `get_doc_link`** — it returns a real `http://...` URL. Never invent a URL scheme like `docId:abc123` or hand-construct a path; the link will be dead.
|
|
76
78
|
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
79
|
|
|
78
80
|
- **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.
|
|
@@ -147,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
147
149
|
|
|
148
150
|
| Tool | Key Params | Description |
|
|
149
151
|
|------|-----------|-------------|
|
|
150
|
-
| `read_pad` |
|
|
152
|
+
| `read_pad` | `docId`, `slice?` (`{from,to}` floats in [0,1]), `force?` (boolean) | Fixed-window word-position read. **Default:** first ~2,000 words; docs at or under the cap return in full. **`slice: {from, to}`** reads a percentile range (e.g. `{from: 0.5, to: 1}` = back half, `{from: 0.25, to: 0.75}` = middle 50%, sequential `{from: 0, to: 0.1}` → `{from: 0.1, to: 0.2}` … = 10% chunks at predictable per-call cost). Snaps to top-level node boundaries, subject to the cap unless `force` is set. **`force: true`** bypasses the cap entirely — returns the full requested region (whole doc, or whole slice). Use for full-doc audits and rewrites where you've accepted the cost. Truncated responses include `lastNodeId` + continuation hints for slice / force / peek_doc / outline_doc. |
|
|
151
153
|
| `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
|
|
152
154
|
| `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
|
|
153
155
|
| `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
|
|
@@ -283,7 +285,10 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
283
285
|
|
|
284
286
|
- Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
|
|
285
287
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
286
|
-
- Get fresh node IDs before editing.
|
|
288
|
+
- Get fresh node IDs before editing. Three patterns by edit scope:
|
|
289
|
+
- **Short doc broad edit** (≤ ~2,000 words): `read_pad({ docId })` returns the full body with all node IDs in one call.
|
|
290
|
+
- **Long doc broad edit**: either `read_pad({ docId, force: true })` for the whole body in one shot (cost acknowledged), or `read_pad({ docId, slice: {from, to} })` walking 10% chunks if the edit spans the whole doc but you want predictable per-call cost.
|
|
291
|
+
- **Surgical edit** (you already know the anchor from `outline_doc` / `search_docs` / deep-link click): `peek_doc({ around: anchor })` returns just the relevant region's current IDs — much cheaper than any read_pad form for targeted work.
|
|
287
292
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
288
293
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
289
294
|
- **`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.
|
|
@@ -396,7 +401,7 @@ For voice-matched drafting without a custom voice profile, install **voice-prese
|
|
|
396
401
|
|
|
397
402
|
### Research (read-only, no edits coming)
|
|
398
403
|
|
|
399
|
-
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`.
|
|
404
|
+
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 a default `read_pad`.
|
|
400
405
|
|
|
401
406
|
```
|
|
402
407
|
1. search_docs({ query: "X" }) → ranked docs across workspace
|
|
@@ -406,29 +411,38 @@ When the user asks "find X in this doc", "what does Y argue", "show me the beat
|
|
|
406
411
|
Use underHeading to drill into one section.
|
|
407
412
|
3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
|
|
408
413
|
OR pick a heading nodeId from step 2.
|
|
409
|
-
4.
|
|
410
|
-
|
|
414
|
+
4. Read the region — pick by how wide:
|
|
415
|
+
- peek_doc({ docId, target: { around: nodeId, before, after } })
|
|
416
|
+
→ node-anchored window (small, surgical)
|
|
417
|
+
- read_pad({ docId, slice: { from: 0.4, to: 0.6 } })
|
|
418
|
+
→ percentile-anchored region (wider, contiguous)
|
|
419
|
+
- read_pad({ docId, force: true }) → whole body (you've accepted the cost)
|
|
411
420
|
```
|
|
412
421
|
|
|
413
|
-
|
|
422
|
+
**Pattern:** `search_docs` returns hits with node IDs *and* approximate doc positions. When the user wants the matched paragraph + a sentence either side, `peek_doc` around the node is right. When they want "the section that hit lives in" or "the back half of the doc that contains the hit" or "a contiguous region wider than peek's 100-node window," `read_pad` with a slice is the better call.
|
|
423
|
+
|
|
424
|
+
Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder (search + peek), ~3k tokens via search + read_pad slice for a 25% region, ~10k tokens for the full body via `read_pad force`. Match the read to the question.
|
|
414
425
|
|
|
415
426
|
### Single document (editing)
|
|
416
427
|
|
|
417
428
|
```
|
|
418
429
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
419
|
-
2. Orient on the doc:
|
|
420
|
-
- Short doc (≤ ~2,000 words): read_pad returns the full body
|
|
421
|
-
- Long doc
|
|
422
|
-
peek_doc({ around:
|
|
423
|
-
|
|
424
|
-
-
|
|
425
|
-
|
|
430
|
+
2. Orient on the doc — pick by edit shape:
|
|
431
|
+
- Short doc (≤ ~2,000 words): read_pad({ docId }) returns the full body
|
|
432
|
+
- Long doc, surgical edit (you know roughly what you're touching):
|
|
433
|
+
search_docs({ query, docId }) → peek_doc({ around: hitNodeId, before, after })
|
|
434
|
+
— fresh IDs for just the region you'll edit, ~500 tokens
|
|
435
|
+
- Long doc, broad edit on one section:
|
|
436
|
+
outline_doc({ docId }) → read_pad({ docId, slice: { from, to } })
|
|
437
|
+
where {from, to} bounds the section's percentile range
|
|
438
|
+
- Long doc, whole-body rewrite:
|
|
439
|
+
read_pad({ docId, force: true }) — explicit, cost acknowledged
|
|
426
440
|
3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
|
|
427
441
|
4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
|
|
428
442
|
5. Wait → user accepts/rejects in browser
|
|
429
443
|
```
|
|
430
444
|
|
|
431
|
-
`read_pad` always returns the doc opening up to ~2,000 words
|
|
445
|
+
`read_pad` always returns the doc opening up to ~2,000 words unless you pass `slice` or `force`. Never assume you got the whole body from a default `read_pad` — the truncation response tells you what's missing and gives you the exact slice/force/peek/outline calls to continue.
|
|
432
446
|
|
|
433
447
|
**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.
|
|
434
448
|
|
|
@@ -436,13 +450,29 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
|
|
|
436
450
|
|
|
437
451
|
```
|
|
438
452
|
1. list_documents → see all docs with title + [docId] + wordCount
|
|
439
|
-
2. For each target doc, orient
|
|
440
|
-
-
|
|
441
|
-
- Long doc:
|
|
453
|
+
2. For each target doc, orient by wordCount:
|
|
454
|
+
- ≤ ~2,000 words: read_pad({ docId }) — full body in one call
|
|
455
|
+
- Long doc, targeted: search_docs({ query, docId }) → peek_doc({ around: hit })
|
|
456
|
+
- Long doc, sectional: outline_doc({ docId }) → read_pad({ docId, slice })
|
|
457
|
+
- Long doc, full pass: read_pad({ docId, force: true })
|
|
442
458
|
3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
|
|
443
459
|
```
|
|
444
460
|
|
|
445
|
-
The wordCount on `list_documents` tells you up-front which docs
|
|
461
|
+
The wordCount on `list_documents` tells you up-front which docs return in full from a default `read_pad` and which will truncate — use it to plan the read shape per doc. A 500-word doc is one round trip; an 8,000-word doc is search + peek for surgical work, outline + slice for sectional work, or force for the rare whole-body case.
|
|
462
|
+
|
|
463
|
+
### Reading patterns at a glance
|
|
464
|
+
|
|
465
|
+
| Intent | Best tool |
|
|
466
|
+
|--------|-----------|
|
|
467
|
+
| "What's in this doc?" | `outline_doc({ docId })` |
|
|
468
|
+
| "Find X in this doc" | `search_docs({ query, docId })` → `peek_doc({ around: hit })` |
|
|
469
|
+
| "Read around this node I already know" | `peek_doc({ around: anchor })` |
|
|
470
|
+
| "Read this specific region of the doc" | `read_pad({ docId, slice: { from, to } })` |
|
|
471
|
+
| "Walk the whole doc in predictable chunks" | `read_pad({ docId, slice })` × N sequential calls |
|
|
472
|
+
| "Give me everything" | `read_pad({ docId, force: true })` |
|
|
473
|
+
| "What's in this doc and what's it about" | `outline_doc` + frontmatter via `get_metadata` |
|
|
474
|
+
| "Which docs in the workspace talk about X" | `search_docs({ query })` (no docId) |
|
|
475
|
+
| "Scan the shelf — concept-level only" | `browse_docs({ workspaceFile })` |
|
|
446
476
|
|
|
447
477
|
### Creating new content (two-step)
|
|
448
478
|
|