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.
- package/dist/client/assets/index-Cc-WcvZz.css +1 -0
- package/dist/client/assets/index-DCMxNd__.js +211 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +8 -5
- package/dist/server/mcp.js +81 -11
- package/dist/server/ws.js +68 -15
- package/package.json +1 -1
- package/skill/SKILL.md +33 -1
- package/skill/docs/anti-ai.md +71 -0
- package/skill/docs/voices.md +88 -0
- package/skill/voices/authority.md +102 -0
- package/skill/voices/business.md +103 -0
- package/skill/voices/logical.md +104 -0
- package/skill/voices/provocateur.md +101 -0
- package/skill/voices/storyteller.md +104 -0
- package/dist/client/assets/index-CuPYxtxy.css +0 -1
- package/dist/client/assets/index-deMuWDiP.js +0 -211
- package/dist/server/prompt-debug.js +0 -58
- package/dist/server/workspace-tags.js +0 -30
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/server/mcp.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 (
|
|
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
|
-
|
|
381
|
-
let writingTimer = null;
|
|
385
|
+
const pendingWrites = new Map();
|
|
382
386
|
const WRITING_TIMEOUT_MS = 60_000;
|
|
383
|
-
export function broadcastWritingStarted(title, target) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|