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.
@@ -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. 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.`,
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
- handler: async ({ docId }) => {
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
- if (trunc.truncated) {
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 — future truncations skip the explanation.]`;
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
- 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`
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
- /** 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.
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
- * 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.
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, maxWords) {
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
- if (totalWords <= maxWords) {
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: totalWords,
300
- lastNodeId: lastTopId(content[content.length - 1]),
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 content) {
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: totalWords - words,
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.25.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.15.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 of the body, ALWAYS truncated above the cap
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 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.
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