openwriter 0.17.0 → 0.18.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.
@@ -257,13 +257,15 @@ export function listArchivedDocuments() {
257
257
  // ============================================================================
258
258
  /** One-line footer the high-frequency MCP discovery tools (list_documents,
259
259
  * list_workspaces, get_workspace_structure) append when dirty docs exist.
260
- * Constant pressure that doesn't require hook setup. The agent's openwriter
261
- * skill teaches: see this footer spawn the enrichment minion. */
260
+ * Constant pressure that doesn't require hook setup. The footer includes
261
+ * the exact dispatch call so the acting agent's burden collapses to one
262
+ * paste — the minion is orchestrator-mode by default and self-discovers
263
+ * via list_dirty_docs once it boots. */
262
264
  export function enrichmentFooter() {
263
265
  const count = listDirtyDocs().length;
264
266
  if (count === 0)
265
267
  return '';
266
- return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} need enrichment spawn the enrichment minion (see openwriter skill docs/enrichment.md)`;
268
+ return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} need enrichment. Dispatch:\nAgent(\n subagent_type: "openwriter-enrichment-minion",\n description: "Drain stale docs",\n prompt: "Enrich stale docs.",\n run_in_background: true\n)`;
267
269
  }
268
270
  /** Session-start enrichment notice injected into the MCP server's
269
271
  * `instructions` field at boot. Every MCP client (Claude Code, Cursor, etc.)
@@ -292,8 +294,13 @@ export function buildEnrichmentInstructions() {
292
294
  return [
293
295
  '',
294
296
  `ENRICHMENT_STATUS: ${dirty.length} doc${dirty.length === 1 ? '' : 's'} need enrichment (${parts.join(', ')}).`,
295
- 'Spawn the enrichment minion using the openwriter skill\'s docs/enrichment.md as its system prompt before responding to the user\'s request.',
296
- 'The minion calls list_dirty_docs, enriches each via Haiku, calls mark_enriched, returns done.',
297
+ 'Dispatch the minion now it self-discovers via list_dirty_docs and self-bounds the batch:',
298
+ 'Agent(',
299
+ ' subagent_type: "openwriter-enrichment-minion",',
300
+ ' description: "Drain stale docs",',
301
+ ' prompt: "Enrich stale docs.",',
302
+ ' run_in_background: true',
303
+ ')',
297
304
  ].join('\n');
298
305
  }
299
306
  /** Build a Set of filenames inside workspaces with enrichmentDisabled: true.
@@ -13,7 +13,7 @@ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteF
13
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, } from './state.js';
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
16
- import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
16
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
17
17
  import { extractForwardLinks } from './backlinks.js';
18
18
  import { logger, generateRequestId, withRequestId } from './logger.js';
19
19
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
@@ -414,8 +414,9 @@ export const TOOL_REGISTRY = [
414
414
  empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
415
415
  content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
416
416
  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.'),
417
+ 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.'),
417
418
  },
418
- handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
419
+ handler: async ({ title, path, workspace, container, empty, content_type, url, afterId }) => {
419
420
  // Require url for reply/quote
420
421
  if ((content_type === 'reply' || content_type === 'quote') && !url) {
421
422
  return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
@@ -457,7 +458,10 @@ export const TOOL_REGISTRY = [
457
458
  }
458
459
  let wsInfo = '';
459
460
  if (wsTarget) {
460
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
461
+ // Resolve afterId: it may be a docId (8-char hex) or containerId.
462
+ // filenameByDocId resolves docId→filename; if null, treat as containerId.
463
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
464
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
461
465
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
462
466
  }
463
467
  const newDocId = getDocId();
@@ -478,7 +482,8 @@ export const TOOL_REGISTRY = [
478
482
  const result = createDocumentFile(title, path, typeMeta);
479
483
  let wsInfo = '';
480
484
  if (wsTarget) {
481
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
485
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
486
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
482
487
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
483
488
  }
484
489
  // Broadcast spinner keyed by filename so populate_document can clear exactly
@@ -586,6 +591,7 @@ export const TOOL_REGISTRY = [
586
591
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
587
592
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
588
593
  path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
594
+ afterId: z.string().optional().describe('Place the new doc immediately after this docId or containerId inside its parent. Omit to append to the bottom (default, ascending-order convention). Requires workspace.'),
589
595
  })).min(1).describe('List of documents to declare (minimum 1).'),
590
596
  },
591
597
  handler: async ({ writes }) => {
@@ -612,7 +618,8 @@ export const TOOL_REGISTRY = [
612
618
  const typeMeta = resolveTypeMeta(w.content_type, w.url);
613
619
  const result = createDocumentFile(w.title, w.path, typeMeta);
614
620
  if (wsTarget) {
615
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
621
+ const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
622
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
616
623
  }
617
624
  broadcastWritingStarted(w.title, wsTarget, result.filename);
618
625
  broadcastedKeys.push(result.filename);
@@ -1090,9 +1097,13 @@ export const TOOL_REGISTRY = [
1090
1097
  workspaceFile: z.string().describe('Workspace manifest filename'),
1091
1098
  name: z.string().describe('Container name (e.g. "Chapters", "Research")'),
1092
1099
  parentContainerId: z.string().optional().describe('Parent container ID for nesting (null = root level)'),
1100
+ afterId: z.string().optional().describe('Place the new container 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).'),
1093
1101
  },
1094
- handler: async ({ workspaceFile, name, parentContainerId }) => {
1095
- const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name);
1102
+ handler: async ({ workspaceFile, name, parentContainerId, afterId }) => {
1103
+ // Resolve afterId: may be docId (8-char hex) or containerId. filenameByDocId
1104
+ // resolves docId→filename; if null, treat as containerId.
1105
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
1106
+ const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name, afterRef);
1096
1107
  broadcastWorkspacesChanged();
1097
1108
  return { content: [{ type: 'text', text: `Created container "${name}" (id:${result.containerId})` }] };
1098
1109
  },
@@ -89,10 +89,13 @@ export function addDocToContainer(root, containerId, file, title, afterIdentifie
89
89
  }
90
90
  }
91
91
  else {
92
- target.unshift(doc);
92
+ // Default: append to the bottom of the parent's child list. This matches
93
+ // the ascending-order convention (newest at bottom, oldest at top). Callers
94
+ // that want top-insertion must pass an explicit afterIdentifier.
95
+ target.push(doc);
93
96
  }
94
97
  }
95
- export function addContainer(root, parentContainerId, name) {
98
+ export function addContainer(root, parentContainerId, name, afterIdentifier) {
96
99
  const depth = getContainerDepth(root, parentContainerId);
97
100
  if (depth >= MAX_DEPTH) {
98
101
  throw new Error(`Maximum nesting depth (${MAX_DEPTH}) reached`);
@@ -106,7 +109,19 @@ export function addContainer(root, parentContainerId, name) {
106
109
  name,
107
110
  items: [],
108
111
  };
109
- target.unshift(container);
112
+ if (afterIdentifier) {
113
+ const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
114
+ if (afterIdx === -1) {
115
+ target.push(container);
116
+ }
117
+ else {
118
+ target.splice(afterIdx + 1, 0, container);
119
+ }
120
+ }
121
+ else {
122
+ // Default: append to the bottom (ascending-order convention).
123
+ target.push(container);
124
+ }
110
125
  return container;
111
126
  }
112
127
  // ============================================================================
@@ -210,9 +210,9 @@ export function reorderDoc(wsFile, file, afterFile) {
210
210
  // ============================================================================
211
211
  // CONTAINER OPERATIONS
212
212
  // ============================================================================
213
- export function addContainerToWorkspace(wsFile, parentContainerId, name) {
213
+ export function addContainerToWorkspace(wsFile, parentContainerId, name, afterIdentifier) {
214
214
  const ws = getWorkspace(wsFile);
215
- const container = addContainerToTree(ws.root, parentContainerId, name);
215
+ const container = addContainerToTree(ws.root, parentContainerId, name, afterIdentifier ?? null);
216
216
  writeWorkspace(wsFile, ws);
217
217
  return { workspace: ws, containerId: container.id };
218
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",