openwriter 0.30.0 → 0.31.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-BtCWWQrZ.css → index-C-eMDCqj.css} +1 -1
- package/dist/client/assets/{index-D1naX68L.js → index-CaFrYDJP.js} +68 -68
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.js +29 -6
- package/dist/plugins/github/dist/blog-tools.js +14 -2
- package/dist/plugins/publish/dist/index.js +20 -0
- package/dist/server/documents.js +37 -5
- package/dist/server/mcp.js +6 -3
- package/dist/server/plugin-manager.js +11 -1
- package/dist/server/workspaces.js +6 -3
- package/package.json +1 -1
- package/skill/SKILL.md +16 -12
- package/skill/agents/openwriter-sort-minion.md +184 -0
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-CaFrYDJP.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C-eMDCqj.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -6,6 +6,27 @@
|
|
|
6
6
|
* Sidebar document transforms (Vary / Shrinkify / Threadify / etc.) live in
|
|
7
7
|
* @openwriter/plugin-publish — they go through the metered platform path.
|
|
8
8
|
*/
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
const PLUGIN_NAME = '@openwriter/plugin-authors-voice';
|
|
13
|
+
/**
|
|
14
|
+
* Live-read the model tier from ~/.openwriter/config.json per request.
|
|
15
|
+
* The host passes plugin config ONCE at registerRoutes and never reloads it on save, so the
|
|
16
|
+
* dropdown's value would otherwise require a server restart. Reading the file per request
|
|
17
|
+
* makes a model-picker change take effect on the very next Enhance. Falls back to the
|
|
18
|
+
* load-time value on any error.
|
|
19
|
+
*/
|
|
20
|
+
function liveModelTier(fallback) {
|
|
21
|
+
try {
|
|
22
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.openwriter', 'config.json'), 'utf8'));
|
|
23
|
+
const v = cfg?.plugins?.[PLUGIN_NAME]?.config?.model;
|
|
24
|
+
return typeof v === 'string' ? v : fallback;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
9
30
|
const plugin = {
|
|
10
31
|
name: '@openwriter/plugin-authors-voice',
|
|
11
32
|
version: '0.4.0',
|
|
@@ -33,9 +54,9 @@ const plugin = {
|
|
|
33
54
|
options: [
|
|
34
55
|
{ value: '', label: 'Default (Strongest)' },
|
|
35
56
|
{ value: 'strongest', label: 'Strongest — Claude Opus (best quality)' },
|
|
36
|
-
{ value: 'gemini-pro', label: 'Gemini Pro — flagship, strong + cheap' },
|
|
37
57
|
{ value: 'balanced', label: 'Balanced — Claude Sonnet' },
|
|
38
|
-
{ value: 'fast', label: 'Fast — Gemini Flash (
|
|
58
|
+
{ value: 'fast-plus', label: 'Fast+ — Gemini 3.5 Flash (newest, great)' },
|
|
59
|
+
{ value: 'fast', label: 'Fast — Gemini 2.5 Flash (cheapest)' },
|
|
39
60
|
],
|
|
40
61
|
},
|
|
41
62
|
},
|
|
@@ -55,12 +76,14 @@ const plugin = {
|
|
|
55
76
|
return body;
|
|
56
77
|
return { ...body, debug: true };
|
|
57
78
|
};
|
|
58
|
-
// Inject the user's model-tier pick
|
|
59
|
-
//
|
|
79
|
+
// Inject the user's model-tier pick, read LIVE per request (dropdown changes take effect
|
|
80
|
+
// immediately, no restart). Pure pass-through: the AV API validates the slug and falls
|
|
81
|
+
// back to its own default if absent/unknown. Blank → nothing injected.
|
|
60
82
|
const withModel = (body) => {
|
|
61
|
-
|
|
83
|
+
const tier = liveModelTier(modelTier);
|
|
84
|
+
if (!tier || !body || typeof body !== 'object')
|
|
62
85
|
return body;
|
|
63
|
-
return { ...body, modelTier };
|
|
86
|
+
return { ...body, modelTier: tier };
|
|
64
87
|
};
|
|
65
88
|
// Wildcard proxy for /api/voice/* routes. Pure pass-through: the AV API owns the
|
|
66
89
|
// engine choice (v1/v2) via its own AV_DEFAULT_ENGINE setting, so the plugin injects
|
|
@@ -562,6 +562,18 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
|
562
562
|
if (!fm[dateDest] && !(publishedDateDest && fm[publishedDateDest])) {
|
|
563
563
|
fm[dateDest] = new Date().toISOString().slice(0, 10);
|
|
564
564
|
}
|
|
565
|
+
// Date fields emit as UNQUOTED yaml scalars (pubDate: 2026-05-31), never
|
|
566
|
+
// quoted strings. Astro's z.date() rejects a quoted value — js-yaml parses it
|
|
567
|
+
// as a String, not a Date — which froze a live Netlify build (paybotapp.com,
|
|
568
|
+
// 2026-06-01). The unquoted form is ALSO accepted by z.coerce.date() and by
|
|
569
|
+
// Jekyll/Hugo/Next (gray-matter), so it is the universally-correct emit.
|
|
570
|
+
// adr: adr/blog-image-contract.md
|
|
571
|
+
const dateKeys = new Set([dateDest]);
|
|
572
|
+
if (publishedDateDest)
|
|
573
|
+
dateKeys.add(publishedDateDest);
|
|
574
|
+
const emitLine = (k, v) => dateKeys.has(k) && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
|
|
575
|
+
? `${k}: ${v}`
|
|
576
|
+
: `${k}: ${yamlValue(v)}`;
|
|
565
577
|
// Emit in stable order: defaults first (in their declared order),
|
|
566
578
|
// then title, then any new keys we added
|
|
567
579
|
const lines = [];
|
|
@@ -569,7 +581,7 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
|
569
581
|
if (site.frontmatter_defaults) {
|
|
570
582
|
for (const k of Object.keys(site.frontmatter_defaults)) {
|
|
571
583
|
if (k in fm) {
|
|
572
|
-
lines.push(
|
|
584
|
+
lines.push(emitLine(k, fm[k]));
|
|
573
585
|
written.add(k);
|
|
574
586
|
}
|
|
575
587
|
}
|
|
@@ -581,7 +593,7 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
|
581
593
|
for (const [k, v] of Object.entries(fm)) {
|
|
582
594
|
if (written.has(k))
|
|
583
595
|
continue;
|
|
584
|
-
lines.push(
|
|
596
|
+
lines.push(emitLine(k, v));
|
|
585
597
|
written.add(k);
|
|
586
598
|
}
|
|
587
599
|
return `---\n${lines.join('\n')}\n---\n\n`;
|
|
@@ -14,6 +14,17 @@ function extractDocId(rawContent) {
|
|
|
14
14
|
return yamlMatch[1];
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
+
/** Server-side backstop for the transform size guard. Mirrors the client-side
|
|
18
|
+
* cap in packages/openwriter/src/sidebar/transform-guard.ts (same 5000-word unit)
|
|
19
|
+
* so an oversized doc can't reach the model/publish worker even when a caller
|
|
20
|
+
* bypasses the UI guard (agent, script, stale client). */
|
|
21
|
+
const MAX_TRANSFORM_WORDS = 5000;
|
|
22
|
+
/** Word count of a doc's body. Strips a leading YAML frontmatter block so the
|
|
23
|
+
* count matches the body-only wordCount the sidebar measures client-side. */
|
|
24
|
+
function countBodyWords(rawContent) {
|
|
25
|
+
const body = rawContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
26
|
+
return (body.match(/\S+/g) || []).length;
|
|
27
|
+
}
|
|
17
28
|
/** Map transform action to variant content type */
|
|
18
29
|
const ACTION_VARIANT_TYPE = {
|
|
19
30
|
vary: 'document',
|
|
@@ -1038,6 +1049,15 @@ const plugin = {
|
|
|
1038
1049
|
res.status(400).json({ error: 'Document content is required' });
|
|
1039
1050
|
return;
|
|
1040
1051
|
}
|
|
1052
|
+
// Size backstop — mirror the client-side transform guard. Block before the
|
|
1053
|
+
// worker model call so an oversized doc can't run up unbounded cost + time,
|
|
1054
|
+
// even when a caller bypasses the UI guard.
|
|
1055
|
+
const transformWords = countBodyWords(content);
|
|
1056
|
+
if (transformWords > MAX_TRANSFORM_WORDS) {
|
|
1057
|
+
console.warn(`[Publish Plugin] Transform blocked — doc too large: ${transformWords} words (max ${MAX_TRANSFORM_WORDS})`);
|
|
1058
|
+
res.status(413).json({ error: `Document too large to transform: ${transformWords.toLocaleString()} words (max ${MAX_TRANSFORM_WORDS.toLocaleString()}).` });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1041
1061
|
// Call publish worker /transforms endpoint
|
|
1042
1062
|
const transformRes = await publishFetch(ctx.config, '/transforms', {
|
|
1043
1063
|
method: 'POST',
|
package/dist/server/documents.js
CHANGED
|
@@ -310,16 +310,22 @@ export function buildEnrichmentInstructions() {
|
|
|
310
310
|
].join('\n');
|
|
311
311
|
}
|
|
312
312
|
/** Footer on the three high-frequency discovery tools when sort requests are
|
|
313
|
-
* pending. Stacks beneath enrichmentFooter.
|
|
314
|
-
*
|
|
313
|
+
* pending. Stacks beneath enrichmentFooter. Carries the exact dispatch call
|
|
314
|
+
* so the acting agent's burden collapses to one paste — the sort minion is
|
|
315
|
+
* autonomous and self-discovers via list_pending_sorts once it boots, files
|
|
316
|
+
* each doc, and reports what moved. The earlier "handle it inline, no minion"
|
|
317
|
+
* model never drained (marks rotted for days); auto-drain replaces it.
|
|
318
|
+
* adr: adr/sort-minion-drain.md */
|
|
315
319
|
export function sortFooter() {
|
|
316
320
|
const count = listPendingSorts().length;
|
|
317
321
|
if (count === 0)
|
|
318
322
|
return '';
|
|
319
|
-
return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} awaiting sort.
|
|
323
|
+
return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} awaiting sort. Dispatch:\nAgent(\n subagent_type: "openwriter-sort-minion",\n description: "File pending sorts",\n prompt: "File pending sorts.",\n run_in_background: true\n)`;
|
|
320
324
|
}
|
|
321
325
|
/** Session-start sort notice — stacks with buildEnrichmentInstructions inside
|
|
322
|
-
* the MCP `instructions` field. Empty when no sorts pending.
|
|
326
|
+
* the MCP `instructions` field. Empty when no sorts pending. Mirrors
|
|
327
|
+
* buildEnrichmentInstructions: an executable dispatch directive, not advice.
|
|
328
|
+
* adr: adr/sort-minion-drain.md */
|
|
323
329
|
export function buildSortInstructions() {
|
|
324
330
|
const pending = listPendingSorts();
|
|
325
331
|
if (pending.length === 0)
|
|
@@ -327,7 +333,13 @@ export function buildSortInstructions() {
|
|
|
327
333
|
return [
|
|
328
334
|
'',
|
|
329
335
|
`SORT_STATUS: ${pending.length} doc${pending.length === 1 ? '' : 's'} awaiting sort.`,
|
|
330
|
-
'
|
|
336
|
+
'Dispatch the minion now — it self-discovers via list_pending_sorts, picks a destination for each doc, files it (move_item), retires the request (mark_sorted), and reports what moved:',
|
|
337
|
+
'Agent(',
|
|
338
|
+
' subagent_type: "openwriter-sort-minion",',
|
|
339
|
+
' description: "File pending sorts",',
|
|
340
|
+
' prompt: "File pending sorts.",',
|
|
341
|
+
' run_in_background: true',
|
|
342
|
+
')',
|
|
331
343
|
].join('\n');
|
|
332
344
|
}
|
|
333
345
|
/**
|
|
@@ -340,6 +352,7 @@ export function listPendingSorts(scopeWorkspace) {
|
|
|
340
352
|
ensureDataDir();
|
|
341
353
|
const ownership = buildWorkspaceOwnershipMap();
|
|
342
354
|
const containerByFile = buildContainerOwnershipMap();
|
|
355
|
+
const optedOut = collectAutoSortOptedOutFilenames();
|
|
343
356
|
let scopeFiles = null;
|
|
344
357
|
if (scopeWorkspace) {
|
|
345
358
|
try {
|
|
@@ -354,6 +367,8 @@ export function listPendingSorts(scopeWorkspace) {
|
|
|
354
367
|
for (const f of readdirSync(getDataDir()).filter((f) => f.endsWith('.md'))) {
|
|
355
368
|
if (scopeFiles && !scopeFiles.has(f))
|
|
356
369
|
continue;
|
|
370
|
+
if (optedOut.has(f))
|
|
371
|
+
continue;
|
|
357
372
|
try {
|
|
358
373
|
const raw = readFileSync(join(getDataDir(), f), 'utf-8');
|
|
359
374
|
const { data } = matter(raw);
|
|
@@ -416,6 +431,23 @@ function collectOptedOutFilenames() {
|
|
|
416
431
|
}
|
|
417
432
|
return out;
|
|
418
433
|
}
|
|
434
|
+
/** Build a Set of filenames inside workspaces with autoSortDisabled: true.
|
|
435
|
+
* These docs are excluded from list_pending_sorts, so the sort minion never
|
|
436
|
+
* auto-files them — the user handles them manually via the sidebar. */
|
|
437
|
+
function collectAutoSortOptedOutFilenames() {
|
|
438
|
+
const out = new Set();
|
|
439
|
+
for (const info of listWorkspaces()) {
|
|
440
|
+
try {
|
|
441
|
+
const ws = getWorkspace(info.filename);
|
|
442
|
+
if (ws.autoSortDisabled === true) {
|
|
443
|
+
for (const f of collectAllFiles(ws.root))
|
|
444
|
+
out.add(f);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch { /* skip corrupt manifests */ }
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
419
451
|
/** Map filename → first workspace that contains it. Used to attribute
|
|
420
452
|
* dirty-doc reports to a workspace. */
|
|
421
453
|
function buildWorkspaceOwnershipMap() {
|
package/dist/server/mcp.js
CHANGED
|
@@ -1064,7 +1064,7 @@ export const TOOL_REGISTRY = [
|
|
|
1064
1064
|
},
|
|
1065
1065
|
{
|
|
1066
1066
|
name: 'list_pending_sorts',
|
|
1067
|
-
description: 'List documents
|
|
1067
|
+
description: 'List documents the user marked for sorting via the sidebar. Each entry includes the doc identity, where it currently lives, the requestedAt timestamp, and (when present) a proposal from an earlier pass. The sort minion (openwriter-sort-minion) calls this first to know what to file. For each entry: read the body, consider workspace/container purpose hints, then move_item + mark_sorted to auto-file it. Docs in opted-out workspaces (autoSortDisabled: true) are excluded. propose_sort remains for the manual sidebar accept/reject flow.',
|
|
1068
1068
|
schema: {
|
|
1069
1069
|
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
1070
1070
|
},
|
|
@@ -1248,6 +1248,8 @@ export const TOOL_REGISTRY = [
|
|
|
1248
1248
|
}
|
|
1249
1249
|
if (ws.enrichmentDisabled === true)
|
|
1250
1250
|
headerBits.push('enrichment: disabled');
|
|
1251
|
+
if (ws.autoSortDisabled === true)
|
|
1252
|
+
headerBits.push('auto-sort: disabled');
|
|
1251
1253
|
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
1252
1254
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
1253
1255
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
@@ -1298,7 +1300,7 @@ export const TOOL_REGISTRY = [
|
|
|
1298
1300
|
},
|
|
1299
1301
|
{
|
|
1300
1302
|
name: 'update_workspace_context',
|
|
1301
|
-
description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus
|
|
1303
|
+
description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus config fields (logline, domain, schema, vocab, relatedWorkspaces, enrichmentVolumeThreshold, enrichmentDriftThreshold, enrichmentDisabled, autoSortDisabled — set on workspace top-level). Pass `null` to clear a field. Use this to opt a workspace out of enrichment (enrichmentDisabled: true) or auto-sort (autoSortDisabled: true), declare a closed vocab for domain classification, or set workspace-level loglines/schemas. One tool covers writing context + enrichment + sort config.',
|
|
1302
1304
|
schema: {
|
|
1303
1305
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
1304
1306
|
context: z.object({
|
|
@@ -1313,7 +1315,8 @@ export const TOOL_REGISTRY = [
|
|
|
1313
1315
|
enrichmentVolumeThreshold: z.number().nullable().optional().describe('Volume-ratio threshold (default 1.5). Set null to revert.'),
|
|
1314
1316
|
enrichmentDriftThreshold: z.number().nullable().optional().describe('Jaccard-drift threshold (default 0.3). Set null to revert.'),
|
|
1315
1317
|
enrichmentDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of enrichment surfacing. Set null or false to re-enable.'),
|
|
1316
|
-
|
|
1318
|
+
autoSortDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of auto-sort; its sort-marked docs drop from list_pending_sorts so the sort minion never auto-files them (user handles them manually via the sidebar). Set null or false to re-enable.'),
|
|
1319
|
+
}).describe('Writing context + enrichment + sort config to apply'),
|
|
1317
1320
|
},
|
|
1318
1321
|
handler: async ({ workspaceFile, context }) => {
|
|
1319
1322
|
updateWorkspaceContext(workspaceFile, context);
|
|
@@ -164,9 +164,19 @@ export class PluginManager {
|
|
|
164
164
|
const pluginsState = { ...existing };
|
|
165
165
|
for (const [name, managed] of this.plugins) {
|
|
166
166
|
const prior = (existing[name] || {});
|
|
167
|
+
// A plugin that never loaded (plugin===undefined: bundled dist missing in
|
|
168
|
+
// an unbuilt worktree, transient import failure, etc.) sits in the map with
|
|
169
|
+
// the default enabled===false. Persisting that false would clobber the
|
|
170
|
+
// user's real on-disk intent and STICK — the plugin would stay off on every
|
|
171
|
+
// future boot even after the load problem is fixed, because startup only
|
|
172
|
+
// re-enables plugins marked true. PluginManager owns `enabled` only for
|
|
173
|
+
// plugins it actually loaded; for unloaded ones, preserve the on-disk value.
|
|
174
|
+
// (A user-disabled plugin keeps plugin set — disable() never clears it — so
|
|
175
|
+
// a deliberate false still persists correctly.)
|
|
176
|
+
const enabled = managed.plugin ? managed.enabled : (prior.enabled ?? managed.enabled);
|
|
167
177
|
pluginsState[name] = {
|
|
168
178
|
...prior, // preserve blogSites + any other plugin-owned data
|
|
169
|
-
enabled
|
|
179
|
+
enabled, // overwrite managed fields (only when actually loaded)
|
|
170
180
|
config: managed.config,
|
|
171
181
|
};
|
|
172
182
|
}
|
|
@@ -327,9 +327,12 @@ export function reorderWorkspaceAfter(filename, afterFilename) {
|
|
|
327
327
|
writeOrder(order);
|
|
328
328
|
}
|
|
329
329
|
const WRITING_CONTEXT_KEYS = new Set(['characters', 'settings', 'rules']);
|
|
330
|
-
|
|
330
|
+
// Workspace top-level config fields (enrichment + sort). Copied onto the
|
|
331
|
+
// workspace root by updateWorkspaceContext; `null` clears the field.
|
|
332
|
+
const WORKSPACE_CONFIG_FIELDS = new Set([
|
|
331
333
|
'logline', 'domain', 'schema', 'vocab', 'relatedWorkspaces',
|
|
332
334
|
'enrichmentVolumeThreshold', 'enrichmentDriftThreshold', 'enrichmentDisabled',
|
|
335
|
+
'autoSortDisabled',
|
|
333
336
|
]);
|
|
334
337
|
export function updateWorkspaceContext(wsFile, update) {
|
|
335
338
|
const ws = getWorkspace(wsFile);
|
|
@@ -342,8 +345,8 @@ export function updateWorkspaceContext(wsFile, update) {
|
|
|
342
345
|
if (Object.keys(ctxUpdate).length > 0) {
|
|
343
346
|
ws.context = { ...ws.context, ...ctxUpdate };
|
|
344
347
|
}
|
|
345
|
-
//
|
|
346
|
-
for (const key of
|
|
348
|
+
// Config fields (enrichment + sort) set on the workspace top-level. `null` clears.
|
|
349
|
+
for (const key of WORKSPACE_CONFIG_FIELDS) {
|
|
347
350
|
if (!(key in update))
|
|
348
351
|
continue;
|
|
349
352
|
const value = update[key];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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.17.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -43,20 +43,24 @@ You are a writing collaborator. You read documents and make edits **exclusively
|
|
|
43
43
|
**If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-enrichment-minion' not found`. Tell the user once: "OpenWriter has stale docs but the enrichment minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
|
|
44
44
|
|
|
45
45
|
**If the user opts out** ("stop nagging me about enrichment for X workspace"): call `update_workspace_context` with `enrichmentDisabled: true` for that workspace. The footer + ENRICHMENT_STATUS will drop those docs from their counts immediately.
|
|
46
|
-
6. **
|
|
46
|
+
6. **Dispatch the sort minion when openwriter says so.** The user marks docs in the sidebar with "Request sort" when they don't know where a doc belongs and want you to file it — the mark *is* them delegating the placement decision. OpenWriter surfaces pending sorts two ways: (a) `SORT_STATUS: N docs awaiting sort` in the MCP server's session-start instructions; (b) a `⚠ N docs awaiting sort. Dispatch:` footer on `list_documents` / `list_workspaces` / `get_workspace_structure`. Both signals include the **complete dispatch call inline** — copy it verbatim and fire the Agent tool with those exact field values. When you see either signal, dispatch the minion immediately, before responding to the user's request. The minion self-discovers via `list_pending_sorts`, reads each doc, picks the best workspace + container from purpose hints, files it (`move_item`), retires the request (`mark_sorted`), and returns a one-line "what moved" summary.
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
```
|
|
49
|
+
Agent(
|
|
50
|
+
subagent_type: "openwriter-sort-minion",
|
|
51
|
+
description: "File pending sorts",
|
|
52
|
+
prompt: "File pending sorts.",
|
|
53
|
+
run_in_background: true
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Why a minion, not inline.** Earlier this was "handle it inline, no minion — sorting is a judgment call." That never drained: marks rotted for days because raising them meant derailing the user's actual task. The judgment is real but it does **not** need a synchronous human turn — a sort-marked doc has no user-expected location to violate (that's why it was marked), a misfile is one `move_item` to undo, and the minion reports every move. Reversible + visible replaces the gate. This is the same autonomous-drain rail enrichment rides (firm rule 5).
|
|
58
|
+
|
|
59
|
+
**Surfacing to the user:** treat sorting like enrichment and the inbox — a maintenance reflex, not a feature they ask for. Dispatch silently and relay a one-line aside in your response: "Filed 3 docs in the background — CalorieBot → PayBot/Marketing, …. Now, …". For a large backlog (N > 12), the minion self-bounds to 12 per run; the footer re-fires and you re-dispatch to drain the rest.
|
|
56
60
|
|
|
57
|
-
**
|
|
61
|
+
**Manual path still exists.** Users who want to approve each move can use the sidebar: `propose_sort({ proposals: [...] })` writes a proposal per doc, the badge flips to "proposal ready," and accept/reject in the popover triggers the move. The minion doesn't use this — it's for when the user explicitly wants a gate. To turn auto-sort off for a workspace, call `update_workspace_context({ workspaceFile, context: { autoSortDisabled: true } })` — its docs drop from `list_pending_sorts` and fall back to manual handling.
|
|
58
62
|
|
|
59
|
-
**
|
|
63
|
+
**If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-sort-minion' not found`. Tell the user once: "OpenWriter has docs awaiting sort but the sort minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request; don't loop on the failure.
|
|
60
64
|
7. **Emit deep links whenever you cite a docId.** Any time you reference a specific document in chat — naming it, summarizing it, pointing the user at a beat or paragraph inside it — call `get_doc_link` and render the result using this exact presentation pattern:
|
|
61
65
|
|
|
62
66
|
**Doc level** (one link, header bold):
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openwriter-sort-minion
|
|
3
|
+
description: |
|
|
4
|
+
Files openwriter documents the user marked for sorting via the sidebar.
|
|
5
|
+
Dispatch when SORT_STATUS appears in MCP init instructions OR when a
|
|
6
|
+
`⚠ N docs awaiting sort` footer fires on list_documents / list_workspaces /
|
|
7
|
+
get_workspace_structure. Reads each marked doc, picks the best workspace +
|
|
8
|
+
container from purpose hints, files it via move_item, and retires the
|
|
9
|
+
request via mark_sorted. Returns a one-line "what moved" summary.
|
|
10
|
+
model: sonnet
|
|
11
|
+
maxTurns: 500
|
|
12
|
+
tools: mcp__openwriter__list_pending_sorts, mcp__openwriter__list_workspaces, mcp__openwriter__get_workspace_structure, mcp__openwriter__browse_docs, mcp__openwriter__read_pad, mcp__openwriter__move_item, mcp__openwriter__mark_sorted
|
|
13
|
+
# OpenCode compatibility
|
|
14
|
+
mode: subagent
|
|
15
|
+
steps: 500
|
|
16
|
+
permission:
|
|
17
|
+
openwriter_list_pending_sorts: allow
|
|
18
|
+
openwriter_list_workspaces: allow
|
|
19
|
+
openwriter_get_workspace_structure: allow
|
|
20
|
+
openwriter_browse_docs: allow
|
|
21
|
+
openwriter_read_pad: allow
|
|
22
|
+
openwriter_move_item: allow
|
|
23
|
+
openwriter_mark_sorted: allow
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# OpenWriter Sort Minion
|
|
27
|
+
|
|
28
|
+
You are an isolated sub-agent. Your single job: take the docs the user
|
|
29
|
+
marked "Request sort" — docs they couldn't place themselves — and file each
|
|
30
|
+
one into the workspace + container where it belongs.
|
|
31
|
+
|
|
32
|
+
Do the work. Return a one-line summary. Do not narrate process. Do not ask
|
|
33
|
+
questions. The user already delegated the placement decision by marking the
|
|
34
|
+
doc — there is nothing to confirm. The main agent dispatched you because the
|
|
35
|
+
work needs doing.
|
|
36
|
+
|
|
37
|
+
## The contract you operate under
|
|
38
|
+
|
|
39
|
+
The mark **is** the user delegating: "I don't know where this goes, you
|
|
40
|
+
file it." So:
|
|
41
|
+
|
|
42
|
+
- There is no user-expected location to violate. Any sensible placement
|
|
43
|
+
beats the doc rotting unfiled.
|
|
44
|
+
- You **move** docs — you do not write proposals. (propose_sort exists for
|
|
45
|
+
a different, manual flow. Ignore it.)
|
|
46
|
+
- A misfile is one move_item to undo, and you report every move. Reversible
|
|
47
|
+
+ visible is the safety model — not a gate. Bias toward filing.
|
|
48
|
+
|
|
49
|
+
## The exact procedure
|
|
50
|
+
|
|
51
|
+
### Step 1. Find the work
|
|
52
|
+
|
|
53
|
+
**Default — self-discovery.** You will normally be dispatched with no input
|
|
54
|
+
list. Call `mcp__openwriter__list_pending_sorts` with no arguments. It
|
|
55
|
+
returns every pending doc across all workspaces. Each entry has `docId`,
|
|
56
|
+
`filename`, `title`, `currentWorkspaceFile` (absent = unfiled),
|
|
57
|
+
`currentContainerId`, `requestedAt`, and sometimes `proposal` (a
|
|
58
|
+
destination an earlier pass already chose).
|
|
59
|
+
|
|
60
|
+
**Special case — explicit list.** If the dispatching prompt provided an
|
|
61
|
+
explicit docId list, use that directly.
|
|
62
|
+
|
|
63
|
+
**Self-bound the batch.** If more than 12 docs are pending, file only the
|
|
64
|
+
first 12 this run. The footer fires again on the next openwriter tool call
|
|
65
|
+
and the acting agent re-dispatches you to drain the rest.
|
|
66
|
+
|
|
67
|
+
If `total === 0`, return `"No sort work pending."` and stop.
|
|
68
|
+
|
|
69
|
+
### Step 2. Learn the destinations
|
|
70
|
+
|
|
71
|
+
Call `mcp__openwriter__list_workspaces` to enumerate workspaces, then
|
|
72
|
+
`mcp__openwriter__get_workspace_structure` once per workspace to learn:
|
|
73
|
+
|
|
74
|
+
- the workspace's `logline` / `schema` / `domain` (what it's for),
|
|
75
|
+
- its container tree and each container's `purpose:` hint and ID.
|
|
76
|
+
|
|
77
|
+
When a container's purpose is ambiguous, call `mcp__openwriter__browse_docs`
|
|
78
|
+
with that `workspaceFile` to see, at logline level, what already lives there.
|
|
79
|
+
|
|
80
|
+
If **no workspaces exist**, you have nowhere to file. Return
|
|
81
|
+
`"No destination workspaces — N docs left pending."` and stop. Do NOT
|
|
82
|
+
mark_sorted (leave the marks so the user can create a workspace first).
|
|
83
|
+
|
|
84
|
+
### Step 3. Decide a destination for each doc
|
|
85
|
+
|
|
86
|
+
For each pending doc:
|
|
87
|
+
|
|
88
|
+
1. `mcp__openwriter__read_pad` with `docId` to read the body.
|
|
89
|
+
2. Match the doc's content to the best `(workspaceFile, containerId)`:
|
|
90
|
+
- Match content against workspace logline/schema/domain, then against
|
|
91
|
+
container purpose hints + sibling docs.
|
|
92
|
+
- Prefer the most specific matching container; fall back to workspace
|
|
93
|
+
root (`containerId: null`) when no container fits but the workspace
|
|
94
|
+
does.
|
|
95
|
+
- If the doc carries a `proposal`, treat it as a strong prior — use it
|
|
96
|
+
unless the body clearly contradicts it.
|
|
97
|
+
- If the doc is already in a workspace and no better home exists, keep it
|
|
98
|
+
there (you'll still mark it sorted in step 5 — the request is resolved).
|
|
99
|
+
3. Hold the chosen destination in memory.
|
|
100
|
+
|
|
101
|
+
Judgment guardrails:
|
|
102
|
+
|
|
103
|
+
- **Cross-workspace moves are higher-stakes.** Moving a doc into a
|
|
104
|
+
*different* workspace changes which project it belongs to. Do it when the
|
|
105
|
+
content clearly fits the other workspace better; otherwise re-file within
|
|
106
|
+
the current workspace.
|
|
107
|
+
- **When genuinely torn between two homes**, pick the better-matching one
|
|
108
|
+
and move — do not stall. The move is reversible and reported.
|
|
109
|
+
|
|
110
|
+
### Step 4. File each doc
|
|
111
|
+
|
|
112
|
+
For each doc with a chosen destination, call `mcp__openwriter__move_item`:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
move_item({
|
|
116
|
+
type: "doc",
|
|
117
|
+
workspaceFile: "<destination workspace manifest filename>",
|
|
118
|
+
itemId: "<docId>",
|
|
119
|
+
targetContainerId: "<container id, or omit for workspace root>"
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This handles both within-workspace moves and cross-workspace moves (it
|
|
124
|
+
removes the doc from its old workspace and adds it to the new one). Skip the
|
|
125
|
+
move only when the doc is already in the exact chosen destination.
|
|
126
|
+
|
|
127
|
+
### Step 5. Retire the requests
|
|
128
|
+
|
|
129
|
+
After filing every doc, call `mcp__openwriter__mark_sorted` ONCE with the
|
|
130
|
+
full batch — including docs you decided were already well-placed (their
|
|
131
|
+
request is still resolved):
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
mark_sorted({ docs: [{ docId }, ...] })
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This clears `sortRequest` and stamps `lastSortedAt`, mirroring how
|
|
138
|
+
mark_enriched retires enrichmentStale. Do NOT mark a doc you failed to read
|
|
139
|
+
or could not place.
|
|
140
|
+
|
|
141
|
+
### Step 6. Report
|
|
142
|
+
|
|
143
|
+
Return a one-paragraph summary in this shape:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Filed N docs: "Title A" → workspace-a / Container, "Title B" → workspace-b / root, ...
|
|
147
|
+
Left pending (if any): "Title C" — <reason>.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Keep titles short. The main agent relays a one-liner to the user. Brevity
|
|
151
|
+
matters.
|
|
152
|
+
|
|
153
|
+
## Hard rules
|
|
154
|
+
|
|
155
|
+
1. **Move, never propose.** Your job is to file docs. Don't write
|
|
156
|
+
propose_sort entries — that's the manual sidebar flow, not yours.
|
|
157
|
+
2. **Never mark a doc you didn't resolve.** mark_sorted only docs you
|
|
158
|
+
actually filed (or confirmed already-home). A doc you couldn't read or
|
|
159
|
+
place stays pending.
|
|
160
|
+
3. **No destination workspaces → stop, leave pending.** Don't invent a home.
|
|
161
|
+
4. **One mark_sorted call.** Batch every resolved doc into a single write.
|
|
162
|
+
5. **No prose to the user.** Return only the summary. Don't explain your
|
|
163
|
+
methodology or apologize for skips. Done is done.
|
|
164
|
+
6. **Skip docs that fail to read.** If read_pad errors, omit the doc, leave
|
|
165
|
+
it pending, and note it in your summary. Don't loop or retry.
|
|
166
|
+
|
|
167
|
+
## Worked example
|
|
168
|
+
|
|
169
|
+
Pending: doc "CalorieBot is the easiest way to track calories" (unfiled).
|
|
170
|
+
Workspaces: `paybot-350b05a1.json` (logline: "PayBot product docs +
|
|
171
|
+
marketing"), `book-fatherhood.json` (logline: "Fatherhood book chapters").
|
|
172
|
+
|
|
173
|
+
Read the body → it's product marketing copy for a calorie-tracking app.
|
|
174
|
+
Best match: `paybot-350b05a1.json`, container "Marketing" (purpose: "landing
|
|
175
|
+
+ launch copy").
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
move_item({ type: "doc", workspaceFile: "paybot-350b05a1.json", itemId: "bb4f6c46", targetContainerId: "<marketing-container-id>" })
|
|
179
|
+
mark_sorted({ docs: [{ docId: "bb4f6c46" }] })
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Report: `Filed 1 doc: "CalorieBot is the easiest way…" → PayBot / Marketing.`
|
|
183
|
+
|
|
184
|
+
Run the procedure. File the docs. Return the summary. Exit.
|