openwriter 0.10.0 → 0.11.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,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-deMuWDiP.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CuPYxtxy.css">
13
+ <script type="module" crossorigin src="/assets/index-DCMxNd__.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Cc-WcvZz.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -723,13 +723,15 @@ export async function startHttpServer(options = {}) {
723
723
  const docIdMatch = docContent.match(/"docId"\s*:\s*"([^"]+)"/);
724
724
  if (docIdMatch)
725
725
  sourceDocId = docIdMatch[1];
726
- // Show sidebar spinner while plugin processes
726
+ // Show sidebar spinner while plugin processes. Unique key so concurrent
727
+ // writes (e.g. declare_writes in flight) aren't cleared alongside this one.
727
728
  const spinnerTitle = label ? `${label}: ${title}` : title;
728
- broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined);
729
+ const spinnerKey = `sidebar-action:${action}:${filename}:${Date.now()}`;
730
+ broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey);
729
731
  // Intercept res.json to clear spinner when plugin handler responds
730
732
  const origJson = res.json.bind(res);
731
733
  res.json = (body) => {
732
- broadcastWritingFinished();
734
+ broadcastWritingFinished(spinnerKey);
733
735
  return origJson(body);
734
736
  };
735
737
  // Forward to plugin route: POST /api/{prefix}/sidebar-action
@@ -737,12 +739,13 @@ export async function startHttpServer(options = {}) {
737
739
  req.url = `/api/${prefix}/sidebar-action`;
738
740
  req.body = { action: actionName, filename, title, instructions, content: docContent };
739
741
  app.handle(req, res, () => {
740
- broadcastWritingFinished();
742
+ broadcastWritingFinished(spinnerKey);
741
743
  res.status(404).json({ error: `No handler registered for action "${action}"` });
742
744
  });
743
745
  }
744
746
  catch (err) {
745
- broadcastWritingFinished();
747
+ // spinnerKey is out of scope here (try body may have thrown before it
748
+ // was declared). The 60s timeout on the server entry cleans it up.
746
749
  res.status(500).json({ error: err.message });
747
750
  }
748
751
  });
@@ -306,11 +306,9 @@ export const TOOL_REGISTRY = [
306
306
  wsTarget = { wsFilename: ws.filename, containerId };
307
307
  broadcastWorkspacesChanged(); // Browser sees container structure before spinner
308
308
  }
