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.
@@ -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: '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. 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 compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
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.24.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.14.1"
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)` — full body (escape hatch for "I need everything")
89
+ 6. `read_pad(docId)` — first ~2,000 words of the body, ALWAYS truncated above the cap
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 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. read_pad → get full document with node IDs + docId
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 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.
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 → 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
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
  ```