openwriter 0.25.0 → 0.26.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 +36 -8
- package/dist/server/peek-outline.js +87 -17
- package/package.json +1 -1
- package/skill/SKILL.md +12 -4
package/dist/server/mcp.js
CHANGED
|
@@ -229,13 +229,26 @@ let firstTruncationShown = false;
|
|
|
229
229
|
export const TOOL_REGISTRY = [
|
|
230
230
|
{
|
|
231
231
|
name: 'read_pad',
|
|
232
|
-
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node.
|
|
232
|
+
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Default: first ~${READ_PAD_MAX_WORDS} words. Three knobs for longer docs:\n• \`slice: { from, to }\` — read a percentile range (floats in [0,1]). \`{from:0.5, to:1}\` = back half, \`{from:0.25, to:0.75}\` = middle 50%, \`{from:0.0, to:0.1}\` then \`{from:0.1, to:0.2}\` … = sequential 10% chunks. Snaps to top-level node boundaries; subject to the word cap unless force is set.\n• \`force: true\` — bypass the cap, return the full requested region (whole doc or whole slice). Use for full-doc audits/rewrites where you've accepted the cost.\n• When the cap kicks in, the response includes \`lastNodeId\` + continuation hints to \`peek_doc\` / \`outline_doc\` / \`search_docs\` / another \`read_pad\` slice.`,
|
|
233
233
|
schema: {
|
|
234
234
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
// Schemas use z.preprocess to coerce string inputs — some MCP clients
|
|
236
|
+
// serialize complex / boolean params as JSON strings rather than native
|
|
237
|
+
// types. Accepting both forms means agents work regardless of client.
|
|
238
|
+
slice: z.preprocess((v) => (typeof v === 'string' && v.trim().startsWith('{')) ? (() => { try {
|
|
239
|
+
return JSON.parse(v);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return v;
|
|
243
|
+
} })() : v, z.object({
|
|
244
|
+
from: z.number().min(0).max(1).describe('Start of the slice as a fraction of total word count (0 = beginning).'),
|
|
245
|
+
to: z.number().min(0).max(1).describe('End of the slice as a fraction of total word count (1 = end). Must be > from.'),
|
|
246
|
+
}).optional()).describe('Percentile range to read instead of the doc opening. Snaps to top-level node boundaries. Examples: { from: 0.5, to: 1 } = back half; { from: 0.25, to: 0.75 } = middle 50%.'),
|
|
247
|
+
force: z.preprocess((v) => (typeof v === 'string') ? v === 'true' : v, z.boolean().optional()).describe(`Bypass the ~${READ_PAD_MAX_WORDS}-word cap. Returns the full requested region in one call. Use for full-doc audits and rewrites where the cost is acknowledged.`),
|
|
248
|
+
},
|
|
249
|
+
handler: async ({ docId, slice, force }) => {
|
|
237
250
|
const target = resolveDocTarget(docId);
|
|
238
|
-
const trunc = truncateRead(target.document, READ_PAD_MAX_WORDS);
|
|
251
|
+
const trunc = truncateRead(target.document, { maxWords: READ_PAD_MAX_WORDS, slice, force });
|
|
239
252
|
const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
|
|
240
253
|
const localCount = getCommentCount(target.filename);
|
|
241
254
|
const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
|
|
@@ -246,14 +259,29 @@ export const TOOL_REGISTRY = [
|
|
|
246
259
|
hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
247
260
|
if (hint)
|
|
248
261
|
hint += '\n[call get_comments to review]';
|
|
249
|
-
|
|
262
|
+
// Label forced / sliced reads so the response is self-describing.
|
|
263
|
+
if (trunc.forced) {
|
|
264
|
+
const region = trunc.slice
|
|
265
|
+
? `forced slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
|
|
266
|
+
: 'forced full read';
|
|
267
|
+
hint += `\n[${region.toUpperCase()} — ${trunc.returnedWords.toLocaleString()} words returned of ${trunc.totalWords.toLocaleString()} total. Cap bypassed.]`;
|
|
268
|
+
}
|
|
269
|
+
else if (trunc.slice && !trunc.truncated) {
|
|
270
|
+
hint += `\n[SLICE ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}% — ${trunc.returnedWords.toLocaleString()} of ${trunc.totalWords.toLocaleString()} words. Adjacent slices: read_pad({ docId: "${target.docId}", slice: { from: ${trunc.slice.to.toFixed(2)}, to: ${Math.min(1, trunc.slice.to + (trunc.slice.to - trunc.slice.from)).toFixed(2)} } }) for the next region.]`;
|
|
271
|
+
}
|
|
272
|
+
else if (trunc.truncated) {
|
|
250
273
|
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
|
|
274
|
+
hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. Override per-call with force:true, or read a specific region with slice:{from,to}. This notice shows once per session.]`;
|
|
252
275
|
firstTruncationShown = true;
|
|
253
276
|
}
|
|
254
277
|
const anchor = trunc.lastNodeId ?? '<no-id>';
|
|
255
|
-
|
|
256
|
-
|
|
278
|
+
const sliceLabel = trunc.slice
|
|
279
|
+
? ` of slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
|
|
280
|
+
: '';
|
|
281
|
+
hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned${sliceLabel}, ${trunc.remaining.toLocaleString()} remain. Continue with:`
|
|
282
|
+
+ `\n read_pad({ docId: "${target.docId}", slice: { from: 0.5, to: 1 } }) — read the back half (or any percentile range)`
|
|
283
|
+
+ `\n read_pad({ docId: "${target.docId}", force: true }) — entire body, cap bypassed`
|
|
284
|
+
+ `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation by node`
|
|
257
285
|
+ `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
|
|
258
286
|
+ `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
|
|
259
287
|
}
|
|
@@ -273,41 +273,108 @@ export function countWords(nodes) {
|
|
|
273
273
|
return 0;
|
|
274
274
|
return text.split(/\s+/).length;
|
|
275
275
|
}
|
|
276
|
-
/**
|
|
277
|
-
* doesn't exceed `maxWords
|
|
278
|
-
* already fits. Always includes at least one top-level node so callers
|
|
279
|
-
* never receive an empty body.
|
|
276
|
+
/** Read a region of a doc, truncated at a top-level node boundary so the
|
|
277
|
+
* returned content doesn't exceed `maxWords` (unless `force` is set).
|
|
280
278
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
279
|
+
* Three modes:
|
|
280
|
+
* - **Default** (no slice, no force) — first N words of the doc, capped at
|
|
281
|
+
* `maxWords`. read_pad's original contract: doc opening + continuation hint.
|
|
282
|
+
* - **Slice** (`{ from, to }`) — words in the percentile range. Snaps to top-level
|
|
283
|
+
* node boundaries so blocks aren't split mid-sentence. Still subject to
|
|
284
|
+
* `maxWords` unless `force` is set; an agent walking 10% chunks of a large
|
|
285
|
+
* doc gets predictable per-call cost.
|
|
286
|
+
* - **Force** — bypass the cap. Returns the full requested region (whole doc
|
|
287
|
+
* if no slice, the whole slice if sliced). Use for full-doc audits and
|
|
288
|
+
* rewrites where the agent has explicitly accepted the cost.
|
|
286
289
|
*
|
|
287
290
|
* Node-boundary truncation (never splits a top-level block) keeps the
|
|
288
291
|
* returned slice structurally valid markdown — list items, blockquotes,
|
|
289
292
|
* and code blocks stay intact. */
|
|
290
|
-
export function truncateRead(doc,
|
|
293
|
+
export function truncateRead(doc, options = {}) {
|
|
294
|
+
const maxWords = options.maxWords ?? 2000;
|
|
295
|
+
const force = !!options.force;
|
|
296
|
+
const sliceIn = options.slice ?? null;
|
|
297
|
+
// Clamp/validate slice — clamp to [0,1], silently fix swapped or degenerate inputs.
|
|
298
|
+
let slice = null;
|
|
299
|
+
if (sliceIn) {
|
|
300
|
+
const from = Math.max(0, Math.min(1, sliceIn.from));
|
|
301
|
+
const to = Math.max(0, Math.min(1, sliceIn.to));
|
|
302
|
+
slice = from < to ? { from, to } : { from: 0, to: 1 };
|
|
303
|
+
}
|
|
291
304
|
const content = doc.content || [];
|
|
292
305
|
const totalWords = countWords(content);
|
|
293
306
|
const lastTopId = (n) => n?.attrs?.id ?? null;
|
|
294
|
-
|
|
307
|
+
// Empty doc — nothing to slice or truncate.
|
|
308
|
+
if (content.length === 0) {
|
|
295
309
|
return {
|
|
296
310
|
doc,
|
|
297
311
|
truncated: false,
|
|
312
|
+
totalWords: 0,
|
|
313
|
+
returnedWords: 0,
|
|
314
|
+
firstNodeId: null,
|
|
315
|
+
lastNodeId: null,
|
|
316
|
+
remaining: 0,
|
|
317
|
+
slice,
|
|
318
|
+
forced: force,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// Step 1: pick the candidate node range based on slice (or all nodes if no slice).
|
|
322
|
+
// Word-position-based: walk top-level nodes, snap to boundaries that contain
|
|
323
|
+
// the slice's word range. A node is included if any of its words fall inside
|
|
324
|
+
// [fromWord, toWord) — rounds outward, agent always gets whole blocks.
|
|
325
|
+
let candidateStart = 0;
|
|
326
|
+
let candidateEnd = content.length;
|
|
327
|
+
let candidateTotalWords = totalWords;
|
|
328
|
+
if (slice) {
|
|
329
|
+
const fromWord = Math.floor(slice.from * totalWords);
|
|
330
|
+
const toWord = Math.ceil(slice.to * totalWords);
|
|
331
|
+
let cum = 0;
|
|
332
|
+
let startIdx = -1;
|
|
333
|
+
let endIdx = content.length;
|
|
334
|
+
for (let i = 0; i < content.length; i++) {
|
|
335
|
+
const w = countWords([content[i]]);
|
|
336
|
+
const nodeStart = cum;
|
|
337
|
+
const nodeEnd = cum + w;
|
|
338
|
+
// Include node if its word span overlaps [fromWord, toWord).
|
|
339
|
+
if (startIdx === -1 && nodeEnd > fromWord)
|
|
340
|
+
startIdx = i;
|
|
341
|
+
if (startIdx !== -1 && nodeStart >= toWord) {
|
|
342
|
+
endIdx = i;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
cum += w;
|
|
346
|
+
}
|
|
347
|
+
if (startIdx === -1)
|
|
348
|
+
startIdx = content.length - 1;
|
|
349
|
+
candidateStart = startIdx;
|
|
350
|
+
candidateEnd = endIdx;
|
|
351
|
+
candidateTotalWords = countWords(content.slice(candidateStart, candidateEnd));
|
|
352
|
+
}
|
|
353
|
+
// Step 2: apply maxWords cap to the candidate range (unless forced).
|
|
354
|
+
const candidate = content.slice(candidateStart, candidateEnd);
|
|
355
|
+
if (force || candidateTotalWords <= maxWords) {
|
|
356
|
+
const firstNodeId = lastTopId(candidate[0]);
|
|
357
|
+
const lastNodeId = lastTopId(candidate[candidate.length - 1]);
|
|
358
|
+
return {
|
|
359
|
+
doc: { ...doc, content: candidate },
|
|
360
|
+
truncated: false,
|
|
298
361
|
totalWords,
|
|
299
|
-
returnedWords:
|
|
300
|
-
|
|
362
|
+
returnedWords: candidateTotalWords,
|
|
363
|
+
firstNodeId,
|
|
364
|
+
lastNodeId,
|
|
301
365
|
remaining: 0,
|
|
366
|
+
slice,
|
|
367
|
+
forced: force,
|
|
302
368
|
};
|
|
303
369
|
}
|
|
370
|
+
// Step 3: candidate exceeds cap — truncate from the start of the candidate
|
|
371
|
+
// range, including whole top-level blocks until adding the next would exceed
|
|
372
|
+
// the cap. Always include at least one node.
|
|
304
373
|
const included = [];
|
|
305
374
|
let words = 0;
|
|
306
375
|
let lastNodeId = null;
|
|
307
|
-
for (const n of
|
|
376
|
+
for (const n of candidate) {
|
|
308
377
|
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
378
|
if (included.length > 0 && words + w > maxWords)
|
|
312
379
|
break;
|
|
313
380
|
included.push(n);
|
|
@@ -320,8 +387,11 @@ export function truncateRead(doc, maxWords) {
|
|
|
320
387
|
truncated: true,
|
|
321
388
|
totalWords,
|
|
322
389
|
returnedWords: words,
|
|
390
|
+
firstNodeId: lastTopId(included[0]),
|
|
323
391
|
lastNodeId,
|
|
324
|
-
remaining:
|
|
392
|
+
remaining: candidateTotalWords - words,
|
|
393
|
+
slice,
|
|
394
|
+
forced: false,
|
|
325
395
|
};
|
|
326
396
|
}
|
|
327
397
|
/** Find blocks whose text matches the query inside one doc. Returns up to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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.16.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -85,10 +85,18 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
85
85
|
2. `browse_docs({ workspaceFile })` — concept-level shelf scan (~60 tokens per doc)
|
|
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
|
-
5. `peek_doc(docId, target)` — windowed node read
|
|
89
|
-
6. `read_pad(docId)` — first ~2,000 words
|
|
88
|
+
5. `peek_doc(docId, target)` — windowed node read by nodeId
|
|
89
|
+
6. `read_pad(docId, ...)` — fixed-window word-position read (default: first ~2,000 words)
|
|
90
90
|
|
|
91
|
-
`read_pad` is a fixed-window tool by
|
|
91
|
+
`read_pad` is a fixed-window tool by default but accepts two knobs for full control:
|
|
92
|
+
|
|
93
|
+
- **Default** — `read_pad({ docId })` returns the first ~2,000 words. Docs at or under the cap return in full.
|
|
94
|
+
- **Slice** — `read_pad({ docId, slice: { from: 0.5, to: 1 } })` reads a percentile range. `{from:0.5, to:1}` = back half, `{from:0.25, to:0.75}` = middle 50%, sequential `{from:0.0,to:0.1}` → `{from:0.1,to:0.2}` … = 10% chunks for whole-doc coverage at predictable per-call cost. Snaps to top-level node boundaries; subject to the cap unless `force` is set.
|
|
95
|
+
- **Force** — `read_pad({ docId, force: true })` bypasses the cap and returns the full requested region. Use for full-doc audits, rewrites, or anywhere you've explicitly accepted the cost.
|
|
96
|
+
|
|
97
|
+
Slice vs peek: peek anchors to a known nodeId (good for "read around this hit"); slice anchors to a word-position percentile (good for "give me the back half" or "walk this doc in 10% chunks"). Use the one that matches your intent — neither is strictly better.
|
|
98
|
+
|
|
99
|
+
When the cap kicks in, the response includes `lastNodeId` plus continuation hints for all four follow-up tools (read_pad slice, read_pad force, peek_doc, outline_doc).
|
|
92
100
|
|
|
93
101
|
**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.
|
|
94
102
|
|