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.
package/dist/server/ws.js CHANGED
@@ -2,12 +2,13 @@
2
2
  * WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
- import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, isAgentStub, unmarkAgentStub, } from './state.js';
6
- import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
5
+ import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
6
+ import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
8
  import { canonicalizeIdentifier } from './helpers.js';
9
9
  import { nodeTextPreview, diagLog } from './pending-overlay.js';
10
10
  import { generateRequestId, withRequestId } from './logger.js';
11
+ import { recordActivity, loadActivityTail } from './activity-log.js';
11
12
  /** Walk a doc and return a per-pending-node summary for diagnostic logging.
12
13
  * Produces lines like "nodeId/status text=\"...\" orig=\"...\"" — empty
13
14
  * if the doc has no pending nodes. adr: adr/pending-overlay-model.md */
@@ -181,6 +182,18 @@ export function setupWebSocket(server) {
181
182
  }
182
183
  console.warn(`[WS] Broadcast external-write-conflict for ${filename}`);
183
184
  });
185
+ // Auto-title applied: the save() pipeline derived a title from body
186
+ // content because the doc was still on a default title. Rename the
187
+ // file on disk if it was a temp file, then broadcast so the sidebar
188
+ // and active editor reflect the new title without a page reload.
189
+ onAutoTitleApplied((newTitle) => {
190
+ const promoted = promoteTempFile(newTitle);
191
+ if (promoted) {
192
+ broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
193
+ }
194
+ broadcastMetadataChanged(getMetadata());
195
+ broadcastDocumentsChanged();
196
+ });
184
197
  wss.on('connection', (ws) => {
185
198
  clients.add(ws);
186
199
  console.log(`[WS] Client connected (total: ${clients.size})`);
@@ -210,6 +223,16 @@ export function setupWebSocket(server) {
210
223
  type: 'pending-docs-changed',
211
224
  pendingDocs: getPendingDocInfo(),
212
225
  }));
226
+ // Seed the right-rail Activity tab with persisted history (newest-first).
227
+ // The disk log is the source of truth; the client mirrors what we send.
228
+ // Backfilled before send so older entries (recorded before the writing-
229
+ // started path threaded an explicit disk filename) pick up a filename
230
+ // from the headline-title lookup and become clickable.
231
+ // adr: adr/right-rail.md
232
+ ws.send(JSON.stringify({
233
+ type: 'activity-log',
234
+ entries: backfillActivityFilenames(loadActivityTail()),
235
+ }));
213
236
  // Rehydrate in-flight writing spinners across app refreshes
214
237
  const pendingWritesSnapshot = getPendingWritesSnapshot();
