openwriter 0.28.2 → 0.29.1
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-DMjEHT50.js → index-DHaZI7nA.js} +57 -57
- package/dist/client/index.html +1 -1
- package/dist/server/content-type-meta.js +20 -0
- package/dist/server/documents.js +88 -4
- package/dist/server/index.js +45 -3
- package/dist/server/mcp.js +14 -17
- package/dist/server/prompt-debug.js +68 -0
- package/package.json +1 -1
- package/skill/SKILL.md +3 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-DHaZI7nA.js"></script>
|
|
14
14
|
<link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single source of truth for content-type scaffolding metadata. Maps a
|
|
2
|
+
// content_type to the frontmatter it needs: the `content_type` field itself
|
|
3
|
+
// (which owns the editor surface — adr: adr/browser-write-fidelity.md) plus the
|
|
4
|
+
// type's context object (tweetContext / articleContext / blogContext / …).
|
|
5
|
+
//
|
|
6
|
+
// Used by both the MCP create_document handler and the HTTP POST /api/documents
|
|
7
|
+
// endpoint (the "Create variant" path) so a typed empty doc is scaffolded the
|
|
8
|
+
// same way regardless of who creates it. adr: docs/variants.md
|
|
9
|
+
export function resolveTypeMeta(type, url) {
|
|
10
|
+
switch (type) {
|
|
11
|
+
case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
|
|
12
|
+
case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
13
|
+
case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
14
|
+
case 'article': return { content_type: 'article', articleContext: { active: true } };
|
|
15
|
+
case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
|
|
16
|
+
case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
|
|
17
|
+
case 'blog': return { content_type: 'blog', blogContext: { active: true } };
|
|
18
|
+
default: return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/server/documents.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join } from 'path';
|
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
|
|
11
|
+
import { resolveTypeMeta } from './content-type-meta.js';
|
|
11
12
|
import { parseMarkdownContent } from './compact.js';
|
|
12
13
|
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, markAsAgentStub, unmarkAgentStub, isAgentStub, } from './state.js';
|
|
13
14
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath } from './helpers.js';
|
|
@@ -1108,7 +1109,7 @@ export function openFile(fullPath) {
|
|
|
1108
1109
|
const filename = isExternalDoc(canonPath) ? canonPath : baseName;
|
|
1109
1110
|
return { document: getDocument(), title: getTitle(), filename };
|
|
1110
1111
|
}
|
|
1111
|
-
export function duplicateDocument(filename) {
|
|
1112
|
+
export function duplicateDocument(filename, variant) {
|
|
1112
1113
|
// Cancel any pending debounced save, then save current doc immediately
|
|
1113
1114
|
cancelDebouncedSave();
|
|
1114
1115
|
save();
|
|
@@ -1118,17 +1119,29 @@ export function duplicateDocument(filename) {
|
|
|
1118
1119
|
}
|
|
1119
1120
|
const raw = readFileSync(sourcePath, 'utf-8');
|
|
1120
1121
|
const parsed = markdownToTiptap(raw);
|
|
1122
|
+
// Title suffix: variants read as "(Tweet)" / "(Blog)", plain copies as "(Copy)".
|
|
1123
|
+
const suffix = variant?.variantType
|
|
1124
|
+
? variant.variantType.charAt(0).toUpperCase() + variant.variantType.slice(1)
|
|
1125
|
+
: 'Copy';
|
|
1121
1126
|
// Generate deduplicated title
|
|
1122
|
-
let newTitle = `${parsed.title} (
|
|
1127
|
+
let newTitle = `${parsed.title} (${suffix})`;
|
|
1123
1128
|
let filePath = filePathForTitle(newTitle);
|
|
1124
1129
|
if (existsSync(filePath)) {
|
|
1125
1130
|
let counter = 2;
|
|
1126
|
-
while (existsSync(filePathForTitle(`${parsed.title} (
|
|
1131
|
+
while (existsSync(filePathForTitle(`${parsed.title} (${suffix} ${counter})`)))
|
|
1127
1132
|
counter++;
|
|
1128
|
-
newTitle = `${parsed.title} (
|
|
1133
|
+
newTitle = `${parsed.title} (${suffix} ${counter})`;
|
|
1129
1134
|
filePath = filePathForTitle(newTitle);
|
|
1130
1135
|
}
|
|
1131
1136
|
const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
|
|
1137
|
+
// Variant relationship — set AFTER the spread so it overrides any inherited
|
|
1138
|
+
// masterDocId/variantType from the source doc. masterDocId points at the
|
|
1139
|
+
// source (the master); variantType labels this copy's intended format.
|
|
1140
|
+
// adr: docs/variants.md
|
|
1141
|
+
if (variant?.masterDocId)
|
|
1142
|
+
metadata.masterDocId = variant.masterDocId;
|
|
1143
|
+
if (variant?.variantType)
|
|
1144
|
+
metadata.variantType = variant.variantType;
|
|
1132
1145
|
setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
|
|
1133
1146
|
const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
|
|
1134
1147
|
ensureDataDir();
|
|
@@ -1136,6 +1149,77 @@ export function duplicateDocument(filename) {
|
|
|
1136
1149
|
const newFilename = filePath.split(/[/\\]/).pop();
|
|
1137
1150
|
return { document: getDocument(), title: getTitle(), filename: newFilename };
|
|
1138
1151
|
}
|
|
1152
|
+
// Content types that surface an editable title/headline above the body. For
|
|
1153
|
+
// these, the doc's frontmatter `title` IS content (blog headline, article title,
|
|
1154
|
+
// newsletter subject). For every other type the title is just a sidebar label
|
|
1155
|
+
// and the body carries everything. adr: docs/variants.md
|
|
1156
|
+
const TITLE_BEARING_TYPES = new Set(['blog', 'article', 'newsletter']);
|
|
1157
|
+
/**
|
|
1158
|
+
* Create a variant of `masterFilename` retyped as `variantType`, nested under
|
|
1159
|
+
* the master. Field-projection model (NOT a verbatim clone — that's
|
|
1160
|
+
* duplicateDocument): port the fields the two types share.
|
|
1161
|
+
* - body: always ported.
|
|
1162
|
+
* - downcast (title-bearing master → body-only variant): the master's title is
|
|
1163
|
+
* folded into the body as its first paragraph so the headline isn't lost.
|
|
1164
|
+
* - the variant is scaffolded with the TARGET type's content_type + context;
|
|
1165
|
+
* the source's context objects (blogContext, tweetContext, …) are NOT
|
|
1166
|
+
* inherited — a variant is a new typed doc, not a surface clone.
|
|
1167
|
+
* adr: docs/variants.md
|
|
1168
|
+
*/
|
|
1169
|
+
export function createVariant(masterFilename, opts) {
|
|
1170
|
+
cancelDebouncedSave();
|
|
1171
|
+
save();
|
|
1172
|
+
const sourcePath = resolveDocPath(masterFilename);
|
|
1173
|
+
if (!existsSync(sourcePath))
|
|
1174
|
+
throw new Error(`Document not found: ${masterFilename}`);
|
|
1175
|
+
const raw = readFileSync(sourcePath, 'utf-8');
|
|
1176
|
+
const parsed = markdownToTiptap(raw);
|
|
1177
|
+
const srcType = deriveContentType(parsed.metadata) || 'document';
|
|
1178
|
+
const tgtType = opts.variantType;
|
|
1179
|
+
const srcTitleBearing = TITLE_BEARING_TYPES.has(srcType);
|
|
1180
|
+
const tgtTitleBearing = TITLE_BEARING_TYPES.has(tgtType);
|
|
1181
|
+
// Body projection. Downcast (title-bearing → body-only): prepend the master's
|
|
1182
|
+
// title as the first paragraph so the headline survives in a surface with no
|
|
1183
|
+
// title field ("title becomes first line, body the next paragraph"). Otherwise
|
|
1184
|
+
// the body ports unchanged.
|
|
1185
|
+
let bodyContent = parsed.document.content || [];
|
|
1186
|
+
if (srcTitleBearing && !tgtTitleBearing && parsed.title) {
|
|
1187
|
+
bodyContent = [
|
|
1188
|
+
{ type: 'paragraph', content: [{ type: 'text', text: parsed.title }] },
|
|
1189
|
+
...bodyContent,
|
|
1190
|
+
];
|
|
1191
|
+
}
|
|
1192
|
+
const bodyDoc = { ...parsed.document, content: bodyContent };
|
|
1193
|
+
// Title is always label-suffixed: it doubles as the filename + sidebar name,
|
|
1194
|
+
// so it must stay unique vs the master (a raw duplicate title would collide).
|
|
1195
|
+
// The title CONTENT still rides along for title-bearing targets — they render
|
|
1196
|
+
// it as the headline and the user trims the suffix.
|
|
1197
|
+
const Label = tgtType.charAt(0).toUpperCase() + tgtType.slice(1);
|
|
1198
|
+
let newTitle = `${parsed.title} (${Label})`;
|
|
1199
|
+
let filePath = filePathForTitle(newTitle);
|
|
1200
|
+
if (existsSync(filePath)) {
|
|
1201
|
+
let counter = 2;
|
|
1202
|
+
while (existsSync(filePathForTitle(`${parsed.title} (${Label} ${counter})`)))
|
|
1203
|
+
counter++;
|
|
1204
|
+
newTitle = `${parsed.title} (${Label} ${counter})`;
|
|
1205
|
+
filePath = filePathForTitle(newTitle);
|
|
1206
|
+
}
|
|
1207
|
+
// Fresh metadata: target type scaffold + variant relationship only. Source
|
|
1208
|
+
// context objects are intentionally dropped (see header).
|
|
1209
|
+
const metadata = {
|
|
1210
|
+
title: newTitle,
|
|
1211
|
+
docId: generateNodeId(),
|
|
1212
|
+
...(resolveTypeMeta(tgtType) || {}),
|
|
1213
|
+
masterDocId: opts.masterDocId,
|
|
1214
|
+
variantType: tgtType,
|
|
1215
|
+
};
|
|
1216
|
+
setActiveDocument(bodyDoc, newTitle, filePath, false, undefined, metadata);
|
|
1217
|
+
const { markdown } = tiptapToMarkdownChecked(bodyDoc, newTitle, metadata);
|
|
1218
|
+
ensureDataDir();
|
|
1219
|
+
atomicWriteFileSync(filePath, markdown);
|
|
1220
|
+
const newFilename = filePath.split(/[/\\]/).pop();
|
|
1221
|
+
return { document: getDocument(), title: getTitle(), filename: newFilename };
|
|
1222
|
+
}
|
|
1139
1223
|
export function getActiveFilename() {
|
|
1140
1224
|
const filePath = getFilePath();
|
|
1141
1225
|
// For external docs, return the full path as the identifier
|
package/dist/server/index.js
CHANGED
|
@@ -14,7 +14,8 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
|
14
14
|
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, setSortRequestOnFile, clearSortRequestOnFile, bumpDocVersion, markAsAgentStub, extractText } from './state.js';
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
16
|
import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
|
|
17
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
|
|
17
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, createVariant, 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,31 @@ 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);
|
|
633
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
634
|
+
broadcastDocumentsChanged();
|
|
635
|
+
res.json(result);
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
res.status(400).json({ error: err.message });
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
// Create variant: a retyped derivative nested under the master. Field-projection
|
|
642
|
+
// (body always; title folds into body on downcast; target type scaffolded) —
|
|
643
|
+
// NOT a verbatim clone (that's /duplicate). adr: docs/variants.md
|
|
644
|
+
app.post('/api/documents/variant', (req, res) => {
|
|
645
|
+
try {
|
|
646
|
+
const { filename, masterDocId, variantType } = req.body;
|
|
647
|
+
if (!filename || !masterDocId || !variantType) {
|
|
648
|
+
res.status(400).json({ error: 'filename, masterDocId, and variantType are required' });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const result = createVariant(filename, { masterDocId, variantType });
|
|
610
652
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
611
653
|
broadcastDocumentsChanged();
|
|
612
654
|
res.json(result);
|
package/dist/server/mcp.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
|
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
15
|
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
16
16
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
17
|
+
import { resolveTypeMeta } from './content-type-meta.js';
|
|
17
18
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions, stagePendingTitle } from './documents.js';
|
|
18
19
|
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
19
20
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
@@ -29,19 +30,6 @@ import { tiptapToMarkdown, splitFusedParagraphs } from './markdown.js';
|
|
|
29
30
|
import { loadDocFromDisk } from './pending-overlay.js';
|
|
30
31
|
import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
|
|
31
32
|
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
32
|
-
/** Map a content type string to its frontmatter metadata object. */
|
|
33
|
-
function resolveTypeMeta(type, url) {
|
|
34
|
-
switch (type) {
|
|
35
|
-
case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
|
|
36
|
-
case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
37
|
-
case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
38
|
-
case 'article': return { content_type: 'article', articleContext: { active: true } };
|
|
39
|
-
case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
|
|
40
|
-
case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
|
|
41
|
-
case 'blog': return { content_type: 'blog', blogContext: { active: true } };
|
|
42
|
-
default: return undefined;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
33
|
/** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
|
|
46
34
|
function resolveDocTarget(docId) {
|
|
47
35
|
const filename = resolveDocId(docId);
|
|
@@ -497,8 +485,10 @@ export const TOOL_REGISTRY = [
|
|
|
497
485
|
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
486
|
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
487
|
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.'),
|
|
488
|
+
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.'),
|
|
489
|
+
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
490
|
},
|
|
501
|
-
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status }) => {
|
|
491
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
|
|
502
492
|
// Require url for reply/quote
|
|
503
493
|
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
504
494
|
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
@@ -532,13 +522,20 @@ export const TOOL_REGISTRY = [
|
|
|
532
522
|
// canonical via set_metadata({ status: "canonical" }) on lifecycle
|
|
533
523
|
// transitions. See brief 2026-05-21-simplify-enrichment-schema-three-fields.
|
|
534
524
|
const statusMeta = { status: status ?? 'draft' };
|
|
525
|
+
// Variant relationship (optional) — lands on the first disk write so the
|
|
526
|
+
// doc nests under its master in the sidebar immediately. See docs/variants.md.
|
|
527
|
+
const variantMeta = {};
|
|
528
|
+
if (masterDocId)
|
|
529
|
+
variantMeta.masterDocId = masterDocId;
|
|
530
|
+
if (variantType)
|
|
531
|
+
variantMeta.variantType = variantType;
|
|
535
532
|
try {
|
|
536
533
|
if (empty) {
|
|
537
534
|
// Immediate switch — no spinner, no populate_document needed
|
|
538
535
|
const result = createDocument(title, undefined, path);
|
|
539
536
|
setAgentLock(result.filename);
|
|
540
|
-
// Apply status + type-specific metadata in one merge
|
|
541
|
-
const initMeta = { ...statusMeta };
|
|
537
|
+
// Apply status + variant + type-specific metadata in one merge
|
|
538
|
+
const initMeta = { ...statusMeta, ...variantMeta };
|
|
542
539
|
if (content_type) {
|
|
543
540
|
const typeMeta = resolveTypeMeta(content_type, url);
|
|
544
541
|
if (typeMeta)
|
|
@@ -578,7 +575,7 @@ export const TOOL_REGISTRY = [
|
|
|
578
575
|
// Merge status with any content-type metadata so it lands on the first
|
|
579
576
|
// disk write.
|
|
580
577
|
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
581
|
-
const initialMeta = { ...statusMeta, ...(typeMeta || {}) };
|
|
578
|
+
const initialMeta = { ...statusMeta, ...variantMeta, ...(typeMeta || {}) };
|
|
582
579
|
const result = createDocumentFile(title, path, initialMeta);
|
|
583
580
|
let wsInfo = '';
|
|
584
581
|
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.
|
|
3
|
+
"version": "0.29.1",
|
|
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.
|
|
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
|
```
|