openwriter 0.22.1 → 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-OAhOx_JE.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-DFbNF7q0.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() {
@@ -7,13 +7,13 @@ import { createServer } from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
9
  import { existsSync, readFileSync } from 'fs';
10
- import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityLogSeed } from './ws.js';
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 });
@@ -826,6 +958,10 @@ export async function startHttpServer(options = {}) {
826
958
  broadcastDocumentsChanged();
827
959
  broadcastWorkspacesChanged();
828
960
  broadcastPendingDocsChanged();
961
+ // Re-seed the Activity tab from the new profile's disk log. The buffer
962
+ // was just cleared in clearAllCaches(); this push replaces whatever the
963
+ // client had from the previous profile.
964
+ broadcastActivityLogSeed();
829
965
  res.json({ success: true, active: name });
830
966
  }
831
967
  catch (err) {
@@ -977,7 +1113,7 @@ export async function startHttpServer(options = {}) {
977
1113
  // writes (e.g. declare_writes in flight) aren't cleared alongside this one.
978
1114
  const spinnerTitle = label ? `${label}: ${title}` : title;
979
1115
  const spinnerKey = `sidebar-action:${action}:${filename}:${Date.now()}`;
980
- broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey);
1116
+ broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey, filename, sourceDocId);
981
1117
  // Intercept res.json to clear spinner when plugin handler responds
982
1118
  const origJson = res.json.bind(res);
983
1119
  res.json = (body) => {
@@ -1035,6 +1171,25 @@ export async function startHttpServer(options = {}) {
1035
1171
  });
1036
1172
  // Sync post history from platform (catch posts made while app was closed)
1037
1173
  syncPostHistory().catch(() => { });
1174
+ // Heal stale references frontmatter on every boot. Older docs that were
1175
+ // written before the prose-link sync pipeline existed (or imported from
1176
+ // legacy formats) may have prose `doc:` links in body but no matching
1177
+ // `references:` array in frontmatter — which makes the inverse-index scan
1178
+ // return zero inbounds for their targets. One-shot rescan + write is
1179
+ // idempotent and finishes in <1s for ~200-doc corpora; runs in background
1180
+ // so it never blocks the listen.
1181
+ (async () => {
1182
+ try {
1183
+ const { rebuildAllReferences } = await import('./backlinks.js');
1184
+ const result = rebuildAllReferences();
1185
+ if (result.updated > 0) {
1186
+ console.log(`[Boot] Healed references frontmatter on ${result.updated}/${result.scanned} docs`);
1187
+ }
1188
+ }
1189
+ catch (err) {
1190
+ console.error('[Boot] rebuildAllReferences failed:', err);
1191
+ }
1192
+ })();
1038
1193
  // Open browser unless --no-open or running as MCP stdio pipe
1039
1194
  const isMcpStdio = !process.stdout.isTTY;
1040
1195
  if (!options.noOpen && !isMcpStdio) {