215
238
  if (pendingWritesSnapshot.length > 0) {
@@ -541,7 +564,26 @@ export function broadcastAgentStatus(connected) {
541
564
  let lastSyncStatus = null;
542
565
  const pendingWrites = new Map();
543
566
  const WRITING_TIMEOUT_MS = 60_000;
544
- export function broadcastWritingStarted(title, target, key) {
567
+ export function broadcastWritingStarted(title, target, key,
568
+ /**
569
+ * Disk filename to record on the Activity-tab entry so the row links to the
570
+ * doc on click. Independent of `target.wsFilename` (which is the workspace
571
+ * anchor filename used for sidebar spinner positioning and is empty/absent
572
+ * for orphan docs and sidebar-action writes). Falls back to
573
+ * target?.wsFilename, then to `key` when it looks like a disk filename
574
+ * (create/declare paths already pass result.filename as the key).
575
+ * Filename is back-compat only — the client prefers docId.
576
+ * adr: adr/right-rail.md
577
+ */
578
+ activityFilename,
579
+ /**
580
+ * Stable docId for the activity entry. Preferred over filename — the
581
+ * client resolves it to a current filename at click time, so renames
582
+ * don't break the link. Most callers can pass this from their result
583
+ * object; pass undefined if not yet known (sidebar-action passes the
584
+ * source doc's id here).
585
+ */
586
+ activityDocId) {
545
587
  const writeKey = key || target?.wsFilename || `write:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
546
588
  const existing = pendingWrites.get(writeKey);
547
589
  if (existing)
@@ -562,6 +604,17 @@ export function broadcastWritingStarted(title, target, key) {
562
604
  if (ws.readyState === WebSocket.OPEN)
563
605
  ws.send(msg);
564
606
  }
607
+ // Right-rail Activity: each agent write produces one entry, emitted at
608
+ // start (target info is richest here, and the spinner-in-sidebar already
609
+ // signals in-progress completion). adr: adr/right-rail.md
610
+ const wsFn = target?.wsFilename && target.wsFilename.length > 0 ? target.wsFilename : undefined;
611
+ const keyAsFilename = key && key.endsWith('.md') ? key : undefined;
612
+ broadcastActivityEvent({
613
+ kind: 'writing-started',
614
+ headline: `Agent wrote in ${title || 'Untitled'}`,
615
+ docId: activityDocId,
616
+ filename: activityFilename ?? wsFn ?? keyAsFilename,
617
+ });
565
618
  return writeKey;
566
619
  }
567
620
  // key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
@@ -621,6 +674,126 @@ export function broadcastCommentsChanged(filename) {
621
674
  ws.send(msg);
622
675
  }
623
676
  }
677
+ /**
678
+ * Record an agent-attributed activity event AND push it to every connected
679
+ * client. The on-disk log is authoritative; broadcast is purely for live UI.
680
+ * adr: adr/right-rail.md
681
+ */
682
+ export function broadcastActivityEvent(partial) {
683
+ const event = recordActivity(partial);
684
+ const msg = JSON.stringify({ type: 'activity-event', event });
685
+ for (const ws of clients) {
686
+ if (ws.readyState === WebSocket.OPEN)
687
+ ws.send(msg);
688
+ }
689
+ }
690
+ /**
691
+ * Push a fresh activity-log seed to all connected clients. Used on profile
692
+ * switch — the buffer has been cleared by clearAllCaches(), so the next
693
+ * loadActivityTail() reads from the new profile's disk log. Clients replace
694
+ * their entire in-memory list on receipt, so cross-profile leakage clears.
695
+ * adr: adr/right-rail.md
696
+ */
697
+ export function broadcastActivityLogSeed() {
698
+ const msg = JSON.stringify({ type: 'activity-log', entries: backfillActivityFilenames(loadActivityTail()) });
699
+ for (const ws of clients) {
700
+ if (ws.readyState === WebSocket.OPEN)
701
+ ws.send(msg);
702
+ }
703
+ }
704
+ /**
705
+ * Patch activity entries before handing the seed to the client. The disk log
706
+ * stays append-only (we don't rewrite it); the patch only affects the
707
+ * in-flight payload.
708
+ *
709
+ * Three passes per entry:
710
+ * 1. If `filename` is set but `docId` isn't, look up docId from the current
711
+ * document list (filename → docId) and stamp it on the entry.
712
+ * 2. If neither is set, parse the headline ("Agent wrote in <title>",
713
+ * "Enrichment stamped <title>") and resolve via the title index.
714
+ * 3. If the entry has a docId (originally or via passes 1–2), refresh the
715
+ * headline's title to the doc's *current* title. This fixes
716
+ * "Agent wrote in Untitled" rows once the doc has earned a real title,
717
+ * and keeps rename-stale headlines in sync. Headline is display-only —
718
+ * the click still resolves via docId — so rewriting in the seed is safe.
719
+ *
720
+ * The client navigates by docId (resolving to the current filename at click
721
+ * time, so renames don't break the link), so backfilling docId is what makes
722
+ * historical entries clickable; the headline refresh is the polish on top.
723
+ *
724
+ * Entries whose original title was "Untitled" AND whose docId still can't be
725
+ * resolved stay un-resolved — no unique target. The client renders them dim,
726
+ * non-clickable, with a tooltip explaining why.
727
+ * adr: adr/right-rail.md
728
+ */
729
+ function backfillActivityFilenames(entries) {
730
+ if (entries.length === 0)
731
+ return entries;
732
+ let titleToDoc = null;
733
+ let filenameToDocId = null;
734
+ let docIdToTitle = null;
735
+ try {
736
+ const docs = listDocuments();
737
+ titleToDoc = new Map();
738
+ filenameToDocId = new Map();
739
+ docIdToTitle = new Map();
740
+ for (const d of docs) {
741
+ if (d.docId) {
742
+ filenameToDocId.set(d.filename, d.docId);
743
+ if (d.title)
744
+ docIdToTitle.set(d.docId, d.title);
745
+ }
746
+ if (d.title && !titleToDoc.has(d.title))
747
+ titleToDoc.set(d.title, { docId: d.docId, filename: d.filename });
748
+ }
749
+ }
750
+ catch {
751
+ return entries;
752
+ }
753
+ const HEADLINE_PREFIXES = ['Agent wrote in ', 'Enrichment stamped '];
754
+ const resolveTitle = (headline) => {
755
+ for (const prefix of HEADLINE_PREFIXES) {
756
+ if (headline.startsWith(prefix))
757
+ return headline.slice(prefix.length);
758
+ }
759
+ return null;
760
+ };
761
+ const rewriteHeadline = (headline, newTitle) => {
762
+ for (const prefix of HEADLINE_PREFIXES) {
763
+ if (headline.startsWith(prefix))
764
+ return prefix + newTitle;
765
+ }
766
+ return headline;
767
+ };
768
+ return entries.map((e) => {
769
+ let next = e;
770
+ // Pass 1 & 2 — fill in docId if missing.
771
+ if (!next.docId && (next.kind === 'writing-started' || next.kind === 'enrichment' || next.kind === 'backlinks-added')) {
772
+ if (next.filename) {
773
+ const docId = filenameToDocId.get(next.filename);
774
+ if (docId)
775
+ next = { ...next, docId };
776
+ }
777
+ if (!next.docId) {
778
+ const title = resolveTitle(next.headline);
779
+ if (title && title !== 'Untitled') {
780
+ const hit = titleToDoc.get(title);
781
+ if (hit)
782
+ next = { ...next, docId: hit.docId, filename: next.filename ?? hit.filename };
783
+ }
784
+ }
785
+ }
786
+ // Pass 3 — refresh headline title from current doc state when docId is known.
787
+ if (next.docId) {
788
+ const currentTitle = docIdToTitle.get(next.docId);
789
+ const oldTitle = resolveTitle(next.headline);
790
+ if (currentTitle && oldTitle !== null && oldTitle !== currentTitle) {
791
+ next = { ...next, headline: rewriteHeadline(next.headline, currentTitle) };
792
+ }
793
+ }
794
+ return next;
795
+ });
796
+ }
624
797
  export function broadcastSyncStatus(status) {
625
798
  lastSyncStatus = status;
626
799
  const msg = JSON.stringify({ type: 'sync-status', ...status });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.22.1",
3
+ "version": "0.24.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",