openwriter 0.24.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.
@@ -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,41 @@ 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: 'Read a document by docId. Returns compact tagged-line format with [type:id] per node, inline markdown formatting. Much more token-efficient than JSON.',
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.`,
222
233
  schema: {
223
234
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
224
- },
225
- 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 }) => {
226
250
  const target = resolveDocTarget(docId);
227
- const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
251
+ const trunc = truncateRead(target.document, { maxWords: READ_PAD_MAX_WORDS, slice, force });
252
+ const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
228
253
  const localCount = getCommentCount(target.filename);
229
254
  const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
230
255
  let hint = '';
@@ -234,6 +259,32 @@ export const TOOL_REGISTRY = [
234
259
  hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
235
260
  if (hint)
236
261
  hint += '\n[call get_comments to review]';
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) {
273
+ if (!firstTruncationShown) {
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.]`;
275
+ firstTruncationShown = true;
276
+ }
277
+ const anchor = trunc.lastNodeId ?? '<no-id>';
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`
285
+ + `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
286
+ + `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
287
+ }
237
288
  return { content: [{ type: 'text', text: compact + hint }] };
238
289
  },
239
290
  },
@@ -258,6 +258,142 @@ 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
+ /** 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).
278
+ *
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.
289
+ *
290
+ * Node-boundary truncation (never splits a top-level block) keeps the
291
+ * returned slice structurally valid markdown — list items, blockquotes,
292
+ * and code blocks stay intact. */
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
+ }
304
+ const content = doc.content || [];
305
+ const totalWords = countWords(content);
306
+ const lastTopId = (n) => n?.attrs?.id ?? null;
307
+ // Empty doc — nothing to slice or truncate.
308
+ if (content.length === 0) {
309
+ return {
310
+ doc,
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,
361
+ totalWords,
362
+ returnedWords: candidateTotalWords,
363
+ firstNodeId,
364
+ lastNodeId,
365
+ remaining: 0,
366
+ slice,
367
+ forced: force,
368
+ };
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.
373
+ const included = [];
374
+ let words = 0;
375
+ let lastNodeId = null;
376
+ for (const n of candidate) {
377
+ const w = countWords([n]);
378
+ if (included.length > 0 && words + w > maxWords)
379
+ break;
380
+ included.push(n);
381
+ words += w;
382
+ if (n.attrs?.id)
383
+ lastNodeId = n.attrs.id;
384
+ }
385
+ return {
386
+ doc: { ...doc, content: included },
387
+ truncated: true,
388
+ totalWords,
389
+ returnedWords: words,
390
+ firstNodeId: lastTopId(included[0]),
391
+ lastNodeId,
392
+ remaining: candidateTotalWords - words,
393
+ slice,
394
+ forced: false,
395
+ };
396
+ }
261
397
  /** Find blocks whose text matches the query inside one doc. Returns up to
262
398
  * `limit` matches with the matched node's ID, type, and a snippet around
263
399
  * 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.24.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.14.1"
19
+ version: "0.16.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -85,10 +85,20 @@ 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)` — full body (escape hatch for "I need everything")
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
- For a 1000-node doc, steps 1–5 cost combined are typically <2k tokens; `read_pad` would be 80k+. Use the ladder.
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).
100
+
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.
92
102
 
93
103
  ## Setup — Which Path?
94
104
 
@@ -406,25 +416,34 @@ Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder vs ~10k via `read
406
416
 
407
417
  ```
408
418
  1. get_pad_status → check pendingChanges and userSignaledReview
409
- 2. read_pad → get full document with node IDs + docId
419
+ 2. Orient on the doc:
420
+ - Short doc (≤ ~2,000 words): read_pad returns the full body — node IDs included
421
+ - Long doc (above the cap): outline_doc({ docId }) for shape, then
422
+ peek_doc({ around: nodeId, before, after }) around the area you'll edit
423
+ (only need fresh IDs for the region you're touching)
424
+ - You already know the anchor (from a prior search_docs or deep-link click):
425
+ skip straight to peek_doc({ around: anchor }) — no full-body read needed
410
426
  3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
411
427
  4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
412
428
  5. Wait → user accepts/rejects in browser
413
429
  ```
414
430
 
415
- For surgical edits (you already know the anchor nodeId from prior orientation), substitute step 2 with `peek_doc({ around: nodeId, before, after })` to grab just the relevant region's current node IDs without re-paying for the whole body.
431
+ `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
432
 
417
433
  **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
434
 
419
435
  ### Multi-document
420
436
 
421
437
  ```
422
- 1. list_documents → see all docs with title + [docId]
423
- 2. read_pad({ docId: "e5f6a7b8" }) → reads that doc directly, no switch needed
424
- 3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
425
- edits go to the identified doc, no view switch needed
438
+ 1. list_documents → see all docs with title + [docId] + wordCount
439
+ 2. For each target doc, orient first:
440
+ - Short doc: read_pad({ docId }) returns full body
441
+ - Long doc: outline_doc({ docId }) → peek_doc({ docId, target: {...} })
442
+ 3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
426
443
  ```
427
444
 
445
+ 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.
446
+
428
447
  ### Creating new content (two-step)
429
448
 
430
449
  ```