openwriter 0.21.0 → 0.22.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-B1-K-j46.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-0ttVnjRp.css">
13
+ <script type="module" crossorigin src="/assets/index-OAhOx_JE.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-DFbNF7q0.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Activity log — persistent record of agent-attributed actions.
3
+ *
4
+ * The right-rail Activity tab reads this log. Entries are appended on the
5
+ * happy path of agent broadcasts (writing-finished, enrichment, backlinks
6
+ * propagation, agent-attributed doc create/delete). Human edits do NOT
7
+ * record entries — they aren't surprising to the user, so they're noise.
8
+ *
9
+ * Disk: JSONL at `<profile-dir>/activity.log`. Append-only with rotation
10
+ * at 10 MB / 5 files. ~150 bytes per entry, so 10 MB ≈ 70k entries — years
11
+ * of heavy use before the first rotation.
12
+ *
13
+ * Memory: a ring buffer of the most recent MAX_BUFFER_ENTRIES used to
14
+ * answer "give me the last 500 for the new client" without ever touching
15
+ * disk on the hot path.
16
+ *
17
+ * adr: adr/right-rail.md
18
+ */
19
+ import { existsSync, mkdirSync, statSync, renameSync, unlinkSync, appendFileSync, readFileSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { getDataDir } from './helpers.js';
22
+ const MAX_BUFFER_ENTRIES = 500;
23
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
24
+ const KEEP_ROTATIONS = 5;
25
+ let buffer = [];
26
+ let bufferSeeded = false;
27
+ function getLogPath() {
28
+ return join(getDataDir(), 'activity.log');
29
+ }
30
+ function ensureLogDir() {
31
+ const dir = getDataDir();
32
+ if (!existsSync(dir))
33
+ mkdirSync(dir, { recursive: true });
34
+ }
35
+ function rotateIfNeeded() {
36
+ const path = getLogPath();
37
+ if (!existsSync(path))
38
+ return;
39
+ let size;
40
+ try {
41
+ size = statSync(path).size;
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ if (size < MAX_FILE_BYTES)
47
+ return;
48
+ try {
49
+ for (let i = KEEP_ROTATIONS; i >= 1; i--) {
50
+ const oldPath = i === 1 ? path : `${path}.${i - 1}`;
51
+ const newPath = `${path}.${i}`;
52
+ if (existsSync(oldPath)) {
53
+ if (i === KEEP_ROTATIONS && existsSync(newPath)) {
54
+ try {
55
+ unlinkSync(newPath);
56
+ }
57
+ catch { /* best-effort */ }
58
+ }
59
+ try {
60
+ renameSync(oldPath, newPath);
61
+ }
62
+ catch { /* best-effort */ }
63
+ }
64
+ }
65
+ }
66
+ catch { /* best-effort */ }
67
+ }
68
+ /**
69
+ * Read the tail of the on-disk log into the in-memory buffer the first
70
+ * time someone asks for it. Subsequent calls are pure memory.
71
+ *
72
+ * The file is small enough that a one-shot full read on first access is
73
+ * fine; if it ever stops being small the rotation logic above keeps it
74
+ * bounded.
75
+ */
76
+ function seedBuffer() {
77
+ if (bufferSeeded)
78
+ return;
79
+ bufferSeeded = true;
80
+ try {
81
+ const path = getLogPath();
82
+ if (!existsSync(path)) {
83
+ buffer = [];
84
+ return;
85
+ }
86
+ const raw = readFileSync(path, 'utf-8');
87
+ const lines = raw.split('\n').filter((l) => l.length > 0);
88
+ // File is oldest-first; the buffer carries newest-first to match what
89
+ // the client expects (and what every push extends).
90
+ const parsed = [];
91
+ for (let i = lines.length - 1; i >= 0 && parsed.length < MAX_BUFFER_ENTRIES; i--) {
92
+ try {
93
+ const evt = JSON.parse(lines[i]);
94
+ if (evt && typeof evt.ts === 'number' && typeof evt.kind === 'string' && typeof evt.headline === 'string') {
95
+ parsed.push(evt);
96
+ }
97
+ }
98
+ catch { /* skip malformed line */ }
99
+ }
100
+ buffer = parsed;
101
+ }
102
+ catch {
103
+ buffer = [];
104
+ }
105
+ }
106
+ /** Append an event. Records to memory + disk; returns the stamped event. */
107
+ export function recordActivity(partial) {
108
+ seedBuffer();
109
+ const evt = {
110
+ ts: partial.ts ?? Date.now(),
111
+ kind: partial.kind,
112
+ headline: partial.headline,
113
+ };
114
+ if (partial.detail)
115
+ evt.detail = partial.detail;
116
+ if (partial.filename)
117
+ evt.filename = partial.filename;
118
+ if (partial.nodeId)
119
+ evt.nodeId = partial.nodeId;
120
+ buffer.unshift(evt);
121
+ if (buffer.length > MAX_BUFFER_ENTRIES)
122
+ buffer.length = MAX_BUFFER_ENTRIES;
123
+ try {
124
+ ensureLogDir();
125
+ rotateIfNeeded();
126
+ appendFileSync(getLogPath(), JSON.stringify(evt) + '\n');
127
+ }
128
+ catch { /* logging must never throw */ }
129
+ return evt;
130
+ }
131
+ /** Tail of the activity log, newest-first. Used to seed clients on connect. */
132
+ export function loadActivityTail(limit = MAX_BUFFER_ENTRIES) {
133
+ seedBuffer();
134
+ return buffer.slice(0, Math.min(limit, buffer.length));
135
+ }
136
+ /**
137
+ * Drop the in-memory buffer and force the next read to re-seed from disk.
138
+ * Called on profile switch — without this, the new profile inherits the
139
+ * previous profile's activity entries because `seedBuffer()` short-circuits
140
+ * when `bufferSeeded === true`. Disk writes are always per-profile (via
141
+ * getDataDir), so only the memory side leaks; this resets that side.
142
+ * adr: adr/right-rail.md
143
+ */
144
+ export function clearActivityBuffer() {
145
+ buffer = [];
146
+ bufferSeeded = false;
147
+ }
@@ -38,8 +38,16 @@ import { addComment, getComments, resolveComments, unresolveComments, deleteComm
38
38
  import { initLogger, logger, generateRequestId, withRequestId } from './logger.js';
39
39
  const __filename = fileURLToPath(import.meta.url);
40
40
  const __dirname = dirname(__filename);
41
+ // Runtime port the HTTP server is actually listening on. Set inside
42
+ // startHttpServer once the port is resolved; read by tools that need to
43
+ // construct absolute URLs (e.g. get_doc_link). Defaults to 5050 if read
44
+ // before the server has booted — matches the default option.
45
+ let runtimePort = 5050;
46
+ export function getRuntimePort() { return runtimePort; }
47
+ export function getBaseUrl() { return `http://localhost:${runtimePort}`; }
41
48
  export async function startHttpServer(options = {}) {
42
49
  const port = options.port || 5050;
50
+ runtimePort = port;
43
51
  // Initialize structured logging first — every subsequent module call can
44
52
  // emit events from this point. Config file lives at ~/.openwriter/
45
53
  // log-config.json (missing = safe public defaults: error-only, no text).
@@ -1775,6 +1775,30 @@ export const TOOL_REGISTRY = [
1775
1775
  }) }] };
