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/client/assets/index-AWIKUHJ_.css +1 -0
- package/dist/client/assets/index-DmHLFNTs.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/activity-log.js +2 -0
- package/dist/server/documents.js +97 -3
- package/dist/server/index.js +159 -4
- package/dist/server/mcp.js +183 -55
- package/dist/server/peek-outline.js +304 -0
- package/dist/server/state.js +128 -0
- package/dist/server/title-from-body.js +125 -0
- package/dist/server/workspaces.js +23 -0
- package/dist/server/ws.js +176 -3
- package/package.json +1 -1
- package/skill/SKILL.md +611 -712
- package/skill/agents/openwriter-enrichment-minion.md +7 -0
- package/skill/docs/setup.md +62 -0
- package/dist/client/assets/index-DFbNF7q0.css +0 -1
- package/dist/client/assets/index-OAhOx_JE.js +0 -212
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.
|
|
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",
|