openwriter 0.12.1 → 0.13.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.
@@ -10,8 +10,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
12
  import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
13
- import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
14
- import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, isAutoAcceptActive, } from './state.js';
14
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments } from './documents.js';
15
+ import { extractForwardLinks } from './backlinks.js';
15
16
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
16
17
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
17
18
  import { findDocNode } from './workspace-tree.js';
@@ -221,8 +222,9 @@ export const TOOL_REGISTRY = [
221
222
  pendingChanges: target.pendingCount,
222
223
  lastModified: target.lastModified.toISOString(),
223
224
  };
224
- // Surface autoAccept so the agent stops waiting for review when it's on.
225
- if (target.metadata?.autoAccept === true)
225
+ // Surface effective autoAccept (doc flag OR workspace/container inherited)
226
+ // so the agent stops waiting for review when it's on.
227
+ if (isAutoAcceptActive(target.filename, target.metadata))
226
228
  status.autoAccept = true;
227
229
  const latestVersion = getUpdateInfo();
228
230
  const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
@@ -414,9 +416,10 @@ export const TOOL_REGISTRY = [
414
416
  };
415
417
  }
416
418
  // Active target (or no filename): existing flow.
417
- // Skip pending tagging when autoAccept is on for this doc content commits directly.
419
+ // Skip pending tagging when autoAccept is effectively on (doc flag or
420
+ // inherited from workspace/container) — content commits directly.
418
421
  setAgentLock(); // Block browser doc-updates during population
419
- if (getMetadata()?.autoAccept !== true) {
422
+ if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
420
423
  markAllNodesAsPending(doc, 'insert');
421
424
  }
422
425
  updateDocument(doc);
@@ -1293,6 +1296,145 @@ export const TOOL_REGISTRY = [
1293
1296
  return { content: [{ type: 'text', text: ok ? `Removed task ${id}.` : `Task ${id} not found.` }] };
1294
1297
  },
1295
1298
  },
