openwriter 0.23.0 → 0.24.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,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-C65mFCh7.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-Ch3Z898_.css">
13
+ <script type="module" crossorigin src="/assets/index-DmHLFNTs.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-AWIKUHJ_.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -113,6 +113,8 @@ export function recordActivity(partial) {
113
113
  };
114
114
  if (partial.detail)
115
115
  evt.detail = partial.detail;
116
+ if (partial.docId)
117
+ evt.docId = partial.docId;
116
118
  if (partial.filename)
117
119
  evt.filename = partial.filename;
118
120
  if (partial.nodeId)
@@ -119,7 +119,7 @@ export function listDocuments() {
119
119
  isActive: fullPath === currentPath,
120
120
  ...(data.docId ? { docId: data.docId } : {}),
121
121
  ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
122
- ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
122
+ ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : {}),
123
123
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
124
124
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
125
125
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
@@ -137,6 +137,9 @@ export function listDocuments() {
137
137
  ...(typeof data.logline === 'string' && data.logline ? { logline: data.logline } : {}),
138
138
  ...(typeof data.status === 'string' && data.status ? { status: data.status } : {}),
139
139
  ...(data.enrichmentStale === true ? { enrichmentStale: true } : {}),
140
+ // Sort-request fields — sidebar reads these to render the badge / proposal popover.
141
+ ...(data.sortRequest && typeof data.sortRequest === 'object' ? { sortRequest: data.sortRequest } : {}),
142
+ ...(typeof data.lastSortedAt === 'string' ? { lastSortedAt: data.lastSortedAt } : {}),
140
143
  };
141
144
  }
142
145
  catch {
@@ -171,8 +174,8 @@ export function listDocuments() {
171
174
  wordCount,
172
175
  isActive: extPath === currentPath,
173
176
  ...(data.docId ? { docId: data.docId } : {}),
174
- ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
175
- ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
177
+ ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : {}),
178
+ ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : {}),
176
179
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
177
180
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
178
181
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
@@ -303,6 +306,97 @@ export function buildEnrichmentInstructions() {
303
306
  ')',
304
307
  ].join('\n');
305
308
  }
