openwriter 0.28.2 → 0.29.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,7 +10,7 @@
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-DMjEHT50.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-SAoCjUU-.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
15
15
  </head>
16
16
  <body>
@@ -1108,7 +1108,7 @@ export function openFile(fullPath) {
1108
1108
  const filename = isExternalDoc(canonPath) ? canonPath : baseName;
1109
1109
  return { document: getDocument(), title: getTitle(), filename };
1110
1110
  }
1111
- export function duplicateDocument(filename) {
1111
+ export function duplicateDocument(filename, variant) {
1112
1112
  // Cancel any pending debounced save, then save current doc immediately
1113
1113
  cancelDebouncedSave();
1114
1114
  save();
@@ -1118,17 +1118,29 @@ export function duplicateDocument(filename) {
1118
1118
  }
1119
1119
  const raw = readFileSync(sourcePath, 'utf-8');
1120
1120
  const parsed = markdownToTiptap(raw);
1121
+ // Title suffix: variants read as "(Tweet)" / "(Blog)", plain copies as "(Copy)".
1122
+ const suffix = variant?.variantType
1123
+ ? variant.variantType.charAt(0).toUpperCase() + variant.variantType.slice(1)
1124
+ : 'Copy';
1121
1125
  // Generate deduplicated title
1122
- let newTitle = `${parsed.title} (Copy)`;
1126
+ let newTitle = `${parsed.title} (${suffix})`;
1123
1127
  let filePath = filePathForTitle(newTitle);
1124
1128
  if (existsSync(filePath)) {
1125
1129
  let counter = 2;
1126
- while (existsSync(filePathForTitle(`${parsed.title} (Copy ${counter})`)))
1130
+ while (existsSync(filePathForTitle(`${parsed.title} (${suffix} ${counter})`)))
1127
1131
  counter++;
1128
- newTitle = `${parsed.title} (Copy ${counter})`;
1132
+ newTitle = `${parsed.title} (${suffix} ${counter})`;
1129
1133
  filePath = filePathForTitle(newTitle);
1130
1134
  }
1131
1135
  const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
1136
+ // Variant relationship — set AFTER the spread so it overrides any inherited
1137
+ // masterDocId/variantType from the source doc. masterDocId points at the
1138
+ // source (the master); variantType labels this copy's intended format.
1139
+ // adr: docs/variants.md
1140
+ if (variant?.masterDocId)
1141
+ metadata.masterDocId = variant.masterDocId;
1142
+ if (variant?.variantType)
1143
+ metadata.variantType = variant.variantType;
1132
1144
  setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
1133
1145
  const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
1134
1146
  ensureDataDir();
@@ -15,6 +15,7 @@ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, ge
15
15
  import { syncPostHistory } from './post-sync.js';
16
16
  import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
17
17
  import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
18
+ import { writePromptDebug, isPromptDebugEnabled } from './prompt-debug.js';
18
19
  import { createWorkspaceRouter } from './workspace-routes.js';
19
20
  import { createLinkRouter } from './link-routes.js';
20
21
  import { createTweetRouter } from './tweet-routes.js';
@@ -224,6 +225,28 @@ export async function startHttpServer(options = {}) {
224
225
  res.status(500).json({ error: err.message });
225
226
  }
226
227
  });
228
+ // Prompt debug inspector: write the realized AV prompt to a timestamped .md doc
229
+ // for hand review. Off by default — gated by OW_PROMPT_DEBUG (see docs/prompt-debug.md).
230
+ // When off, no-ops so the client POST stays harmless.
231
+ app.post('/api/prompt-debug', (req, res) => {
232
+ try {
233
+ if (!isPromptDebugEnabled()) {
234
+ res.json({ success: false, skipped: true });
235
+ return;
236
+ }
237
+ const { action, debug, metadata } = req.body;
238
+ if (!debug) {
239
+ res.status(400).json({ error: 'debug payload is required' });
240
+ return;
241
+ }
242
+ const filename = writePromptDebug(action, debug, metadata);
243
+ broadcastDocumentsChanged();
244
+ res.json({ success: true, filename });
245
+ }
246
+ catch (err) {
247
+ res.status(500).json({ error: err.message });
248
+ }
249
+ });
227
250
  // Toggle auto-accept on a workspace or container. Inherits to every doc inside.