1299
+ {
1300
+ name: 'link_to',
1301
+ description: 'Wrap anchor text in the ACTIVE doc with a doc: link pointing at another doc. Optionally target a specific paragraph (target_node_id) for paragraph-level navigation, with an optional quote for scroll-anchor fallback. The on-save backlinks pipeline then auto-updates the target doc\'s frontmatter `backlinks` field — so this single tool call creates both the forward link and the backlink. Use after writing prose to cross-reference concepts: agent writes about "territorial imperative" then calls link_to to point that phrase at the canonical concept doc.',
1302
+ schema: {
1303
+ text: z.string().describe('Anchor text in the active doc to wrap with the link. Exact substring match. First occurrence wins if the text appears multiple times.'),
1304
+ target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs).'),
1305
+ target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
1306
+ quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
1307
+ },
1308
+ handler: async ({ text, target_doc_id, target_node_id, quote }) => {
1309
+ // 1. Locate the block in the active doc that contains the anchor text
1310
+ const doc = getDocument();
1311
+ let sourceNodeId = null;
1312
+ function walk(nodes) {
1313
+ if (sourceNodeId)
1314
+ return;
1315
+ for (const node of nodes) {
1316
+ if (sourceNodeId)
1317
+ return;
1318
+ if (Array.isArray(node.content)) {
1319
+ const blockText = node.content.map((c) => c.text || '').join('');
1320
+ if (node.attrs?.id && blockText.includes(text)) {
1321
+ sourceNodeId = node.attrs.id;
1322
+ return;
1323
+ }
1324
+ walk(node.content);
1325
+ }
1326
+ }
1327
+ }
1328
+ walk(doc.content);
1329
+ if (!sourceNodeId) {
1330
+ return { content: [{ type: 'text', text: `Anchor text "${text}" not found in the active doc. Use search_docs first to locate the right doc.` }] };
1331
+ }
1332
+ // 2. Build the href in canonical doc:DOCID#NODEID?q=quote form
1333
+ let href = `doc:${target_doc_id}`;
1334
+ if (target_node_id)
1335
+ href += `#${target_node_id}`;
1336
+ if (quote)
1337
+ href += `?q=${encodeURIComponent(quote)}`;
1338
+ // 3. Apply the link mark to the matched substring via the existing text-edit pipeline
1339
+ const result = applyTextEdits(sourceNodeId, [{
1340
+ find: text,
1341
+ addMark: { type: 'link', attrs: { href } },
1342
+ }]);
1343
+ if (!result.success) {
1344
+ return { content: [{ type: 'text', text: `Failed to apply link mark: ${result.error}` }] };
1345
+ }
1346
+ save(); // triggers writeToDisk → backlinks pipeline auto-updates the target's frontmatter
1347
+ return { content: [{ type: 'text', text: `Linked "${text}" in node ${sourceNodeId} → ${href}. Target doc's backlinks frontmatter will refresh on next save.` }] };
1348
+ },
1349
+ },
1350
+ {
1351
+ name: 'search_docs',
1352
+ description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
1353
+ schema: {
1354
+ query: z.string().describe('Search query (case-insensitive substring match against title, tags, then content).'),
1355
+ limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
1356
+ },
1357
+ handler: async ({ query, limit = 10 }) => {
1358
+ const cap = Math.min(Math.max(limit, 1), 50);
1359
+ const raw = searchDocuments(query);
1360
+ // Enrich with docId by reading each result's frontmatter
1361
+ const enriched = raw.slice(0, cap).map((r) => {
1362
+ let docId = null;
1363
+ try {
1364
+ const filePath = resolveDocPath(r.filename);
1365
+ const fileRaw = readFileSync(filePath, 'utf-8');
1366
+ const fm = matter(fileRaw);
1367
+ docId = fm.data?.docId || null;
1368
+ }
1369
+ catch { /* docId stays null */ }
1370
+ return {
1371
+ docId,
1372
+ title: r.title,
1373
+ filename: r.filename,
1374
+ matchType: r.matchType,
1375
+ snippet: r.snippet,
1376
+ matchedTag: r.matchedTag,
1377
+ };
1378
+ });
1379
+ return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
1380
+ },
1381
+ },
1382
+ {
1383
+ name: 'get_graph',
1384
+ description: 'Return forward links + backlinks for a doc — the crawl primitive for cross-doc context retrieval. Forward links extracted from the doc body, backlinks read from the doc\'s frontmatter (maintained by the on-save backlinks pipeline). Optional depth walks neighbors recursively (cap 3).',
1385
+ schema: {
1386
+ docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
1387
+ depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
1388
+ },
1389
+ handler: async ({ docId, depth = 1 }) => {
1390
+ const maxDepth = Math.min(Math.max(depth, 1), 3);
1391
+ const seen = new Set();
1392
+ const nodes = [];
1393
+ function visit(id, hopsLeft) {
1394
+ if (seen.has(id) || hopsLeft < 0)
1395
+ return;
1396
+ seen.add(id);
1397
+ let target;
1398
+ try {
1399
+ target = resolveDocTarget(id);
1400
+ }
1401
+ catch {
1402
+ return;
1403
+ }
1404
+ const forward = extractForwardLinks(target.document, id);
1405
+ const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
1406
+ nodes.push({
1407
+ docId: id,
1408
+ title: target.title,
1409
+ forward: forward.map((l) => ({
1410
+ text: l.text,
1411
+ from_node: l.from_node,
1412
+ to_doc: l.to_doc,
1413
+ ...(l.to_node ? { to_node: l.to_node } : {}),
1414
+ })),
1415
+ backlinks: backlinks.map((b) => ({
1416
+ text: b.text,
1417
+ from_doc: b.from_doc,
1418
+ from_node: b.from_node,
1419
+ ...(b.to_node ? { to_node: b.to_node } : {}),
1420
+ })),
1421
+ });
1422
+ if (hopsLeft > 0) {
1423
+ const neighbors = new Set();
1424
+ for (const l of forward)
1425
+ neighbors.add(l.to_doc);
1426
+ for (const b of backlinks)
1427
+ neighbors.add(b.from_doc);
1428
+ for (const n of neighbors) {
1429
+ if (!seen.has(n))
1430
+ visit(n, hopsLeft - 1);
1431
+ }
1432
+ }
1433
+ }
1434
+ visit(docId, maxDepth - 1);
1435
+ return { content: [{ type: 'text', text: JSON.stringify(nodes) }] };
1436
+ },
1437
+ },
1296
1438
  ];
