openwriter 0.12.0 → 0.13.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/bin/pad.js +101 -146
- package/dist/client/assets/index-BlLnLdoc.js +212 -0
- package/dist/client/assets/{index-CRImKlcp.css → index-OV13QtgQ.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/backlinks.js +323 -0
- package/dist/server/index.js +130 -1
- package/dist/server/markdown-parse.js +45 -6
- package/dist/server/markdown-serialize.js +10 -2
- package/dist/server/marks.js +9 -0
- package/dist/server/mcp.js +148 -6
- package/dist/server/state.js +47 -6
- package/dist/server/workspace-routes.js +31 -3
- package/dist/server/workspaces.js +85 -0
- package/package.json +1 -1
- package/skill/SKILL.md +3 -7
- package/dist/client/assets/index-CNmzNvB_.js +0 -211
- package/skill/docs/anti-ai.md +0 -71
- package/skill/docs/voices.md +0 -88
- package/skill/voices/authority.md +0 -102
- package/skill/voices/business.md +0 -103
- package/skill/voices/logical.md +0 -104
- package/skill/voices/provocateur.md +0 -101
- package/skill/voices/storyteller.md +0 -104
package/dist/server/mcp.js
CHANGED
|
@@ -10,8 +10,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
|
|
13
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
|
|
14
|
-
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
|
|
13
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, isAutoAcceptActive, } from './state.js';
|
|
14
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments } from './documents.js';
|
|
15
|
+
import { extractForwardLinks } from './backlinks.js';
|
|
15
16
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
|
|
16
17
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
17
18
|
import { findDocNode } from './workspace-tree.js';
|
|
@@ -221,8 +222,9 @@ export const TOOL_REGISTRY = [
|
|
|
221
222
|
pendingChanges: target.pendingCount,
|
|
222
223
|
lastModified: target.lastModified.toISOString(),
|
|
223
224
|
};
|
|
224
|
-
// Surface autoAccept
|
|
225
|
-
|
|
225
|
+
// Surface effective autoAccept (doc flag OR workspace/container inherited)
|
|
226
|
+
// so the agent stops waiting for review when it's on.
|
|
227
|
+
if (isAutoAcceptActive(target.filename, target.metadata))
|
|
226
228
|
status.autoAccept = true;
|
|
227
229
|
const latestVersion = getUpdateInfo();
|
|
228
230
|
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
@@ -414,9 +416,10 @@ export const TOOL_REGISTRY = [
|
|
|
414
416
|
};
|
|
415
417
|
}
|
|
416
418
|
// Active target (or no filename): existing flow.
|
|
417
|
-
// Skip pending tagging when autoAccept is on
|
|
419
|
+
// Skip pending tagging when autoAccept is effectively on (doc flag or
|
|
420
|
+
// inherited from workspace/container) — content commits directly.
|
|
418
421
|
setAgentLock(); // Block browser doc-updates during population
|
|
419
|
-
if (
|
|
422
|
+
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
420
423
|
markAllNodesAsPending(doc, 'insert');
|
|
421
424
|
}
|
|
422
425
|
updateDocument(doc);
|
|
@@ -1293,6 +1296,145 @@ export const TOOL_REGISTRY = [
|
|
|
1293
1296
|
return { content: [{ type: 'text', text: ok ? `Removed task ${id}.` : `Task ${id} not found.` }] };
|
|
1294
1297
|
},
|
|
1295
1298
|
},
|
|
1299
|
+
{
|
|
1300
|
+
name: 'link_to',
|
|
1301
|
+
description: 'Wrap anchor text in the ACTIVE doc with a doc: link pointing at another doc. Optionally target a specific paragraph (target_node_id) for paragraph-level navigation, with an optional quote for scroll-anchor fallback. The on-save backlinks pipeline then auto-updates the target doc\'s frontmatter `backlinks` field — so this single tool call creates both the forward link and the backlink. Use after writing prose to cross-reference concepts: agent writes about "territorial imperative" then calls link_to to point that phrase at the canonical concept doc.',
|
|
1302
|
+
schema: {
|
|
1303
|
+
text: z.string().describe('Anchor text in the active doc to wrap with the link. Exact substring match. First occurrence wins if the text appears multiple times.'),
|
|
1304
|
+
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs).'),
|
|
1305
|
+
target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
|
|
1306
|
+
quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
|
|
1307
|
+
},
|
|
1308
|
+
handler: async ({ text, target_doc_id, target_node_id, quote }) => {
|
|
1309
|
+
// 1. Locate the block in the active doc that contains the anchor text
|
|
1310
|
+
const doc = getDocument();
|
|
1311
|
+
let sourceNodeId = null;
|
|
1312
|
+
function walk(nodes) {
|
|
1313
|
+
if (sourceNodeId)
|
|
1314
|
+
return;
|
|
1315
|
+
for (const node of nodes) {
|
|
1316
|
+
if (sourceNodeId)
|
|
1317
|
+
return;
|
|
1318
|
+
if (Array.isArray(node.content)) {
|
|
1319
|
+
const blockText = node.content.map((c) => c.text || '').join('');
|
|
1320
|
+
if (node.attrs?.id && blockText.includes(text)) {
|
|
1321
|
+
sourceNodeId = node.attrs.id;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
walk(node.content);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
walk(doc.content);
|
|
1329
|
+
if (!sourceNodeId) {
|
|
1330
|
+
return { content: [{ type: 'text', text: `Anchor text "${text}" not found in the active doc. Use search_docs first to locate the right doc.` }] };
|
|
1331
|
+
}
|
|
1332
|
+
// 2. Build the href in canonical doc:DOCID#NODEID?q=quote form
|
|
1333
|
+
let href = `doc:${target_doc_id}`;
|
|
1334
|
+
if (target_node_id)
|
|
1335
|
+
href += `#${target_node_id}`;
|
|
1336
|
+
if (quote)
|
|
1337
|
+
href += `?q=${encodeURIComponent(quote)}`;
|
|
1338
|
+
// 3. Apply the link mark to the matched substring via the existing text-edit pipeline
|
|
1339
|
+
const result = applyTextEdits(sourceNodeId, [{
|
|
1340
|
+
find: text,
|
|
1341
|
+
addMark: { type: 'link', attrs: { href } },
|
|
1342
|
+
}]);
|
|
1343
|
+
if (!result.success) {
|
|
1344
|
+
return { content: [{ type: 'text', text: `Failed to apply link mark: ${result.error}` }] };
|
|
1345
|
+
}
|
|
1346
|
+
save(); // triggers writeToDisk → backlinks pipeline auto-updates the target's frontmatter
|
|
1347
|
+
return { content: [{ type: 'text', text: `Linked "${text}" in node ${sourceNodeId} → ${href}. Target doc's backlinks frontmatter will refresh on next save.` }] };
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: 'search_docs',
|
|
1352
|
+
description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
|
|
1353
|
+
schema: {
|
|
1354
|
+
query: z.string().describe('Search query (case-insensitive substring match against title, tags, then content).'),
|
|
1355
|
+
limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
|
|
1356
|
+
},
|
|
1357
|
+
handler: async ({ query, limit = 10 }) => {
|
|
1358
|
+
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1359
|
+
const raw = searchDocuments(query);
|
|
1360
|
+
// Enrich with docId by reading each result's frontmatter
|
|
1361
|
+
const enriched = raw.slice(0, cap).map((r) => {
|
|
1362
|
+
let docId = null;
|
|
1363
|
+
try {
|
|
1364
|
+
const filePath = resolveDocPath(r.filename);
|
|
1365
|
+
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1366
|
+
const fm = matter(fileRaw);
|
|
1367
|
+
docId = fm.data?.docId || null;
|
|
1368
|
+
}
|
|
1369
|
+
catch { /* docId stays null */ }
|
|
1370
|
+
return {
|
|
1371
|
+
docId,
|
|
1372
|
+
title: r.title,
|
|
1373
|
+
filename: r.filename,
|
|
1374
|
+
matchType: r.matchType,
|
|
1375
|
+
snippet: r.snippet,
|
|
1376
|
+
matchedTag: r.matchedTag,
|
|
1377
|
+
};
|
|
1378
|
+
});
|
|
1379
|
+
return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
name: 'get_graph',
|
|
1384
|
+
description: 'Return forward links + backlinks for a doc — the crawl primitive for cross-doc context retrieval. Forward links extracted from the doc body, backlinks read from the doc\'s frontmatter (maintained by the on-save backlinks pipeline). Optional depth walks neighbors recursively (cap 3).',
|
|
1385
|
+
schema: {
|
|
1386
|
+
docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
|
|
1387
|
+
depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
|
|
1388
|
+
},
|
|
1389
|
+
handler: async ({ docId, depth = 1 }) => {
|
|
1390
|
+
const maxDepth = Math.min(Math.max(depth, 1), 3);
|
|
1391
|
+
const seen = new Set();
|
|
1392
|
+
const nodes = [];
|
|
1393
|
+
function visit(id, hopsLeft) {
|
|
1394
|
+
if (seen.has(id) || hopsLeft < 0)
|
|
1395
|
+
return;
|
|
1396
|
+
seen.add(id);
|
|
1397
|
+
let target;
|
|
1398
|
+
try {
|
|
1399
|
+
target = resolveDocTarget(id);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const forward = extractForwardLinks(target.document, id);
|
|
1405
|
+
const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
|
|
1406
|
+
nodes.push({
|
|
1407
|
+
docId: id,
|
|
1408
|
+
title: target.title,
|
|
1409
|
+
forward: forward.map((l) => ({
|
|
1410
|
+
text: l.text,
|
|
1411
|
+
from_node: l.from_node,
|
|
1412
|
+
to_doc: l.to_doc,
|
|
1413
|
+
...(l.to_node ? { to_node: l.to_node } : {}),
|
|
1414
|
+
})),
|
|
1415
|
+
backlinks: backlinks.map((b) => ({
|
|
1416
|
+
text: b.text,
|
|
1417
|
+
from_doc: b.from_doc,
|
|
1418
|
+
from_node: b.from_node,
|
|
1419
|
+
...(b.to_node ? { to_node: b.to_node } : {}),
|
|
1420
|
+
})),
|
|
1421
|
+
});
|
|
1422
|
+
if (hopsLeft > 0) {
|
|
1423
|
+
const neighbors = new Set();
|
|
1424
|
+
for (const l of forward)
|
|
1425
|
+
neighbors.add(l.to_doc);
|
|
1426
|
+
for (const b of backlinks)
|
|
1427
|
+
neighbors.add(b.from_doc);
|
|
1428
|
+
for (const n of neighbors) {
|
|
1429
|
+
if (!seen.has(n))
|
|
1430
|
+
visit(n, hopsLeft - 1);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
visit(docId, maxDepth - 1);
|
|
1435
|
+
return { content: [{ type: 'text', text: JSON.stringify(nodes) }] };
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1296
1438
|
];
|
|
1297
1439
|
/** Live MCP server instance — used to register plugin tools dynamically. */
|
|
1298
1440
|
let mcpServerInstance = null;
|
package/dist/server/state.js
CHANGED
|
@@ -10,6 +10,7 @@ import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js'
|
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
|
+
import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
|
|
13
14
|
const DEFAULT_DOC = {
|
|
14
15
|
type: 'doc',
|
|
15
16
|
content: [{ type: 'paragraph', content: [] }],
|
|
@@ -733,9 +734,28 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
|
|
|
733
734
|
}
|
|
734
735
|
return processed;
|
|
735
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Effective auto-accept for a doc: true if the doc's own frontmatter has it,
|
|
739
|
+
* OR if any workspace/container ancestor in the workspace tree has it on.
|
|
740
|
+
*/
|
|
741
|
+
export function isAutoAcceptActive(filename, metadata) {
|
|
742
|
+
if (metadata?.autoAccept === true)
|
|
743
|
+
return true;
|
|
744
|
+
if (!filename)
|
|
745
|
+
return false;
|
|
746
|
+
// Lazy import to avoid circular dep between state.ts and workspaces.ts
|
|
747
|
+
try {
|
|
748
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
749
|
+
const { isAutoAcceptInheritedForDoc } = require('./workspaces.js');
|
|
750
|
+
return isAutoAcceptInheritedForDoc(filename);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
736
756
|
/** Apply changes to the active document singleton. */
|
|
737
757
|
function applyChangesToDocument(changes) {
|
|
738
|
-
const autoAccept = state.metadata
|
|
758
|
+
const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
|
|
739
759
|
const processed = applyChangesToDoc(state.document, changes, autoAccept);
|
|
740
760
|
if (processed.length > 0) {
|
|
741
761
|
state.lastModified = new Date();
|
|
@@ -755,7 +775,7 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
755
775
|
if (!result)
|
|
756
776
|
return { success: false, error: 'No edits matched' };
|
|
757
777
|
// Inline edit decoration only matters when there's a review surface — skip in autoAccept.
|
|
758
|
-
if (state.metadata
|
|
778
|
+
if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
|
|
759
779
|
result.node.attrs = {
|
|
760
780
|
...result.node.attrs,
|
|
761
781
|
pendingTextEdits: result.textEdits,
|
|
@@ -1015,6 +1035,16 @@ export function getPendingDocInfo() {
|
|
|
1015
1035
|
// ============================================================================
|
|
1016
1036
|
function writeToDisk() {
|
|
1017
1037
|
ensureDataDir();
|
|
1038
|
+
// Capture old forward links BEFORE we overwrite the file — needed by the
|
|
1039
|
+
// backlinks engine to know which target docs to refresh when source changes.
|
|
1040
|
+
// Skip for external docs (they don't participate in the doc graph).
|
|
1041
|
+
let oldForwardLinks = [];
|
|
1042
|
+
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
1043
|
+
try {
|
|
1044
|
+
oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
|
|
1045
|
+
}
|
|
1046
|
+
catch { /* best-effort */ }
|
|
1047
|
+
}
|
|
1018
1048
|
let markdown;
|
|
1019
1049
|
if (isExternalDoc(state.filePath)) {
|
|
1020
1050
|
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
|
|
@@ -1054,6 +1084,17 @@ function writeToDisk() {
|
|
|
1054
1084
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
1055
1085
|
}
|
|
1056
1086
|
catch { /* ignore */ }
|
|
1087
|
+
// Backlinks update: refresh target docs' backlinks frontmatter if source's
|
|
1088
|
+
// forward links changed. Best-effort — never blocks the save it follows.
|
|
1089
|
+
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
1090
|
+
try {
|
|
1091
|
+
const newForwardLinks = extractForwardLinks(state.document, state.docId);
|
|
1092
|
+
updateBacklinksForSource(state.docId, newForwardLinks, oldForwardLinks);
|
|
1093
|
+
}
|
|
1094
|
+
catch (err) {
|
|
1095
|
+
console.error('[State] backlinks update failed:', err);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1057
1098
|
}
|
|
1058
1099
|
export function save() {
|
|
1059
1100
|
if (!state.filePath) {
|
|
@@ -1501,9 +1542,9 @@ export function populateDocumentFile(filename, doc) {
|
|
|
1501
1542
|
const targetPath = resolveDocPath(filename);
|
|
1502
1543
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
1503
1544
|
const parsed = markdownToTiptap(raw);
|
|
1504
|
-
// Skip pending tagging when the target doc has autoAccept on —
|
|
1545
|
+
// Skip pending tagging when the target doc effectively has autoAccept on —
|
|
1505
1546
|
// content commits directly as accepted.
|
|
1506
|
-
if (parsed.metadata
|
|
1547
|
+
if (!isAutoAcceptActive(filename, parsed.metadata)) {
|
|
1507
1548
|
markAllNodesAsPending(doc, 'insert');
|
|
1508
1549
|
}
|
|
1509
1550
|
flushDocToFile(filename, doc, parsed.title, parsed.metadata);
|
|
@@ -1541,7 +1582,7 @@ export function applyChangesToFile(filename, changes) {
|
|
|
1541
1582
|
docId = metadata.docId || '';
|
|
1542
1583
|
isTemp = false;
|
|
1543
1584
|
}
|
|
1544
|
-
const autoAccept = metadata
|
|
1585
|
+
const autoAccept = isAutoAcceptActive(filename, metadata);
|
|
1545
1586
|
const processed = applyChangesToDoc(doc, changes, autoAccept);
|
|
1546
1587
|
if (processed.length > 0) {
|
|
1547
1588
|
flushDocToFile(filename, doc, title, metadata);
|
|
@@ -1597,7 +1638,7 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
|
|
|
1597
1638
|
const result = applyTextEditsToNode(originalNode, edits);
|
|
1598
1639
|
if (!result)
|
|
1599
1640
|
return { success: false, error: 'No edits matched' };
|
|
1600
|
-
const autoAccept = metadata
|
|
1641
|
+
const autoAccept = isAutoAcceptActive(filename, metadata);
|
|
1601
1642
|
// pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
|
|
1602
1643
|
// since the change commits directly.
|
|
1603
1644
|
if (!autoAccept) {
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
6
|
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, crossMoveContainer, promoteContainerToWorkspace, } from './workspaces.js';
|
|
7
|
+
import { findNode } from './workspace-tree.js';
|
|
8
|
+
import { deleteDocument } from './documents.js';
|
|
9
|
+
function collectDocFilesInSubtree(nodes, out = []) {
|
|
10
|
+
for (const n of nodes) {
|
|
11
|
+
if (n.type === 'doc')
|
|
12
|
+
out.push(n.file);
|
|
13
|
+
else if (n.type === 'container')
|
|
14
|
+
collectDocFilesInSubtree(n.items, out);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
7
18
|
export function createWorkspaceRouter(b) {
|
|
8
19
|
const router = Router();
|
|
9
20
|
router.get('/api/workspaces', (_req, res) => {
|
|
@@ -130,11 +141,28 @@ export function createWorkspaceRouter(b) {
|
|
|
130
141
|
res.status(400).json({ error: err.message });
|
|
131
142
|
}
|
|
132
143
|
});
|
|
133
|
-
router.delete('/api/workspaces/:filename/containers/:containerId', (req, res) => {
|
|
134
|
-
try {
|
|
144
|
+
router.delete('/api/workspaces/:filename/containers/:containerId', async (req, res) => {
|
|
145
|
+
try {
|
|
146
|
+
const cascade = req.query.cascade === 'true' || req.query.cascade === '1';
|
|
147
|
+
let deletedDocs = 0;
|
|
148
|
+
if (cascade) {
|
|
149
|
+
// Find the container, collect all docs in its subtree, delete them from disk.
|
|
150
|
+
const current = getWorkspace(req.params.filename);
|
|
151
|
+
const found = findNode(current.root, (n) => n.type === 'container' && n.id === req.params.containerId);
|
|
152
|
+
if (found && found.node.type === 'container') {
|
|
153
|
+
const files = collectDocFilesInSubtree(found.node.items);
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
try {
|
|
156
|
+
await deleteDocument(file);
|
|
157
|
+
deletedDocs++;
|
|
158
|
+
}
|
|
159
|
+
catch { /* swallow per-doc failures; keep going */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
135
163
|
const ws = removeContainer(req.params.filename, req.params.containerId);
|
|
136
164
|
b.broadcastWorkspacesChanged();
|
|
137
|
-
res.json(ws);
|
|
165
|
+
res.json({ ...ws, deletedDocs });
|
|
138
166
|
}
|
|
139
167
|
catch (err) {
|
|
140
168
|
res.status(400).json({ error: err.message });
|
|
@@ -437,6 +437,91 @@ export function getWorkspaceAssignedFiles() {
|
|
|
437
437
|
}
|
|
438
438
|
return assigned;
|
|
439
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Walk every workspace and return true if `file` is inside one where auto-accept
|
|
442
|
+
* is on at the workspace level or on any ancestor container. Returns false when
|
|
443
|
+
* the doc isn't in any workspace or no ancestor has the flag set.
|
|
444
|
+
*
|
|
445
|
+
* A doc's own `autoAccept` frontmatter is NOT checked here — that's the caller's
|
|
446
|
+
* job (combined with this lookup, OR-style).
|
|
447
|
+
*/
|
|
448
|
+
export function isAutoAcceptInheritedForDoc(file) {
|
|
449
|
+
const workspaces = listWorkspaces();
|
|
450
|
+
for (const info of workspaces) {
|
|
451
|
+
try {
|
|
452
|
+
const ws = readWorkspace(info.filename);
|
|
453
|
+
// Walk root to find the doc; collect ancestor containers along the way.
|
|
454
|
+
function walk(nodes, ancestors) {
|
|
455
|
+
for (const n of nodes) {
|
|
456
|
+
if (n.type === 'doc' && n.file === file) {
|
|
457
|
+
if (ws.autoAccept === true)
|
|
458
|
+
return true;
|
|
459
|
+
for (const c of ancestors)
|
|
460
|
+
if (c.autoAccept === true)
|
|
461
|
+
return true;
|
|
462
|
+
return false; // doc lives here but no ancestor flag set
|
|
463
|
+
}
|
|
464
|
+
if (n.type === 'container') {
|
|
465
|
+
const result = walk(n.items, [...ancestors, n]);
|
|
466
|
+
if (result !== null)
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const found = walk(ws.root, []);
|
|
473
|
+
if (found === true)
|
|
474
|
+
return true;
|
|
475
|
+
// if found === false, doc IS in this workspace but no ancestor flag is on;
|
|
476
|
+
// continue scanning other workspaces (a doc could be referenced in multiple)
|
|
477
|
+
}
|
|
478
|
+
catch { /* skip corrupt manifests */ }
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
/** Set or clear workspace-level autoAccept. */
|
|
483
|
+
export function setWorkspaceAutoAccept(wsFile, enabled) {
|
|
484
|
+
const ws = readWorkspace(wsFile);
|
|
485
|
+
if (enabled)
|
|
486
|
+
ws.autoAccept = true;
|
|
487
|
+
else
|
|
488
|
+
delete ws.autoAccept;
|
|
489
|
+
writeWorkspace(wsFile, ws);
|
|
490
|
+
}
|
|
491
|
+
/** Set or clear container-level autoAccept. */
|
|
492
|
+
export function setContainerAutoAccept(wsFile, containerId, enabled) {
|
|
493
|
+
const ws = readWorkspace(wsFile);
|
|
494
|
+
const found = findContainer(ws.root, containerId);
|
|
495
|
+
if (!found)
|
|
496
|
+
throw new Error(`Container ${containerId} not found in ${wsFile}`);
|
|
497
|
+
if (enabled)
|
|
498
|
+
found.node.autoAccept = true;
|
|
499
|
+
else
|
|
500
|
+
delete found.node.autoAccept;
|
|
501
|
+
writeWorkspace(wsFile, ws);
|
|
502
|
+
}
|
|
503
|
+
/** Collect every file inside a workspace or container subtree. Used for broadcast. */
|
|
504
|
+
export function collectFilesInWorkspace(wsFile) {
|
|
505
|
+
try {
|
|
506
|
+
const ws = readWorkspace(wsFile);
|
|
507
|
+
return collectAllFiles(ws.root);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
export function collectFilesInContainer(wsFile, containerId) {
|
|
514
|
+
try {
|
|
515
|
+
const ws = readWorkspace(wsFile);
|
|
516
|
+
const found = findContainer(ws.root, containerId);
|
|
517
|
+
if (!found)
|
|
518
|
+
return [];
|
|
519
|
+
return collectAllFiles(found.node.items);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
440
525
|
export function getWorkspaceStructure(filename) {
|
|
441
526
|
return getWorkspace(filename);
|
|
442
527
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.7.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -278,13 +278,9 @@ When creating **two or more documents together** — a tweet thread saved as sep
|
|
|
278
278
|
- `reply` / `quote` types still require `url`
|
|
279
279
|
- For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
|
|
280
280
|
|
|
281
|
-
##
|
|
281
|
+
## Companion Skills (optional)
|
|
282
282
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
**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.
|
|
286
|
-
|
|
287
|
-
**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.
|
|
283
|
+
For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill — 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
|
|
288
284
|
|
|
289
285
|
## Workflow
|
|
290
286
|
|