228
251
  // Body: { wsFile, containerId?, enabled }. Omit containerId to target the
229
252
  // whole workspace; pass it to target a specific container.
@@ -601,12 +624,12 @@ export async function startHttpServer(options = {}) {
601
624
  });
602
625
  app.post('/api/documents/duplicate', (req, res) => {
603
626
  try {
604
- const { filename } = req.body;
627
+ const { filename, masterDocId, variantType } = req.body;
605
628
  if (!filename) {
606
629
  res.status(400).json({ error: 'filename is required' });
607
630
  return;
608
631
  }
609
- const result = duplicateDocument(filename);
632
+ const result = duplicateDocument(filename, (masterDocId || variantType) ? { masterDocId, variantType } : undefined);
610
633
  broadcastDocumentSwitched(result.document, result.title, result.filename);
611
634
  broadcastDocumentsChanged();
612
635
  res.json(result);
@@ -497,8 +497,10 @@ export const TOOL_REGISTRY = [
497
497
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
498
498
  afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
499
499
  status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
500
+ masterDocId: z.string().optional().describe('Make this doc a VARIANT of another doc. Pass the master doc\'s docId (8-char hex). The variant nests under its master in the sidebar (expandable tree). Use when creating a derivative — e.g. a tweet thread from a blog post. Pair with variantType. See docs/variants.md.'),
501
+ variantType: z.string().optional().describe('Label for what kind of variant this is (e.g. "tweet", "blog", "linkedin"). Shows as a badge in the sidebar. Only meaningful alongside masterDocId.'),
500
502
  },
501
- handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status }) => {
503
+ handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
502
504
  // Require url for reply/quote
503
505
  if ((content_type === 'reply' || content_type === 'quote') && !url) {
504
506
  return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
@@ -532,13 +534,20 @@ export const TOOL_REGISTRY = [
532
534
  // canonical via set_metadata({ status: "canonical" }) on lifecycle
533
535
  // transitions. See brief 2026-05-21-simplify-enrichment-schema-three-fields.
534
536
  const statusMeta = { status: status ?? 'draft' };
537
+ // Variant relationship (optional) — lands on the first disk write so the
538
+ // doc nests under its master in the sidebar immediately. See docs/variants.md.
539
+ const variantMeta = {};
540
+ if (masterDocId)
541
+ variantMeta.masterDocId = masterDocId;
542
+ if (variantType)
543
+ variantMeta.variantType = variantType;
535
544
  try {
536
545
  if (empty) {
537
546
  // Immediate switch — no spinner, no populate_document needed
538
547
  const result = createDocument(title, undefined, path);
539
548
  setAgentLock(result.filename);
540
- // Apply status + type-specific metadata in one merge
541
- const initMeta = { ...statusMeta };
549
+ // Apply status + variant + type-specific metadata in one merge
550
+ const initMeta = { ...statusMeta, ...variantMeta };
542
551
  if (content_type) {
543
552
  const typeMeta = resolveTypeMeta(content_type, url);
544
553
  if (typeMeta)
@@ -578,7 +587,7 @@ export const TOOL_REGISTRY = [
578
587
  // Merge status with any content-type metadata so it lands on the first
579
588
  // disk write.
580
589
  const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
581
- const initialMeta = { ...statusMeta, ...(typeMeta || {}) };
590
+ const initialMeta = { ...statusMeta, ...variantMeta, ...(typeMeta || {}) };
582
591
  const result = createDocumentFile(title, path, initialMeta);
583
592
  let wsInfo = '';
584
593
  if (wsTarget) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * File: prompt-debug.ts
3
+ * Purpose: Write AV prompt debug data to timestamped .md files for inspection.
4
+ * Each enhance creates a new `_prompt-<action>-<ts>.md` doc in the data dir,
5
+ * visible in the OpenWriter sidebar — so the exact system+user prompt that was
6
+ * sent to the AI can be reviewed by hand.
7
+ * Control: gated by isPromptDebugEnabled() (OW_PROMPT_DEBUG env). Off by default.
8
+ * See docs/prompt-debug.md for the on/off switch.
9
+ */
10
+ import { getDataDir, ensureDataDir, atomicWriteFileSync } from './helpers.js';
11
+ import { join } from 'path';
12
+ /** True when the prompt-debug inspector is switched on. Read lazily so the
13
+ * process picks up OW_PROMPT_DEBUG without code changes. */
14
+ export function isPromptDebugEnabled() {
15
+ const v = process.env.OW_PROMPT_DEBUG;
16
+ return v === '1' || v === 'true';
17
+ }
18
+ /**
19
+ * Write prompt debug info to a timestamped markdown file.
20
+ * Returns the filename created.
21
+ */
22
+ export function writePromptDebug(action, debug, metadata) {
23
+ ensureDataDir();
24
+ const now = new Date();
25
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
26
+ const filename = `_prompt-${action || 'debug'}-${ts}.md`;
27
+ const filePath = join(getDataDir(), filename);
28
+ const timeStr = now.toLocaleTimeString('en-US', { hour12: true, hour: '2-digit', minute: '2-digit', second: '2-digit' });
29
+ let md = `---\ntitle: "Prompt Debug: ${action} @ ${timeStr}"\n---\n\n`;
30
+ // Metadata summary
31
+ if (metadata) {
32
+ md += `## Metadata\n\n`;
33
+ md += `| Key | Value |\n|-----|-------|\n`;
34
+ if (metadata.action)
35
+ md += `| Action | ${metadata.action} |\n`;
36
+ if (metadata.profileUsed)
37
+ md += `| Profile | ${metadata.profileUsed} |\n`;
38
+ if (metadata.nodesIn != null)
39
+ md += `| Nodes In | ${metadata.nodesIn} |\n`;
40
+ if (metadata.nodesOut != null)
41
+ md += `| Nodes Out | ${metadata.nodesOut} |\n`;
42
+ if (metadata.ragExamples != null)
43
+ md += `| RAG Examples | ${metadata.ragExamples} |\n`;
44
+ if (metadata.ragTotalWords != null)
45
+ md += `| RAG Total Words | ${metadata.ragTotalWords} |\n`;
46
+ if (metadata.processingTimeMs != null)
47
+ md += `| Processing Time | ${metadata.processingTimeMs}ms |\n`;
48
+ if (metadata.estimatedCost != null)
49
+ md += `| Estimated Cost | $${metadata.estimatedCost.toFixed(4)} |\n`;
50
+ md += `\n`;
51
+ }
52
+ // System prompt
53
+ md += `## System Prompt\n\n`;
54
+ md += debug.systemPrompt + '\n\n';
55
+ // User prompt
56
+ md += `---\n\n## User Prompt\n\n`;
57
+ md += debug.userPrompt + '\n\n';
58
+ // RAG examples
59
+ if (debug.ragExamples && debug.ragExamples.length > 0) {
60
+ md += `---\n\n## RAG Examples (${debug.ragExamples.length})\n\n`;
61
+ for (const ex of debug.ragExamples) {
62
+ md += `### ${ex.anchor} (${ex.wordCount} words)\n\n`;
63
+ md += ex.context + '\n\n';
64
+ }
65
+ }
66
+ atomicWriteFileSync(filePath, md);
67
+ return filename;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.28.2",
3
+ "version": "0.29.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.16.3"
19
+ version: "0.16.4"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -346,6 +346,8 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
346
346
 
347
347
  ### Batched Creation (multiple docs at once)
348
348
 
349
+ **Variants** — when repurposing a doc into another format (a thread off a blog post, a LinkedIn cut of a newsletter), pass `create_document({ masterDocId, variantType })` so the new doc nests under its master in the sidebar instead of floating off as a disconnected doc. Users do the same via right-click → "Create variant".
350
+
349
351
  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.
350
352
 
351
353
  ```