1297
1439
  /** Live MCP server instance — used to register plugin tools dynamically. */
1298
1440
  let mcpServerInstance = null;
@@ -10,6 +10,7 @@ import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js'
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
11
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
+ import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
13
14
  const DEFAULT_DOC = {
14
15
  type: 'doc',
15
16
  content: [{ type: 'paragraph', content: [] }],
@@ -733,9 +734,28 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
733
734
  }
734
735
  return processed;
735
736
  }
737
+ /**
738
+ * Effective auto-accept for a doc: true if the doc's own frontmatter has it,
739
+ * OR if any workspace/container ancestor in the workspace tree has it on.
740
+ */
741
+ export function isAutoAcceptActive(filename, metadata) {
742
+ if (metadata?.autoAccept === true)
743
+ return true;
744
+ if (!filename)
745
+ return false;
746
+ // Lazy import to avoid circular dep between state.ts and workspaces.ts
747
+ try {
748
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
749
+ const { isAutoAcceptInheritedForDoc } = require('./workspaces.js');
750
+ return isAutoAcceptInheritedForDoc(filename);
751
+ }
752
+ catch {
753
+ return false;
754
+ }
755
+ }
736
756
  /** Apply changes to the active document singleton. */
737
757
  function applyChangesToDocument(changes) {
738
- const autoAccept = state.metadata?.autoAccept === true;
758
+ const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
739
759
  const processed = applyChangesToDoc(state.document, changes, autoAccept);
740
760
  if (processed.length > 0) {
741
761
  state.lastModified = new Date();
@@ -755,7 +775,7 @@ export function applyTextEdits(nodeId, edits) {
755
775
  if (!result)
756
776
  return { success: false, error: 'No edits matched' };
757
777
  // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
758
- if (state.metadata?.autoAccept !== true) {
778
+ if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
759
779
  result.node.attrs = {
760
780
  ...result.node.attrs,
761
781
  pendingTextEdits: result.textEdits,
@@ -1015,6 +1035,16 @@ export function getPendingDocInfo() {
1015
1035
  // ============================================================================
1016
1036
  function writeToDisk() {
1017
1037
  ensureDataDir();
1038
+ // Capture old forward links BEFORE we overwrite the file — needed by the
1039
+ // backlinks engine to know which target docs to refresh when source changes.
1040
+ // Skip for external docs (they don't participate in the doc graph).
1041
+ let oldForwardLinks = [];
1042
+ if (!isExternalDoc(state.filePath) && state.docId) {
1043
+ try {
1044
+ oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
1045
+ }
1046
+ catch { /* best-effort */ }
1047
+ }
1018
1048
  let markdown;
1019
1049
  if (isExternalDoc(state.filePath)) {
1020
1050
  // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
@@ -1054,6 +1084,17 @@ function writeToDisk() {
1054
1084
  snapshotIfNeeded(state.docId, state.filePath);
1055
1085
  }
1056
1086
  catch { /* ignore */ }
1087
+ // Backlinks update: refresh target docs' backlinks frontmatter if source's
1088
+ // forward links changed. Best-effort — never blocks the save it follows.
1089
+ if (!isExternalDoc(state.filePath) && state.docId) {
1090
+ try {
1091
+ const newForwardLinks = extractForwardLinks(state.document, state.docId);
1092
+ updateBacklinksForSource(state.docId, newForwardLinks, oldForwardLinks);
1093
+ }
1094
+ catch (err) {
1095
+ console.error('[State] backlinks update failed:', err);
1096
+ }
1097
+ }
1057
1098
  }
1058
1099
  export function save() {
1059
1100
  if (!state.filePath) {
@@ -1501,9 +1542,9 @@ export function populateDocumentFile(filename, doc) {
1501
1542
  const targetPath = resolveDocPath(filename);
1502
1543
  const raw = readFileSync(targetPath, 'utf-8');
1503
1544
  const parsed = markdownToTiptap(raw);
1504
- // Skip pending tagging when the target doc has autoAccept on —
1545
+ // Skip pending tagging when the target doc effectively has autoAccept on —
1505
1546
  // content commits directly as accepted.
1506
- if (parsed.metadata?.autoAccept !== true) {
1547
+ if (!isAutoAcceptActive(filename, parsed.metadata)) {
1507
1548
  markAllNodesAsPending(doc, 'insert');
1508
1549
  }
1509
1550
  flushDocToFile(filename, doc, parsed.title, parsed.metadata);
@@ -1541,7 +1582,7 @@ export function applyChangesToFile(filename, changes) {
1541
1582
  docId = metadata.docId || '';
1542
1583
  isTemp = false;
1543
1584
  }
1544
- const autoAccept = metadata?.autoAccept === true;
1585
+ const autoAccept = isAutoAcceptActive(filename, metadata);
1545
1586
  const processed = applyChangesToDoc(doc, changes, autoAccept);
1546
1587
  if (processed.length > 0) {
1547
1588
  flushDocToFile(filename, doc, title, metadata);
@@ -1597,7 +1638,7 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1597
1638
  const result = applyTextEditsToNode(originalNode, edits);
1598
1639
  if (!result)
1599
1640
  return { success: false, error: 'No edits matched' };
1600
- const autoAccept = metadata?.autoAccept === true;
1641
+ const autoAccept = isAutoAcceptActive(filename, metadata);
1601
1642
  // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1602
1643
  // since the change commits directly.
1603
1644
  if (!autoAccept) {
@@ -4,6 +4,17 @@
4
4
  */
5
5
  import { Router } from 'express';
6
6
  import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, crossMoveContainer, promoteContainerToWorkspace, } from './workspaces.js';
7
+ import { findNode } from './workspace-tree.js';
8
+ import { deleteDocument } from './documents.js';
9
+ function collectDocFilesInSubtree(nodes, out = []) {
10
+ for (const n of nodes) {
11
+ if (n.type === 'doc')
12
+ out.push(n.file);
13
+ else if (n.type === 'container')
14
+ collectDocFilesInSubtree(n.items, out);
15
+ }
16
+ return out;
17
+ }
7
18
  export function createWorkspaceRouter(b) {
8
19
  const router = Router();
9
20
  router.get('/api/workspaces', (_req, res) => {
@@ -130,11 +141,28 @@ export function createWorkspaceRouter(b) {
130
141
  res.status(400).json({ error: err.message });
131
142
  }
132
143
  });
133
- router.delete('/api/workspaces/:filename/containers/:containerId', (req, res) => {
134
- try {
144
+ router.delete('/api/workspaces/:filename/containers/:containerId', async (req, res) => {
145
+ try {
146
+ const cascade = req.query.cascade === 'true' || req.query.cascade === '1';
147
+ let deletedDocs = 0;
148
+ if (cascade) {
149
+ // Find the container, collect all docs in its subtree, delete them from disk.
150
+ const current = getWorkspace(req.params.filename);
151
+ const found = findNode(current.root, (n) => n.type === 'container' && n.id === req.params.containerId);
152
+ if (found && found.node.type === 'container') {
153
+ const files = collectDocFilesInSubtree(found.node.items);
154
+ for (const file of files) {
155
+ try {
156
+ await deleteDocument(file);
157
+ deletedDocs++;
158
+ }
159
+ catch { /* swallow per-doc failures; keep going */ }
160
+ }
161
+ }
162
+ }
135
163
  const ws = removeContainer(req.params.filename, req.params.containerId);
136
164
  b.broadcastWorkspacesChanged();
137
- res.json(ws);
165
+ res.json({ ...ws, deletedDocs });
138
166
  }
139
167
  catch (err) {
140
168
  res.status(400).json({ error: err.message });
@@ -437,6 +437,91 @@ export function getWorkspaceAssignedFiles() {
437
437
  }
438
438
  return assigned;
439
439
  }
440
+ /**
441
+ * Walk every workspace and return true if `file` is inside one where auto-accept
442
+ * is on at the workspace level or on any ancestor container. Returns false when
443
+ * the doc isn't in any workspace or no ancestor has the flag set.
444
+ *
445
+ * A doc's own `autoAccept` frontmatter is NOT checked here — that's the caller's
446
+ * job (combined with this lookup, OR-style).
447
+ */
448
+ export function isAutoAcceptInheritedForDoc(file) {
449
+ const workspaces = listWorkspaces();
450
+ for (const info of workspaces) {
451
+ try {
452
+ const ws = readWorkspace(info.filename);
453
+ // Walk root to find the doc; collect ancestor containers along the way.
454
+ function walk(nodes, ancestors) {
455
+ for (const n of nodes) {
456
+ if (n.type === 'doc' && n.file === file) {
457
+ if (ws.autoAccept === true)
458
+ return true;
459
+ for (const c of ancestors)
460
+ if (c.autoAccept === true)
461
+ return true;
462
+ return false; // doc lives here but no ancestor flag set
463
+ }
464
+ if (n.type === 'container') {
465
+ const result = walk(n.items, [...ancestors, n]);
466
+ if (result !== null)
467
+ return result;
468
+ }
469
+ }
470
+ return null;
471
+ }
472
+ const found = walk(ws.root, []);
473
+ if (found === true)
474
+ return true;
475
+ // if found === false, doc IS in this workspace but no ancestor flag is on;
476
+ // continue scanning other workspaces (a doc could be referenced in multiple)
477
+ }
478
+ catch { /* skip corrupt manifests */ }
479
+ }
480
+ return false;
481
+ }
482
+ /** Set or clear workspace-level autoAccept. */
483
+ export function setWorkspaceAutoAccept(wsFile, enabled) {
484
+ const ws = readWorkspace(wsFile);
485
+ if (enabled)
486
+ ws.autoAccept = true;
487
+ else
488
+ delete ws.autoAccept;
489
+ writeWorkspace(wsFile, ws);
490
+ }
491
+ /** Set or clear container-level autoAccept. */
492
+ export function setContainerAutoAccept(wsFile, containerId, enabled) {
493
+ const ws = readWorkspace(wsFile);
494
+ const found = findContainer(ws.root, containerId);
495
+ if (!found)
496
+ throw new Error(`Container ${containerId} not found in ${wsFile}`);
497
+ if (enabled)
498
+ found.node.autoAccept = true;
499
+ else
500
+ delete found.node.autoAccept;
501
+ writeWorkspace(wsFile, ws);
502
+ }
503
+ /** Collect every file inside a workspace or container subtree. Used for broadcast. */
504
+ export function collectFilesInWorkspace(wsFile) {
505
+ try {
506
+ const ws = readWorkspace(wsFile);
507
+ return collectAllFiles(ws.root);
508
+ }
509
+ catch {
510
+ return [];
511
+ }
512
+ }
513
+ export function collectFilesInContainer(wsFile, containerId) {
514
+ try {
515
+ const ws = readWorkspace(wsFile);
516
+ const found = findContainer(ws.root, containerId);
517
+ if (!found)
518
+ return [];
519
+ return collectAllFiles(found.node.items);
520
+ }
521
+ catch {
522
+ return [];
523
+ }
524
+ }
440
525
  export function getWorkspaceStructure(filename) {
441
526
  return getWorkspace(filename);
442
527
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.12.1",
3
+ "version": "0.13.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.6.0"
19
+ version: "0.7.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -278,13 +278,9 @@ When creating **two or more documents together** — a tweet thread saved as sep
278
278
  - `reply` / `quote` types still require `url`
279
279
  - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
280
280
 
281
- ## Voice Frames
281
+ ## Companion Skills (optional)
282
282
 
283
- Pre-built voice postures for when the user wants a specific style but has no custom voice profile. Five frames cover the common needs: authority, provocateur, logical, storyteller, business.
284
-
285
- **Triggers** — any of the following should make you load frames: "write authoritatively", "authority voice", "contrarian take", "provocateur", "first principles", "logical/analytical essay", "tell the story", "storyteller", "business email", "high-status brevity", or an explicit frame name.
286
-
287
- **Protocol** — load `docs/voices.md` for the full selection guide and 4-step protocol. Then read the specific `voices/<frame>.md` for the rules. Apply all 6 category rules as hard constraints while drafting in the editor, and run the `docs/anti-ai.md` Tier 1 pass before leaving the output.
283
+ For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
288
284
 
289
285
  ## Workflow
290
286