1776
1776
  },
1777
1777
  },
1778
+ {
1779
+ name: 'get_doc_link',
1780
+ description: 'Return a clickable deep-link URL for a doc (and optionally a specific paragraph). Use this whenever you cite a docId in a response — emit the link as `[open](url)` so the user can click straight to the doc instead of manually navigating. URL pattern: `http://localhost:<port>/d/{docId}` for doc-level, plus `#node={nodeId}` for paragraph-level. The server stitches the URL with the live port, so the agent never has to guess the hostname or port.',
1781
+ schema: {
1782
+ docId: z.string().describe('Target document docId (8-char hex from list_documents / read_pad).'),
1783
+ nodeId: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level anchor (visible in `read_pad`\'s tagged-line output). When set, opening the link scrolls the editor to that block and briefly highlights it.'),
1784
+ },
1785
+ handler: async ({ docId, nodeId }) => {
1786
+ if (!/^[a-f0-9]{8}$/.test(docId)) {
1787
+ return { content: [{ type: 'text', text: `docId "${docId}" is not a valid 8-char hex. Use list_documents to find the right docId.` }] };
1788
+ }
1789
+ const filename = resolveDocId(docId);
1790
+ if (!filename) {
1791
+ return { content: [{ type: 'text', text: `docId "${docId}" not found. Use list_documents to find the right docId.` }] };
1792
+ }
1793
+ if (nodeId !== undefined && !/^[a-f0-9]{8}$/.test(nodeId)) {
1794
+ return { content: [{ type: 'text', text: `nodeId "${nodeId}" is not a valid 8-char hex. Use read_pad to see paragraph nodeIds.` }] };
1795
+ }
1796
+ const { getBaseUrl } = await import('./index.js');
1797
+ const base = getBaseUrl();
1798
+ const url = nodeId ? `${base}/d/${docId}#node=${nodeId}` : `${base}/d/${docId}`;
1799
+ return { content: [{ type: 'text', text: JSON.stringify({ url, docId, nodeId: nodeId ?? null }) }] };
1800
+ },
1801
+ },
1778
1802
  {
1779
1803
  name: 'search_docs',
1780
1804
  description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",