309
+ /** Footer on the three high-frequency discovery tools when sort requests are
310
+ * pending. Stacks beneath enrichmentFooter. Sorting is a judgment call —
311
+ * handle it inline in conversation, don't dispatch a subagent. */
312
+ export function sortFooter() {
313
+ const count = listPendingSorts().length;
314
+ if (count === 0)
315
+ return '';
316
+ return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} awaiting sort. Call list_pending_sorts to handle inline — discuss destinations with the user, then either move + mark_sorted (when the user confirms in chat) or propose_sort (UI accept/reject for batches).`;
317
+ }
318
+ /** Session-start sort notice — stacks with buildEnrichmentInstructions inside
319
+ * the MCP `instructions` field. Empty when no sorts pending. */
320
+ export function buildSortInstructions() {
321
+ const pending = listPendingSorts();
322
+ if (pending.length === 0)
323
+ return '';
324
+ return [
325
+ '',
326
+ `SORT_STATUS: ${pending.length} doc${pending.length === 1 ? '' : 's'} awaiting sort.`,
327
+ 'Call list_pending_sorts when the user engages or you have a natural moment. For each doc: read it, pick a destination (get_workspace_structure for tree shape + container purpose: hints; browse for what other docs in a container are about). For 1–3 docs, discuss in chat then move_item + mark_sorted on confirmation. For many docs, write propose_sort entries and let the user accept/reject via the sidebar popover. Sorting is a judgment call — bias toward asking when a doc could plausibly live in two places.',
328
+ ].join('\n');
329
+ }
330
+ /**
331
+ * List documents with a pending sortRequest. Returns identity + (optional)
332
+ * proposal — no bodies. The agent calls this first to know what to work on.
333
+ *
334
+ * Optional `scopeWorkspace` narrows to one workspace.
335
+ */
336
+ export function listPendingSorts(scopeWorkspace) {
337
+ ensureDataDir();
338
+ const ownership = buildWorkspaceOwnershipMap();
339
+ const containerByFile = buildContainerOwnershipMap();
340
+ let scopeFiles = null;
341
+ if (scopeWorkspace) {
342
+ try {
343
+ const ws = getWorkspace(scopeWorkspace);
344
+ scopeFiles = new Set(collectAllFiles(ws.root));
345
+ }
346
+ catch {
347
+ return [];
348
+ }
349
+ }
350
+ const out = [];
351
+ for (const f of readdirSync(getDataDir()).filter((f) => f.endsWith('.md'))) {
352
+ if (scopeFiles && !scopeFiles.has(f))
353
+ continue;
354
+ try {
355
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
356
+ const { data } = matter(raw);
357
+ if (data.archivedAt)
358
+ continue;
359
+ const req = data.sortRequest;
360
+ if (!req || typeof req !== 'object')
361
+ continue;
362
+ out.push({
363
+ docId: data.docId || '',
364
+ filename: f,
365
+ title: data.title || f.replace(/\.md$/, ''),
366
+ ...(ownership.get(f) ? { currentWorkspaceFile: ownership.get(f) } : {}),
367
+ ...(containerByFile.has(f) ? { currentContainerId: containerByFile.get(f) ?? null } : {}),
368
+ requestedAt: typeof req.requestedAt === 'string' ? req.requestedAt : '',
369
+ ...(req.proposal && typeof req.proposal === 'object' ? { proposal: req.proposal } : {}),
370
+ });
371
+ }
372
+ catch { /* skip unreadable */ }
373
+ }
374
+ return out;
375
+ }
376
+ /** Map filename → containerId (or null for workspace root) for every doc inside
377
+ * any workspace. Used to attribute current location to pending-sort entries. */
378
+ function buildContainerOwnershipMap() {
379
+ const map = new Map();
380
+ for (const info of listWorkspaces()) {
381
+ try {
382
+ const ws = getWorkspace(info.filename);
383
+ walkForContainerOwnership(ws.root, null, (file, containerId) => {
384
+ if (!map.has(file))
385
+ map.set(file, containerId);
386
+ });
387
+ }
388
+ catch { /* skip corrupt */ }
389
+ }
390
+ return map;
391
+ }
392
+ function walkForContainerOwnership(nodes, parentContainerId, cb) {
393
+ for (const n of nodes) {
394
+ if (n.type === 'doc')
395
+ cb(n.file, parentContainerId);
396
+ else if (n.type === 'container')
397
+ walkForContainerOwnership(n.items, n.id, cb);
398
+ }
399
+ }
306
400
  /** Build a Set of filenames inside workspaces with enrichmentDisabled: true.
307
401
  * These docs are excluded from list_dirty_docs and crawl results. */
308
402
  function collectOptedOutFilenames() {
@@ -11,9 +11,9 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
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, markAsAgentStub } from './state.js';
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 } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
16
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
17
17
  import { createWorkspaceRouter } from './workspace-routes.js';
18
18
  import { createLinkRouter } from './link-routes.js';
19
19
  import { createTweetRouter } from './tweet-routes.js';
@@ -259,6 +259,138 @@ export async function startHttpServer(options = {}) {
259
259
  res.status(500).json({ error: err.message });
260
260
  }
261
261
  });
262
+ // Tag one or more docs as "needs sorting" — the main agent picks them up via
263
+ // list_pending_sorts. Bulk and folder-wide sort requests are just this endpoint
264
+ // with the appropriate filenames array; the frontend collects the file list.
265
+ // Body: { filenames: string[] }.
266
+ //
267
+ // Active-doc branch: setMetadata + bumpDocVersion + save() so the in-memory
268
+ // editor state stays in sync (writing to disk via setSortRequestOnFile would
269
+ // trip the editor's external-write detector and reload from disk mid-edit).
270
+ // Mirrors the /api/auto-accept pattern.
271
+ app.post('/api/documents/sort-request', (req, res) => {
272
+ try {
273
+ const filenames = Array.isArray(req.body?.filenames) ? req.body.filenames : [];
274
+ if (filenames.length === 0)
275
+ return res.status(400).json({ error: 'filenames array is required' });
276
+ const activeFn = getActiveFilename();
277
+ const requestedAt = new Date().toISOString();
278
+ for (const f of filenames) {
279
+ if (f === activeFn) {
280
+ setMetadata({ sortRequest: { requestedAt } });
281
+ bumpDocVersion();
282
+ save();
283
+ broadcastMetadataChanged(getMetadata());
284
+ }
285
+ else {
286
+ setSortRequestOnFile(f);
287
+ }
288
+ }
289
+ broadcastDocumentsChanged();
290
+ res.json({ success: true, count: filenames.length });
291
+ }
292
+ catch (err) {
293
+ res.status(500).json({ error: err.message });
294
+ }
295
+ });
296
+ // Accept a sort proposal: apply the agent's move, clear the request, stamp
297
+ // lastSortedAt. The destination is read from the doc's stored proposal —
298
+ // no client-supplied target so a stale UI can't move the doc somewhere wrong.
299
+ // Body: { filename: string }.
300
+ app.post('/api/documents/sort-accept', async (req, res) => {
301
+ try {
302
+ const filename = req.body?.filename;
303
+ if (!filename)
304
+ return res.status(400).json({ error: 'filename required' });
305
+ const pending = listPendingSorts().find((p) => p.filename === filename);
306
+ if (!pending)
307
+ return res.status(404).json({ error: 'no pending sort for this file' });
308
+ const proposal = pending.proposal;
309
+ if (!proposal)
310
+ return res.status(400).json({ error: 'no proposal to accept' });
311
+ const { addDoc, moveDoc, getDocTitle, removeDocFromAllWorkspaces: removeDocFromAll, getWorkspace } = await import('./workspaces.js');
312
+ const { findNode } = await import('./workspace-tree.js');
313
+ const targetWs = getWorkspace(proposal.wsFilename);
314
+ const inTarget = findNode(targetWs.root, (n) => n.type === 'doc' && n.file === filename);
315
+ if (inTarget) {
316
+ moveDoc(proposal.wsFilename, filename, proposal.containerId, null);
317
+ }
318
+ else {
319
+ removeDocFromAll(filename);
320
+ addDoc(proposal.wsFilename, proposal.containerId, filename, getDocTitle(filename), null);
321
+ }
322
+ // Clear sortRequest. Active-doc branch keeps the write in the editor's
323
+ // in-memory pipeline (avoid external-write detection mid-edit).
324
+ if (filename === getActiveFilename()) {
325
+ const live = getMetadata();
326
+ delete live.sortRequest;
327
+ setMetadata({ lastSortedAt: new Date().toISOString() });
328
+ bumpDocVersion();
329
+ save();
330
+ broadcastMetadataChanged(getMetadata());
331
+ }
332
+ else {
333
+ clearSortRequestOnFile(filename);
334
+ }
335
+ broadcastWorkspacesChanged();
336
+ broadcastDocumentsChanged();
337
+ res.json({ success: true });
338
+ }
339
+ catch (err) {
340
+ res.status(500).json({ error: err.message });
341
+ }
342
+ });
343
+ // Reject a sort proposal (or cancel a request that hasn't been proposed yet):
344
+ // clear the request without moving the doc. Stamps lastSortedAt either way
345
+ // since the request is resolved. Body: { filename: string }.
346
+ //
347
+ // Active-doc branch matches /api/documents/sort-request — keep the write in
348
+ // the editor's in-memory pipeline so the external-write detector doesn't fire.
349
+ app.post('/api/documents/sort-reject', (req, res) => {
350
+ try {
351
+ const filename = req.body?.filename;
352
+ if (!filename)
353
+ return res.status(400).json({ error: 'filename required' });
354
+ if (filename === getActiveFilename()) {
355
+ const live = getMetadata();
356
+ delete live.sortRequest;
357
+ setMetadata({ lastSortedAt: new Date().toISOString() });
358
+ bumpDocVersion();
359
+ save();
360
+ broadcastMetadataChanged(getMetadata());
361
+ }
362
+ else {
363
+ clearSortRequestOnFile(filename);
364
+ }
365
+ broadcastDocumentsChanged();
366
+ res.json({ success: true });
367
+ }
368
+ catch (err) {
369
+ res.status(500).json({ error: err.message });
370
+ }
371
+ });
372
+ // Set the user-authored `purpose:` hint on a workspace or container — gives
373
+ // the sort agent a strong signal about what belongs there. Empty string clears.
374
+ // Body: { wsFile, containerId?, purpose }.
375
+ app.post('/api/sort-purpose', async (req, res) => {
376
+ try {
377
+ const wsFile = req.body?.wsFile;
378
+ const containerId = req.body?.containerId;
379
+ const purpose = typeof req.body?.purpose === 'string' ? req.body.purpose : '';
380
+ if (!wsFile)
381
+ return res.status(400).json({ error: 'wsFile required' });
382
+ const { setWorkspacePurpose, setContainerPurpose } = await import('./workspaces.js');
383
+ if (containerId)
384
+ setContainerPurpose(wsFile, containerId, purpose);
385
+ else
386
+ setWorkspacePurpose(wsFile, purpose);
387
+ broadcastWorkspacesChanged();
388
+ res.json({ success: true });
389
+ }
390
+ catch (err) {
391
+ res.status(500).json({ error: err.message });
392
+ }
393
+ });
262
394
  app.post('/api/save', (_req, res) => {
263
395
  save();
264
396
  res.json({ success: true });
@@ -981,7 +1113,7 @@ export async function startHttpServer(options = {}) {
981
1113
  // writes (e.g. declare_writes in flight) aren't cleared alongside this one.
982
1114
  const spinnerTitle = label ? `${label}: ${title}` : title;
983
1115
  const spinnerKey = `sidebar-action:${action}:${filename}:${Date.now()}`;
984
- broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey);
1116
+ broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey, filename, sourceDocId);
985
1117
  // Intercept res.json to clear spinner when plugin handler responds
986
1118
  const origJson = res.json.bind(res);
987
1119
  res.json = (body) => {