openwriter 0.24.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/mcp.js +26 -3
- package/dist/server/peek-outline.js +66 -0
- package/package.json +1 -1
- package/skill/SKILL.md +20 -9
package/dist/server/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ import { z } from 'zod';
|
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
|
|
13
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
|
-
import { outline, peek, searchInDoc } from './peek-outline.js';
|
|
15
|
+
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
16
16
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
17
17
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions } from './documents.js';
|
|
18
18
|
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
@@ -215,16 +215,28 @@ function formatCommentsOutput({ byFile, scopeLabel }) {
|
|
|
215
215
|
}
|
|
216
216
|
return lines.join('\n');
|
|
217
217
|
}
|
|
218
|
+
/** Hard cap on words returned per read_pad call. Above this, the response
|
|
219
|
+
* is truncated at a top-level node boundary and a continuation hint is
|
|
220
|
+
* appended pointing at peek_doc / outline_doc / search_docs. The cap exists
|
|
221
|
+
* so the agent can't accidentally token-blow a 50k-word doc — read_pad's
|
|
222
|
+
* contract is "doc opening + handle to continue," not "everything."
|
|
223
|
+
* v0.25 — see CHANGELOG. */
|
|
224
|
+
const READ_PAD_MAX_WORDS = 2000;
|
|
225
|
+
/** First-truncation FYI shows once per MCP process lifetime so the agent
|
|
226
|
+
* learns the new behavior without repeating the explanation. Resets on
|
|
227
|
+
* server restart. */
|
|
228
|
+
let firstTruncationShown = false;
|
|
218
229
|
export const TOOL_REGISTRY = [
|
|
219
230
|
{
|
|
220
231
|
name: 'read_pad',
|
|
221
|
-
description:
|
|
232
|
+
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Capped at ~${READ_PAD_MAX_WORDS} words per call — for longer docs you get the opening slice plus a lastNodeId hint. Use peek_doc({ around: lastNodeId, after: N }) to continue linearly, outline_doc to jump by heading, or search_docs({ query, docId }) to find a specific passage. For docs under the cap, returns the full body as before.`,
|
|
222
233
|
schema: {
|
|
223
234
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
224
235
|
},
|
|
225
236
|
handler: async ({ docId }) => {
|
|
226
237
|
const target = resolveDocTarget(docId);
|
|
227
|
-
const
|
|
238
|
+
const trunc = truncateRead(target.document, READ_PAD_MAX_WORDS);
|
|
239
|
+
const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
|
|
228
240
|
const localCount = getCommentCount(target.filename);
|
|
229
241
|
const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
|
|
230
242
|
let hint = '';
|
|
@@ -234,6 +246,17 @@ export const TOOL_REGISTRY = [
|
|
|
234
246
|
hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
235
247
|
if (hint)
|
|
236
248
|
hint += '\n[call get_comments to review]';
|
|
249
|
+
if (trunc.truncated) {
|
|
250
|
+
if (!firstTruncationShown) {
|
|
251
|
+
hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. This notice shows once per session — future truncations skip the explanation.]`;
|
|
252
|
+
firstTruncationShown = true;
|
|
253
|
+
}
|
|
254
|
+
const anchor = trunc.lastNodeId ?? '<no-id>';
|
|
255
|
+
hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned, ${trunc.remaining.toLocaleString()} remain. Continue with:`
|
|
256
|
+
+ `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation`
|
|
257
|
+
+ `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
|
|
258
|
+
+ `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
|
|
259
|
+
}
|
|
237
260
|
return { content: [{ type: 'text', text: compact + hint }] };
|
|
238
261
|
},
|
|
239
262
|
},
|
|
@@ -258,6 +258,72 @@ function findTopLevelIndex(content, id) {
|
|
|
258
258
|
}
|
|
259
259
|
return -1;
|
|
260
260
|
}
|
|
261
|
+
/** Count whitespace-delimited tokens across every text node in `nodes`.
|
|
262
|
+
* Mirrors the convention used elsewhere in the server (extractText + split). */
|
|
263
|
+
export function countWords(nodes) {
|
|
264
|
+
function collect(node) {
|
|
265
|
+
if (node.type === 'text' && typeof node.text === 'string')
|
|
266
|
+
return node.text;
|
|
267
|
+
if (!node.content)
|
|
268
|
+
return '';
|
|
269
|
+
return node.content.map(collect).join(' ');
|
|
270
|
+
}
|
|
271
|
+
const text = nodes.map(collect).join(' ').trim();
|
|
272
|
+
if (!text)
|
|
273
|
+
return 0;
|
|
274
|
+
return text.split(/\s+/).length;
|
|
275
|
+
}
|
|
276
|
+
/** Truncate a doc at a top-level node boundary so the returned content
|
|
277
|
+
* doesn't exceed `maxWords`. Returns the original doc unchanged if it
|
|
278
|
+
* already fits. Always includes at least one top-level node so callers
|
|
279
|
+
* never receive an empty body.
|
|
280
|
+
*
|
|
281
|
+
* read_pad's contract: a fixed-window read, not a full-body read. Above
|
|
282
|
+
* the cap, the agent gets the doc opening (most context-rich slice —
|
|
283
|
+
* title, intro, first few sections) plus the lastNodeId so `peek_doc`
|
|
284
|
+
* can continue from the boundary, or `outline_doc` / `search_docs` can
|
|
285
|
+
* jump elsewhere.
|
|
286
|
+
*
|
|
287
|
+
* Node-boundary truncation (never splits a top-level block) keeps the
|
|
288
|
+
* returned slice structurally valid markdown — list items, blockquotes,
|
|
289
|
+
* and code blocks stay intact. */
|
|
290
|
+
export function truncateRead(doc, maxWords) {
|
|
291
|
+
const content = doc.content || [];
|
|
292
|
+
const totalWords = countWords(content);
|
|
293
|
+
const lastTopId = (n) => n?.attrs?.id ?? null;
|
|
294
|
+
if (totalWords <= maxWords) {
|
|
295
|
+
return {
|
|
296
|
+
doc,
|
|
297
|
+
truncated: false,
|
|
298
|
+
totalWords,
|
|
299
|
+
returnedWords: totalWords,
|
|
300
|
+
lastNodeId: lastTopId(content[content.length - 1]),
|
|
301
|
+
remaining: 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const included = [];
|
|
305
|
+
let words = 0;
|
|
306
|
+
let lastNodeId = null;
|
|
307
|
+
for (const n of content) {
|
|
308
|
+
const w = countWords([n]);
|
|
309
|
+
// Always include at least one node — even if it alone exceeds the cap,
|
|
310
|
+
// an empty body would be a worse failure mode than a slightly oversize one.
|
|
311
|
+
if (included.length > 0 && words + w > maxWords)
|
|
312
|
+
break;
|
|
313
|
+
included.push(n);
|
|
314
|
+
words += w;
|
|
315
|
+
if (n.attrs?.id)
|
|
316
|
+
lastNodeId = n.attrs.id;
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
doc: { ...doc, content: included },
|
|
320
|
+
truncated: true,
|
|
321
|
+
totalWords,
|
|
322
|
+
returnedWords: words,
|
|
323
|
+
lastNodeId,
|
|
324
|
+
remaining: totalWords - words,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
261
327
|
/** Find blocks whose text matches the query inside one doc. Returns up to
|
|
262
328
|
* `limit` matches with the matched node's ID, type, and a snippet around
|
|
263
329
|
* the hit. Case-insensitive substring match, same shape as the workspace-
|
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
|
---
|
|
@@ -86,9 +86,11 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
86
86
|
3. `outline_doc(docId)` — heading tree (~5 tokens per heading)
|
|
87
87
|
4. `search_docs(query, { docId })` — in-doc content search → matching nodeIds
|
|
88
88
|
5. `peek_doc(docId, target)` — windowed node read
|
|
89
|
-
6. `read_pad(docId)` —
|
|
89
|
+
6. `read_pad(docId)` — first ~2,000 words of the body, ALWAYS truncated above the cap
|
|
90
90
|
|
|
91
|
-
|
|
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.
|
|
92
94
|
|
|
93
95
|
## Setup — Which Path?
|
|
94
96
|
|
|
@@ -406,25 +408,34 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
|
|
|
406
408
|
|
|
407
409
|
```
|
|
408
410
|
1. get_pad_status → check pendingChanges and userSignaledReview
|
|
409
|
-
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
|
|
410
418
|
3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
|
|
411
419
|
4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
|
|
412
420
|
5. Wait → user accepts/rejects in browser
|
|
413
421
|
```
|
|
414
422
|
|
|
415
|
-
For
|
|
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.
|
|
416
424
|
|
|
417
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.
|
|
418
426
|
|
|
419
427
|
### Multi-document
|
|
420
428
|
|
|
421
429
|
```
|
|
422
|
-
1. list_documents
|
|
423
|
-
2.
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
426
435
|
```
|
|
427
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
|
+
|
|
428
439
|
### Creating new content (two-step)
|
|
429
440
|
|
|
430
441
|
```
|