309
- if (!empty) {
310
- broadcastWritingStarted(title || 'Untitled', wsTarget);
311
- // Yield so the browser receives and renders the spinner before heavy work
312
- await new Promise((resolve) => setTimeout(resolve, 200));
313
- }
309
+ // Track the spinner key so catch can clear exactly this entry
310
+ // (not siblings from a concurrent declare_writes).
311
+ let spinnerKey = null;
314
312
  try {
315
313
  if (empty) {
316
314
  // Immediate switch — no spinner, no populate_document needed
@@ -349,6 +347,11 @@ export const TOOL_REGISTRY = [
349
347
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
350
348
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
351
349
  }
350
+ // Broadcast spinner keyed by filename so populate_document can clear exactly
351
+ // this entry. Fires after the file exists, so documents-changed arrives with
352
+ // the real entry that the sidebar filters behind the spinner until populate.
353
+ spinnerKey = result.filename;
354
+ broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
352
355
  broadcastDocumentsChanged();
353
356
  return {
354
357
  content: [{
@@ -358,8 +361,8 @@ export const TOOL_REGISTRY = [
358
361
  };
359
362
  }
360
363
  catch (err) {
361
- if (!empty)
362
- broadcastWritingFinished();
364
+ if (spinnerKey)
365
+ broadcastWritingFinished(spinnerKey);
363
366
  throw err;
364
367
  }
365
368
  },
@@ -387,7 +390,7 @@ export const TOOL_REGISTRY = [
387
390
  doc = content;
388
391
  }
389
392
  else {
390
- broadcastWritingFinished();
393
+ broadcastWritingFinished(filename);
391
394
  return {
392
395
  content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
393
396
  };
@@ -399,7 +402,7 @@ export const TOOL_REGISTRY = [
399
402
  broadcastDocumentsChanged();
400
403
  broadcastWorkspacesChanged();
401
404
  broadcastPendingDocsChanged();
402
- broadcastWritingFinished();
405
+ broadcastWritingFinished(filename);
403
406
  return {
404
407
  content: [{
405
408
  type: 'text',
@@ -419,7 +422,7 @@ export const TOOL_REGISTRY = [
419
422
  broadcastWorkspacesChanged();
420
423
  broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
421
424
  broadcastPendingDocsChanged();
422
- broadcastWritingFinished();
425
+ broadcastWritingFinished(filename || getActiveFilename());
423
426
  const wordCount = getWordCount();
424
427
  return {
425
428
  content: [{
@@ -429,11 +432,78 @@ export const TOOL_REGISTRY = [
429
432
  };
430
433
  }
431
434
  catch (err) {
432
- broadcastWritingFinished();
435
+ broadcastWritingFinished(filename);
433
436
  throw err;
434
437
  }
435
438
  },
436
439
  },
440
+ {
441
+ name: 'declare_writes',
442
+ description: 'Declare a batch of documents to create at once. Use this when creating multiple documents in parallel (e.g. a series of blog drafts, a tweet thread saved as separate docs, newsletter variants). Each write gets its own sidebar spinner keyed to its filename — spinners persist across app refreshes and only clear when you call populate_document for that specific doc. Returns an array of { docId, filename, title }. Next step: call populate_document once per docId (in parallel is fine). For creating a single document, prefer create_document.',
443
+ schema: {
444
+ writes: z.array(z.object({
445
+ title: z.string().describe('Title for the document.'),
446
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Content type. Use "document" for plain docs.'),
447
+ workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it does not exist.'),
448
+ container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
449
+ url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
450
+ path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
451
+ })).min(1).describe('List of documents to declare (minimum 1).'),
452
+ },
453
+ handler: async ({ writes }) => {
454
+ const results = [];
455
+ let workspacesChanged = false;
456
+ const broadcastedKeys = [];
457
+ for (const w of writes) {
458
+ try {
459
+ if ((w.content_type === 'reply' || w.content_type === 'quote') && !w.url) {
460
+ results.push({ docId: '', filename: '', title: w.title, error: `content_type "${w.content_type}" requires a url parameter` });
461
+ continue;
462
+ }
463
+ let wsTarget;
464
+ if (w.workspace) {
465
+ const ws = findOrCreateWorkspace(w.workspace);
466
+ let containerId = null;
467
+ if (w.container) {
468
+ const c = findOrCreateContainer(ws.filename, w.container);
469
+ containerId = c.containerId;
470
+ }
471
+ wsTarget = { wsFilename: ws.filename, containerId };
472
+ workspacesChanged = true;
473
+ }
474
+ const typeMeta = resolveTypeMeta(w.content_type, w.url);
475
+ const result = createDocumentFile(w.title, w.path, typeMeta);
476
+ if (wsTarget) {
477
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
478
+ }
479
+ broadcastWritingStarted(w.title, wsTarget, result.filename);
480
+ broadcastedKeys.push(result.filename);
481
+ results.push({ docId: result.docId, filename: result.filename, title: result.title });
482
+ }
483
+ catch (err) {
484
+ results.push({ docId: '', filename: '', title: w.title, error: err.message });
485
+ }
486
+ }
487
+ broadcastDocumentsChanged();
488
+ if (workspacesChanged)
489
+ broadcastWorkspacesChanged();
490
+ const successes = results.filter((r) => !r.error);
491
+ const failures = results.filter((r) => r.error);
492
+ const lines = [
493
+ `Declared ${successes.length} write${successes.length === 1 ? '' : 's'}${failures.length ? ` (${failures.length} failed)` : ''}:`,
494
+ ...successes.map((r) => ` "${r.title}" [${r.docId}] → ${r.filename}`),
495
+ ];
496
+ if (failures.length) {
497
+ lines.push('', 'Errors:');
498
+ for (const r of failures)
499
+ lines.push(` "${r.title}" — ${r.error}`);
500
+ }
501
+ if (successes.length) {
502
+ lines.push('', 'Next: call populate_document once per docId to fill in content.');
503
+ }
504
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
505
+ },
506
+ },
437
507
  {
438
508
  name: 'open_file',
439
509
  description: 'Open an existing .md file from any location on disk. Saves the current document first, then loads the file and sets it as active. The file appears in the sidebar and edits save back to the original path.',
package/dist/server/ws.js CHANGED
@@ -116,6 +116,11 @@ export function setupWebSocket(server) {
116
116
  type: 'pending-docs-changed',
117
117
  pendingDocs: getPendingDocInfo(),
118
118
  }));
119
+ // Rehydrate in-flight writing spinners across app refreshes
120
+ const pendingWritesSnapshot = getPendingWritesSnapshot();
121
+ if (pendingWritesSnapshot.length > 0) {
122
+ ws.send(JSON.stringify({ type: 'pending-writes-sync', writes: pendingWritesSnapshot }));
123
+ }
119
124
  ws.on('message', async (data) => {
120
125
  try {
121
126
  const msg = JSON.parse(data.toString());
@@ -377,32 +382,80 @@ export function broadcastAgentStatus(connected) {
377
382
  }
378
383
  }
379
384
  let lastSyncStatus = null;
380
- // Safety net: auto-clear spinner if writing-finished never arrives
381
- let writingTimer = null;
385
+ const pendingWrites = new Map();
382
386
  const WRITING_TIMEOUT_MS = 60_000;
383
- export function broadcastWritingStarted(title, target) {
384
- if (writingTimer)
385
- clearTimeout(writingTimer);
386
- writingTimer = setTimeout(() => {
387
- console.log('[WS] Writing spinner timed out — auto-clearing');
388
- broadcastWritingFinished();
387
+ export function broadcastWritingStarted(title, target, key) {
388
+ const writeKey = key || target?.wsFilename || `write:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
389
+ const existing = pendingWrites.get(writeKey);
390
+ if (existing)
391
+ clearTimeout(existing.timer);
392
+ const timer = setTimeout(() => {
393
+ console.log(`[WS] Writing spinner timed out for ${writeKey} — auto-clearing`);
394
+ broadcastWritingFinished(writeKey);
389
395
  }, WRITING_TIMEOUT_MS);
390
- const msg = JSON.stringify({ type: 'writing-started', title, target: target || null });
396
+ pendingWrites.set(writeKey, {
397
+ key: writeKey,
398
+ title,
399
+ target: target || null,
400
+ startedAt: Date.now(),
401
+ timer,
402
+ });
403
+ const msg = JSON.stringify({ type: 'writing-started', title, target: target || null, key: writeKey });
391
404
  for (const ws of clients) {
392
405
  if (ws.readyState === WebSocket.OPEN)
393
406
  ws.send(msg);
394
407
  }
408
+ return writeKey;
395
409
  }
396
- export function broadcastWritingFinished() {
397
- if (writingTimer) {
398
- clearTimeout(writingTimer);
399
- writingTimer = null;
410
+ // key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
411
+ export function broadcastWritingFinished(key) {
412
+ if (key) {
413
+ const entry = pendingWrites.get(key);
414
+ if (entry) {
415
+ clearTimeout(entry.timer);
416
+ pendingWrites.delete(key);
417
+ }
418
+ }
419
+ else {
420
+ for (const entry of pendingWrites.values())
421
+ clearTimeout(entry.timer);
422
+ pendingWrites.clear();
400
423
  }
401
- const msg = JSON.stringify({ type: 'writing-finished' });
424
+ // Always send writing-finished with the key so the client can drop it from
425
+ // its pending set. Then, if siblings remain, re-surface the latest with a
426
+ // writing-started so the spinner doesn't vanish mid-batch.
427
+ const finishedMsg = JSON.stringify({ type: 'writing-finished', key: key || null });
402
428
  for (const ws of clients) {
403
429
  if (ws.readyState === WebSocket.OPEN)
404
- ws.send(msg);
430
+ ws.send(finishedMsg);
405
431
  }
432
+ if (key && pendingWrites.size > 0) {
433
+ let next = null;
434
+ for (const e of pendingWrites.values()) {
435
+ if (!next || e.startedAt > next.startedAt)
436
+ next = e;
437
+ }
438
+ if (next) {
439
+ const startedMsg = JSON.stringify({
440
+ type: 'writing-started',
441
+ title: next.title,
442
+ target: next.target,
443
+ key: next.key,
444
+ });
445
+ for (const ws of clients) {
446
+ if (ws.readyState === WebSocket.OPEN)
447
+ ws.send(startedMsg);
448
+ }
449
+ }
450
+ }
451
+ }
452
+ export function getPendingWritesSnapshot() {
453
+ return Array.from(pendingWrites.values()).map(({ key, title, target, startedAt }) => ({
454
+ key,
455
+ title,
456
+ target,
457
+ startedAt,
458
+ }));
406
459
  }
407
460
  export function broadcastMarksChanged(filename) {
408
461
  const msg = JSON.stringify({ type: 'marks-changed', filename });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.10.0",
3
+ "version": "0.11.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.4.5"
19
+ version: "0.5.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -244,6 +244,38 @@ create_document({
244
244
 
245
245
  This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
246
246
 
247
+ ### Batched Creation (multiple docs at once)
248
+
249
+ When creating **two or more documents together** — a tweet thread saved as separate docs, a series of blog drafts, newsletter variants, a workspace populated with several files — use `declare_writes` instead of looping `create_document`. It's one tool call, registers all sidebar spinners atomically, and survives app refreshes.
250
+
251
+ ```
252
+ 1. declare_writes({
253
+ writes: [
254
+ { title: "Post 1", content_type: "tweet" },
255
+ { title: "Post 2", content_type: "tweet" },
256
+ { title: "Post 3", content_type: "tweet" },
257
+ ]
258
+ })
259
+ → returns [{ docId, filename, title }, ...]
260
+
261
+ 2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
262
+ ```
263
+
264
+ **Rules:**
265
+ - Each write in the batch gets its own sidebar spinner keyed to its filename — a spinner only clears when you `populate_document` that specific `docId`
266
+ - Spinners persist across app refreshes (server-side registry)
267
+ - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`
268
+ - `reply` / `quote` types still require `url`
269
+ - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
270
+
271
+ ## Voice Frames
272
+
273
+ 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.
274
+
275
+ **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.
276
+
277
+ **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.
278
+
247
279
  ## Workflow
248
280
 
249
281
  ### Single document
@@ -0,0 +1,71 @@
1
+ # Anti-AI Detection Rules
2
+
3
+ Two tiers. Tier 1 rules are **hard rules** — fix unconditionally, no voice profile override. These patterns are so statistically associated with AI that detectors flag them regardless of context. Tier 2 rules are **voice-gated** — check against the voice profile before fixing.
4
+
5
+ ---
6
+
7
+ ## Tier 1: Hard Rules (Always Fix)
8
+
9
+ **Em-dashes — eliminate, then calibrate.** AI uses em-dashes at 5-10x human density. This is one of the strongest AI signals.
10
+ - **Default (no profile or generic voice)**: Zero em-dashes. Convert to periods, commas, or parentheses.
11
+ - **With voice profile**: Check the author's samples. If they use em-dashes, match their frequency — never exceed it. An author at 1 per 300 words gets 1 per 300 words. An author who never uses them gets zero.
12
+
13
+ **Contrastive formula — never use.** These constructions are AI fingerprints:
14
+ - "It's not X, it's Y" / "This isn't X, it's Y"
15
+ - "Rather than X, we should Y"
16
+ - "Instead of X, consider Y"
17
+ - "Not merely X, but Y"
18
+ Rewrite without the formula. State the point directly.
19
+
20
+ **Nuclear phrases — kill on sight.** These phrases are 100-900x more frequent in AI text than human text. No human writes them at this density:
21
+ - "valuable insights" (902x) · "indelible mark" (319x) · "rich tapestry" (227x)
22
+ - "crucial role in shaping" (250x) · "adds a layer of complexity" (194x)
23
+ - "a stark reminder" (151x) · "fostering a sense" (138x) · "nuanced understanding" (115x)
24
+ - "unwavering commitment" (256x) · "multifaceted nature" (92x) · "beacon of hope" (58x)
25
+ - "delve deeper" (72x) · "navigate the complex" (87x) · "transformative power" (74x)
26
+ - "shed light on" · "serves as a testament" · "underscores the importance"
27
+
28
+ **Copula avoidance — use simple verbs.** AI replaces "is" with "serves as", "has" with "boasts", "shows" with "underscores." This is a telltale pattern. Use the simple verb.
29
+
30
+ **Sycophantic filler — cut unconditionally.** "Interestingly", "It's worth noting", "Notably", "It is important to note that", "It's crucial to understand" — these are AI padding. Delete them. They add nothing.
31
+
32
+ **Contraction consistency — mix intentionally.** Uniform contraction use (100% contractions OR 100% expanded) is an AI signal. Real humans are inconsistent. Use "don't" in one sentence and "does not" three sentences later. The inconsistency IS the authenticity signal. Check the voice frame or profile for specific guidance.
33
+
34
+ **Colon density — cap usage.** AI overuses colons. Cap at 1 per 400-500 words depending on the voice frame. Business Framed and Provocateur: no colons at all. Authority: 1 per post. Long-form frames: 1 per 400-500 words.
35
+
36
+ **Register uniformity — break it.** AI maintains a single consistent register throughout. Real writing mixes formal vocabulary with casual asides, academic precision with colloquial reactions. Intentionally vary register within a piece. This is the "bidirectional sophistication" principle from empirical testing (GPTZero 100% → 3%).
37
+
38
+ ---
39
+
40
+ ## Tier 2: Voice-Gated Checks (Check Profile First)
41
+
42
+ These patterns are suspicious but may match the author's voice. Check the profile before fixing.
43
+
44
+ - **AI vocabulary**: "additionally", "furthermore", "landscape", "tapestry", "interplay", "pivotal", "delve", "paradigm", "leverage", "robust", "seamlessly" — check every word against the author's diction. If they don't use it, you can't either
45
+ - **Inflated significance**: "marking a pivotal moment", "a significant milestone" — does the author elevate this way? If not, cut it
46
+ - **Vague attribution**: "Experts argue", "Studies show" — does the author cite this way or make direct claims?
47
+ - **Formula transitions**: "Despite these challenges", "Future Outlook", "In conclusion", "Moreover", "Furthermore" — does the author use these? Check discourse rules
48
+ - **Rule of three**: Forcing ideas into triplets. Some authors do this naturally (check rhetoric rules). If not, break it
49
+ - **Elegant variation**: Cycling synonyms — "the man...the individual...the person." Use whatever the author would repeat
50
+ - **Sentence length uniformity**: AI defaults to medium-length sentences. Check your short/medium/long/very-long percentages against the author's distribution. Force variation to match
51
+ - **Too-clean structure**: AI writes perfect essay structure. Real writing has asides, interruptions, unexpected turns. Match the author's discourse patterns
52
+ - **Uniform paragraph length**: AI writes ~3-4 sentence paragraphs consistently. Match the author's paragraph rhythm from samples
53
+ - **Mid-formal default**: AI gravitates toward neutral professional register. Match the author's register exactly, even if blunt, profane, or fragmentary
54
+ - **Hedging where the author asserts**: "could potentially", "it might be argued" — if the rhetoric rules say direct claims, delete all hedging
55
+
56
+ ---
57
+
58
+ ## Final Check
59
+
60
+ Re-read the complete output:
61
+ 1. Count em-dashes. No profile: should be zero. With profile: does the count match the author's observed frequency? Convert any excess
62
+ 2. Scan for any contrastive formula. Rewrite if found
63
+ 3. Grep for nuclear phrases. Kill any survivors
64
+ 4. Check contraction consistency. Are contractions mixed inconsistently (not 100% one way)?
65
+ 5. Count colons. Within the frame's limit?
66
+ 6. Check register variation. Is the tone monotonously consistent, or does it mix naturally?
67
+ 7. Scan for copula inflation ("serves as", "boasts", "underscores"). Simplify to plain verbs
68
+ 8. Would a reader who knows this author believe they wrote this?
69
+ 9. Does any sentence sound like "AI writing" rather than this specific person?
70
+
71
+ If anything fails, rewrite that section. Don't patch — rewrite using the samples as reference.
@@ -0,0 +1,88 @@
1
+ # Voice Frames
2
+
3
+ Pre-built voice postures the agent applies as behavioral constraints while
4
+ writing in OpenWriter. Each frame is a distinct **communication posture** —
5
+ not a register or tone — with its own strategy, diction, syntax, and discourse
6
+ pattern. No API keys, no network calls, no retrieval. The agent reads a `.md`
7
+ file from `voices/` and applies the rules.
8
+
9
+ Use frames when:
10
+ - The user asks for a specific posture ("authority voice", "contrarian take", "business email", "tell the story")
11
+ - They want a voice-matched draft but don't have a custom profile
12
+ - Quick tasks where configuring anything heavier isn't worth it
13
+
14
+ ## The Five Frames
15
+
16
+ ### Short-form (social media, threads, posts)
17
+
18
+ **Authority** (`voices/authority.md`) — teaches from experience. Credibility via specificity. First-person experiential language, sentence distribution skewed short.
19
+
20
+ **Provocateur** (`voices/provocateur.md`) — contrarian engagement. Opens with a claim that contradicts audience beliefs. Sharp verbs, hard lines.
21
+
22
+ ### Long-form (essays, blog posts, articles)
23
+
24
+ **Logical** (`voices/logical.md`) — disassembles accepted assumptions, rebuilds from first principles. Names the conventional answer then dismantles it.
25
+
26
+ **Storyteller** (`voices/storyteller.md`) — narrative-driven. Opens with a scene, not a thesis. Real names, real stakes. Lesson emerges from the story.
27
+
28
+ ### Business communication
29
+
30
+ **Business** (`voices/business.md`) — high-status brevity. 12-word sentence ceiling. First sentence is the ask or decision. No filler.
31
+
32
+ ## Protocol
33
+
34
+ ### Step 1: Select the frame
35
+
36
+ If the user names one, use it. If not, infer:
37
+
38
+ - **Short-form** → `authority` (teaching) or `provocateur` (challenging)
39
+ - **Long-form** → `logical` (analytical) or `storyteller` (narrative)
40
+ - **Business comms** → `business`
41
+
42
+ If ambiguous, ask.
43
+
44
+ ### Step 2: Load the voice file
45
+
46
+ Read the selected `voices/<frame>.md`. Internalize all 6 categories (Diction,
47
+ Syntax, Punctuation, Rhetoric, Discourse, Idiolect) as hard constraints.
48
+
49
+ ### Step 3: Write
50
+
51
+ Apply every rule as a constraint. Match the sentence distribution targets.
52
+ Use the file's pre-resolved Tier 2 decisions without guessing.
53
+
54
+ ### Step 4: Anti-AI pass
55
+
56
+ Run `docs/anti-ai.md` Tier 1 (hard rules) against your output:
57
+ - Em-dash density (zero for frames unless the file says otherwise)
58
+ - Contrastive formula ("It's not X, it's Y")
59
+ - Nuclear phrases ("valuable insights", "delve deeper", etc.)
60
+ - Copula inflation ("serves as", "boasts", "underscores")
61
+ - Sycophantic filler ("interestingly", "it's worth noting")
62
+ - Contraction consistency, colon density, register variation
63
+
64
+ Tier 2 checks are pre-resolved in each frame file — no profile needed.
65
+
66
+ ## Voice File Schema
67
+
68
+ Every frame file follows the same format:
69
+
70
+ ```
71
+ # Frame Name
72
+ Posture + when to use.
73
+
74
+ ## Diction, Syntax, Punctuation, Rhetoric, Discourse, Idiolect
75
+ 2-5 imperative rules per category.
76
+
77
+ ## Sentence Distribution
78
+ Short/medium/long/very-long percentages.
79
+
80
+ ## Tier 2 Decisions
81
+ Pre-resolved answers for anti-AI checks (varies per frame).
82
+
83
+ ## Use-Case Constraints
84
+ What this voice is for and what it isn't.
85
+ ```
86
+
87
+ Rules are single imperative sentences. Two agents reading the same rule should
88
+ produce similar output.
@@ -0,0 +1,102 @@
1
+ # Authority Frame
2
+
3
+ Short-form voice for social media, threads, and posts. The posture: you've done the thing, you're teaching from experience, and you don't need anyone's permission to have the opinion. Credibility comes from specificity, not credentials.
4
+
5
+ ---
6
+
7
+ ## Diction
8
+
9
+ - Use first-person experiential language: "I built", "I tested", "I shipped", "I watched it fail."
10
+ - Replace theory words with evidence words: "works" not "could potentially work", "broke" not "presented challenges."
11
+ - Use numbers and proper nouns instead of vague gestures: "47 users", "Stripe", "last Tuesday" — not "many people", "a platform", "recently."
12
+ - Kill qualification words: no "somewhat", "relatively", "fairly", "quite", "arguably."
13
+ - Use the plainest word available: "use" not "leverage", "run" not "execute", "talk" not "engage."
14
+
15
+ ## Syntax
16
+
17
+ - Default sentence length: 5-12 words.
18
+ - One claim per sentence. Period. Next sentence.
19
+ - Use fragments when the fragment IS the point: "Every time." or "Not even close."
20
+ - Save your one long sentence (20+ words) for the framework or the lesson — the part the reader screenshots.
21
+ - Never stack clauses with commas. If you need a comma, you need two sentences.
22
+
23
+ ## Punctuation
24
+
25
+ - Periods do the work. Not commas, not dashes, not semicolons.
26
+ - Never use em-dashes.
27
+ - Never use semicolons.
28
+ - Use colons only to set up a list or a single payoff line.
29
+ - Question marks: one per post maximum. Use to open a thread, never mid-argument.
30
+
31
+ ## Rhetoric
32
+
33
+ - Open with the conclusion. The lesson goes first. Context is earned after the hook lands.
34
+ - Teach by showing what happened, not by explaining what should happen.
35
+ - Replace "you should" with "I did" — the reader extracts the lesson themselves.
36
+ - One idea per post. If you wrote two ideas, delete one.
37
+ - End on the strongest line, not a summary. The last sentence is what people remember.
38
+
39
+ ## Discourse
40
+
41
+ - Line breaks between thoughts. No transition words.
42
+ - Never use "However", "Furthermore", "Additionally", "Moreover."
43
+ - If two ideas connect, juxtapose them. The reader sees the connection without you narrating it.
44
+ - Threads: each post delivers one self-contained insight. No cliffhangers, no "and here's why (thread)."
45
+ - Kill throat-clearing. No "Let me tell you something" or "Here's the thing" — just say the thing.
46
+
47
+ ## Idiolect
48
+
49
+ - Write like someone who has nothing to prove and no time to waste.
50
+ - Specificity is the authority signal: "I emailed 200 founders in 3 weeks" not "I did extensive outreach."
51
+ - When you disagree, state it flat: "That's wrong." Then say why in the next sentence.
52
+ - Use "you" to address the reader, but sparingly — this voice is about what YOU (the writer) know, not what THEY should do.
53
+ - No emoji. No hashtags unless requested. Let the words carry the weight.
54
+
55
+ ---
56
+
57
+ ## Sentence Distribution
58
+
59
+ | Length | Words | Target |
60
+ |--------|-------|--------|
61
+ | Short | 1-8 | 55% |
62
+ | Medium | 9-16 | 30% |
63
+ | Long | 17-25 | 12% |
64
+ | Very long | 26+ | 3% |
65
+
66
+ Average sentence length: 9 words. Short-max boundary: 8 words. Long-min boundary: 17 words.
67
+
68
+ ---
69
+
70
+ ## Tier 2 Decisions
71
+
72
+ - **AI vocabulary** ("additionally", "furthermore", "landscape", "paradigm", "leverage"): Always cut. Authority doesn't need big words.
73
+ - **"However"**: Cut. Start the next sentence with the contrasting claim directly.
74
+ - **Inflated significance** ("pivotal moment", "game-changing"): Cut. If it's significant, the specifics prove it.
75
+ - **Vague attribution** ("Experts say", "Studies show"): Cut. Name the person or cite the number. Or just state it as your own observation.
76
+ - **Formula transitions**: Always cut. Use line breaks.
77
+ - **Rule of three**: Allowed. Triplets land hard in short-form: "Build it. Ship it. Fix it later."
78
+ - **Elegant variation**: Never. Repeat the word. Repetition is a power move in short-form.
79
+ - **Sentence length uniformity**: The 55% short target handles this. But watch for 4+ consecutive sentences of the same length.
80
+ - **Uniform paragraph length**: N/A for social media.
81
+ - **Mid-formal default**: Override toward direct and informal. Not sloppy — precise and economical.
82
+ - **Hedging**: Cut all hedging. Authority voices assert. If you're uncertain, say "I don't know" — don't hedge.
83
+ - **Copula inflation**: Never replace "is" with "serves as", "has" with "boasts", "shows" with "underscores." Authority uses the plainest verb.
84
+ - **Sycophantic filler**: Cut "Interestingly", "It's worth noting", "Notably" unconditionally. Authority doesn't editorialize.
85
+ - **Contraction mixing**: Don't use contractions 100% of the time OR 0%. Mix "don't" and "do not" inconsistently. Uniform contraction use is an AI signal.
86
+ - **Colon cap**: Maximum 1 colon per post. Use for lists or a single payoff line only.
87
+
88
+ ---
89
+
90
+ ## Use-Case Constraints
91
+
92
+ - Maximum: 300 words per post, 300 words per thread segment.
93
+ - No paragraphs longer than 2 sentences. If you wrote 3, split it.
94
+ - Thread posts are self-contained. A reader dropping into post 4 gets a complete thought.
95
+ - No emoji, no hashtags unless the user requests them.
96
+ - This voice does NOT work for: customer support, apologies, or anything requiring warmth. Use Storyteller or Business Framed for those.
97
+
98
+ ---
99
+
100
+ ## Upgrade Path
101
+
102
+ This was written with a generic authority voice. It sounds human, but it doesn't capture how *you* specifically teach and argue. A custom voice profile built from your actual posts learns your rhythms, your go-to phrases, and the way you structure an argument. Import a few writing samples at authors-voice.com to build yours.