@inerrata/channel 0.3.6 → 0.3.7

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/index.js CHANGED
@@ -12,373 +12,18 @@
12
12
  * Config (in .mcp.json or claude mcp add):
13
13
  * { "command": "npx", "args": ["@inerrata/channel"], "env": { "ERRATA_API_KEY": "err_..." } }
14
14
  */
15
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
15
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
- import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
18
- const MCP_BASE = (process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev').replace('/api/v1', '');
19
- const API_BASE = MCP_BASE + '/api/v1';
20
- const API_KEY = process.env.ERRATA_API_KEY ?? '';
16
+ import { API_KEY } from './api-client.js';
17
+ import { server } from './mcp-server.js';
18
+ // Register prompt handlers (side-effect import)
19
+ import './prompts.js';
20
+ import { connectAnnouncementChannel } from './stream-relay.js';
21
+ import { stopHeartbeat, sendOffline } from './heartbeat.js';
21
22
  if (!API_KEY) {
22
23
  console.error('[inerrata-channel] ERRATA_API_KEY is required');
23
24
  process.exit(1);
24
25
  }
25
26
  // ---------------------------------------------------------------------------
26
- // MCP Server — low-level Server class (McpServer does NOT propagate
27
- // the claude/channel experimental capability)
28
- // ---------------------------------------------------------------------------
29
- // Always true — the channel plugin is a Claude Code stdio server, and
30
- // notifications/claude/channel is what renders <channel> tags in the conversation.
31
- // Claude Code doesn't declare the capability back, but it renders them fine.
32
- const clientSupportsChannelNotif = true;
33
- const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
34
- capabilities: {
35
- experimental: { 'claude/channel': {} },
36
- tools: {},
37
- logging: {},
38
- },
39
- instructions: `You are connected to inErrata messaging via a real-time channel.
40
-
41
- When a <channel source="inerrata-channel"> tag appears, it means another agent sent you a message on inErrata.
42
- The tag attributes include the sender handle, thread ID, and message type.
43
-
44
- To reply, use the "send_message" tool with the sender's handle and your message body.
45
- To accept or decline a pending message request, use the "message_request" tool with the request ID and action.
46
-
47
- Message types:
48
- - message.received: A new message in an established conversation
49
- - message.request: A first-contact request from a new agent (includes their profile)`,
50
- });
51
- // ---------------------------------------------------------------------------
52
- // Tools — reply + accept_request
53
- // ---------------------------------------------------------------------------
54
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
55
- tools: [
56
- {
57
- name: 'send_message',
58
- description: 'Send a direct message to another agent on inErrata',
59
- inputSchema: {
60
- type: 'object',
61
- properties: {
62
- to_handle: { type: 'string', description: 'Recipient agent handle' },
63
- body: { type: 'string', description: 'Message body' },
64
- },
65
- required: ['to_handle', 'body'],
66
- },
67
- },
68
- {
69
- name: 'message_request',
70
- description: 'Accept or decline a pending first-contact message request',
71
- inputSchema: {
72
- type: 'object',
73
- properties: {
74
- request_id: { type: 'string', description: 'The message request ID' },
75
- action: { type: 'string', enum: ['accept', 'decline'], description: 'Accept or decline the request' },
76
- },
77
- required: ['request_id', 'action'],
78
- },
79
- },
80
- ],
81
- }));
82
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
83
- const { name, arguments: args } = req.params;
84
- if (name === 'send_message') {
85
- const res = await apiFetch('/messages', {
86
- method: 'POST',
87
- body: JSON.stringify({
88
- toHandle: args.to_handle,
89
- body: args.body,
90
- }),
91
- });
92
- if (!res.ok) {
93
- const err = await res.json().catch(() => ({}));
94
- return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
95
- }
96
- return { content: [{ type: 'text', text: 'Message sent.' }] };
97
- }
98
- if (name === 'message_request') {
99
- const a = args;
100
- if (!a.action) {
101
- return { content: [{ type: 'text', text: 'Error: action is required (accept or decline)' }], isError: true };
102
- }
103
- const res = await apiFetch(`/messages/requests/${a.request_id}`, {
104
- method: 'PATCH',
105
- body: JSON.stringify({ action: a.action }),
106
- });
107
- if (!res.ok) {
108
- const err = await res.json().catch(() => ({}));
109
- return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
110
- }
111
- const resultText = a.action === 'accept' ? 'Request accepted — conversation is now open.' : 'Request declined.';
112
- return { content: [{ type: 'text', text: resultText }] };
113
- }
114
- return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
115
- });
116
- // ---------------------------------------------------------------------------
117
- // API helper
118
- // ---------------------------------------------------------------------------
119
- function apiFetch(path, init) {
120
- return fetch(`${API_BASE}${path}`, {
121
- ...init,
122
- headers: {
123
- 'Content-Type': 'application/json',
124
- Authorization: `Bearer ${API_KEY}`,
125
- ...(init?.headers ?? {}),
126
- },
127
- });
128
- }
129
- // ---------------------------------------------------------------------------
130
- // Deduplication — prevents double-delivery if the same notification is
131
- // somehow pushed twice over the stream.
132
- // ---------------------------------------------------------------------------
133
- const seenNotifications = new Set();
134
- const SEEN_MAX = 200;
135
- function isDuplicate(id) {
136
- if (seenNotifications.has(id))
137
- return true;
138
- seenNotifications.add(id);
139
- if (seenNotifications.size > SEEN_MAX) {
140
- seenNotifications.delete(seenNotifications.values().next().value);
141
- }
142
- return false;
143
- }
144
- // ---------------------------------------------------------------------------
145
- // Notification pusher — relays channel events into Claude Code
146
- // ---------------------------------------------------------------------------
147
- async function pushNotification(data) {
148
- try {
149
- const notifId = data.requestId ?? data.messageId;
150
- if (notifId && isDuplicate(notifId))
151
- return;
152
- const content = data.content ?? '';
153
- const meta = data.meta ?? {};
154
- if (clientSupportsChannelNotif) {
155
- // Claude Code: renders as a <channel> tag in the conversation
156
- await server.notification({
157
- method: 'notifications/claude/channel',
158
- params: { content, meta },
159
- });
160
- }
161
- else {
162
- // Copilot / other clients: standard MCP logging notification
163
- await server.notification({
164
- method: 'notifications/message',
165
- params: { level: 'info', logger: 'inErrata', data: content },
166
- });
167
- }
168
- }
169
- catch (err) {
170
- console.error('[inerrata-channel] Failed to push notification:', err);
171
- }
172
- }
173
- // ---------------------------------------------------------------------------
174
- // Welcome banner — fetches agent profile and pushes on first connect
175
- async function pushWelcomeNotif(content, meta) {
176
- if (clientSupportsChannelNotif) {
177
- await server.notification({ method: 'notifications/claude/channel', params: { content, meta } });
178
- }
179
- else {
180
- await server.notification({ method: 'notifications/message', params: { level: 'info', logger: 'inErrata', data: content } });
181
- }
182
- }
183
- // ---------------------------------------------------------------------------
184
- async function pushWelcome() {
185
- try {
186
- const [meRes, connRes, inboxRes] = await Promise.all([
187
- apiFetch('/me'),
188
- apiFetch('/messages/connections'),
189
- apiFetch('/messages/inbox?limit=50&offset=0'),
190
- ]);
191
- const me = meRes.ok
192
- ? (await meRes.json()).agent ?? null
193
- : null;
194
- const connections = connRes.ok
195
- ? (await connRes.json()).connections ?? []
196
- : [];
197
- const inboxMessages = inboxRes.ok
198
- ? (await inboxRes.json())
199
- : [];
200
- const onlineReachable = connections.filter(c => c.online && c.notifyReachable !== false).map(c => c.handle);
201
- const onlineMcpOnly = connections.filter(c => c.online && c.notifyReachable === false).map(c => c.handle);
202
- const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
203
- if (!me) {
204
- await pushWelcomeNotif(`✦ Connected to inErrata.`, { type: 'welcome' });
205
- return;
206
- }
207
- const level = me.level ?? 1;
208
- const xp = me.xp ?? 0;
209
- const unreadLabel = unreadCount > 0 ? ` · 💬${unreadCount}` : '';
210
- const lines = [`✦ inErrata · @${me.handle} · Lv.${level} · ✨${xp}xp${unreadLabel}`];
211
- if (onlineReachable.length)
212
- lines.push(`┃ 🟢 ${onlineReachable.join(', ')}`);
213
- if (onlineMcpOnly.length)
214
- lines.push(`┃ 🔵 ${onlineMcpOnly.join(', ')}`);
215
- lines.push(`✦`);
216
- await pushWelcomeNotif(lines.join('\n'), { type: 'welcome' });
217
- }
218
- catch {
219
- // Best-effort
220
- }
221
- }
222
- // ---------------------------------------------------------------------------
223
- // Announcement channel relay — connects to GET /mcp and relays notifications.
224
- // Reconnects automatically with exponential backoff on stream drop.
225
- // ---------------------------------------------------------------------------
226
- let welcomed = false;
227
- // How long to wait with zero data before assuming the stream is dead
228
- // and reconnecting proactively. Fly.io's proxy kills idle connections
229
- // after ~60s, so we set this well above a heartbeat interval but below
230
- // the point where we'd miss many notifications.
231
- const STREAM_IDLE_TIMEOUT_MS = 90_000;
232
- async function connectAnnouncementChannel(retryDelay = 1000) {
233
- try {
234
- // Step 1: initialize a new MCP session
235
- const initRes = await fetch(`${MCP_BASE}/mcp`, {
236
- method: 'POST',
237
- headers: {
238
- 'Content-Type': 'application/json',
239
- 'Accept': 'application/json, text/event-stream',
240
- Authorization: `Bearer ${API_KEY}`,
241
- },
242
- body: JSON.stringify({
243
- jsonrpc: '2.0',
244
- id: 1,
245
- method: 'initialize',
246
- params: {
247
- protocolVersion: '2025-03-26',
248
- capabilities: {},
249
- clientInfo: { name: 'inerrata-channel-relay', version: '0.1.0' },
250
- },
251
- }),
252
- });
253
- const sessionId = initRes.headers.get('mcp-session-id');
254
- if (!sessionId) {
255
- console.error('[inerrata-channel] No session ID from MCP initialize — retrying in', retryDelay, 'ms');
256
- setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
257
- return;
258
- }
259
- // Step 2: open the announcement channel stream
260
- const streamRes = await fetch(`${MCP_BASE}/mcp`, {
261
- method: 'GET',
262
- headers: {
263
- Authorization: `Bearer ${API_KEY}`,
264
- 'mcp-session-id': sessionId,
265
- Accept: 'text/event-stream',
266
- },
267
- });
268
- if (!streamRes.ok || !streamRes.body) {
269
- console.error('[inerrata-channel] Failed to open announcement stream:', streamRes.status);
270
- setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
271
- return;
272
- }
273
- console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
274
- // Reset backoff on successful connection
275
- retryDelay = 1000;
276
- // Start heartbeat now that we have a live session — keeps channelOnlineAt
277
- // in sync with actual push-path availability. Stopped in the finally block.
278
- if (!heartbeatTimer)
279
- startHeartbeat();
280
- // Send notifications/initialized (MCP protocol requirement) and push
281
- // welcome in a single fire-and-forget POST — the initialized notification
282
- // tells the server the session is ready, and the welcome greets the user.
283
- fetch(`${MCP_BASE}/mcp`, {
284
- method: 'POST',
285
- headers: {
286
- 'Content-Type': 'application/json',
287
- 'Accept': 'application/json, text/event-stream',
288
- Authorization: `Bearer ${API_KEY}`,
289
- 'mcp-session-id': sessionId,
290
- },
291
- body: JSON.stringify({
292
- jsonrpc: '2.0',
293
- method: 'notifications/initialized',
294
- }),
295
- }).catch(() => { });
296
- if (!welcomed) {
297
- welcomed = true;
298
- setTimeout(() => pushWelcome().catch(() => { }), 500);
299
- }
300
- // Step 3: parse SSE stream line by line
301
- const reader = streamRes.body.getReader();
302
- const decoder = new TextDecoder();
303
- let buffer = '';
304
- let lastDataAt = Date.now();
305
- // Idle watchdog — if no data (including heartbeats) arrives within the
306
- // timeout, the stream is likely dead (proxy killed it). Force reconnect.
307
- const idleTimer = setInterval(() => {
308
- if (Date.now() - lastDataAt > STREAM_IDLE_TIMEOUT_MS) {
309
- console.error('[inerrata-channel] Stream idle for', STREAM_IDLE_TIMEOUT_MS, 'ms — forcing reconnect');
310
- reader.cancel().catch(() => { });
311
- clearInterval(idleTimer);
312
- }
313
- }, 10_000);
314
- try {
315
- while (true) {
316
- const { done, value } = await reader.read();
317
- if (done)
318
- break;
319
- lastDataAt = Date.now();
320
- buffer += decoder.decode(value, { stream: true });
321
- const lines = buffer.split('\n');
322
- buffer = lines.pop() ?? '';
323
- for (const line of lines) {
324
- if (!line.startsWith('data: '))
325
- continue;
326
- const raw = line.slice(6).trim();
327
- if (!raw)
328
- continue;
329
- try {
330
- const msg = JSON.parse(raw);
331
- if (msg.method === 'notifications/claude/channel' && msg.params) {
332
- console.error('[inerrata-channel] Relaying notification:', msg.params.meta);
333
- await pushNotification(msg.params);
334
- }
335
- }
336
- catch {
337
- // Malformed line — skip
338
- }
339
- }
340
- }
341
- }
342
- finally {
343
- clearInterval(idleTimer);
344
- }
345
- console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
346
- }
347
- catch (err) {
348
- console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
349
- }
350
- finally {
351
- // Stop heartbeat when session ends — channelOnlineAt will go stale naturally.
352
- // This ensures notifyReachable accurately reflects push-path availability.
353
- if (heartbeatTimer) {
354
- clearInterval(heartbeatTimer);
355
- heartbeatTimer = undefined;
356
- }
357
- }
358
- setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
359
- }
360
- // ---------------------------------------------------------------------------
361
- // Channel heartbeat — tells the server this agent's channel plugin is alive.
362
- // The server uses this to fire online/offline status notifications to the
363
- // agent's confirmed connections.
364
- // ---------------------------------------------------------------------------
365
- const HEARTBEAT_INTERVAL_MS = 60_000;
366
- let heartbeatTimer;
367
- function startHeartbeat() {
368
- // Fire immediately, then repeat
369
- sendHeartbeat();
370
- heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
371
- }
372
- function sendHeartbeat() {
373
- apiFetch('/channel/heartbeat', { method: 'POST' }).catch((err) => {
374
- console.error('[inerrata-channel] Heartbeat failed:', err);
375
- });
376
- }
377
- function sendOffline() {
378
- // Best-effort — process may be exiting
379
- apiFetch('/channel/heartbeat', { method: 'DELETE' }).catch(() => { });
380
- }
381
- // ---------------------------------------------------------------------------
382
27
  // Start
383
28
  // ---------------------------------------------------------------------------
384
29
  async function main() {
@@ -388,8 +33,7 @@ async function main() {
388
33
  }
389
34
  // Graceful shutdown — tell the server we're going offline
390
35
  function shutdown() {
391
- if (heartbeatTimer)
392
- clearInterval(heartbeatTimer);
36
+ stopHeartbeat();
393
37
  sendOffline();
394
38
  // Give the offline request a moment to fire, then exit
395
39
  setTimeout(() => process.exit(0), 500);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * MCP server setup and tool handlers for the inErrata channel plugin.
3
+ */
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ export declare const clientSupportsChannelNotif = true;
6
+ export declare const server: Server<{
7
+ method: string;
8
+ params?: {
9
+ [x: string]: unknown;
10
+ _meta?: {
11
+ [x: string]: unknown;
12
+ progressToken?: string | number | undefined;
13
+ "io.modelcontextprotocol/related-task"?: {
14
+ taskId: string;
15
+ } | undefined;
16
+ } | undefined;
17
+ } | undefined;
18
+ }, {
19
+ method: string;
20
+ params?: {
21
+ [x: string]: unknown;
22
+ _meta?: {
23
+ [x: string]: unknown;
24
+ progressToken?: string | number | undefined;
25
+ "io.modelcontextprotocol/related-task"?: {
26
+ taskId: string;
27
+ } | undefined;
28
+ } | undefined;
29
+ } | undefined;
30
+ }, {
31
+ [x: string]: unknown;
32
+ _meta?: {
33
+ [x: string]: unknown;
34
+ progressToken?: string | number | undefined;
35
+ "io.modelcontextprotocol/related-task"?: {
36
+ taskId: string;
37
+ } | undefined;
38
+ } | undefined;
39
+ }>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * MCP server setup and tool handlers for the inErrata channel plugin.
3
+ */
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
+ import { apiFetch } from './api-client.js';
7
+ import { markNotificationRead, buildNotificationSummary, drainNotifications } from './notification-buffer.js';
8
+ // Always true — the channel plugin is a Claude Code stdio server, and
9
+ // notifications/claude/channel is what renders <channel> tags in the conversation.
10
+ export const clientSupportsChannelNotif = true;
11
+ export const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
12
+ capabilities: {
13
+ experimental: { 'claude/channel': {}, 'elicitation': {} },
14
+ tools: {},
15
+ prompts: {},
16
+ logging: {},
17
+ },
18
+ instructions: `You are connected to inErrata messaging via a real-time channel.
19
+
20
+ When a <channel source="inerrata-channel"> tag appears, it means another agent sent you a message on inErrata.
21
+ The tag attributes include the sender handle, thread ID, and message type.
22
+
23
+ To reply, use the "send_message" tool with the sender's handle and your message body.
24
+ To accept or decline a pending message request, use the "message_request" tool with the request ID and action.
25
+ To check for notifications in clients that do not render live channel tags, use "notification_summary" and "drain_notifications".
26
+
27
+ Message types:
28
+ - message.received: A new message in an established conversation - reply with send_message.
29
+ - message.request: A first-contact request from a new agent. Read the preview and sender profile carefully. Accept if the message is relevant or the sender seems like a good-faith collaborator. Decline if it looks like spam, irrelevant, or unwanted. Do not accept all requests by default.`,
30
+ });
31
+ // ---------------------------------------------------------------------------
32
+ // Tools — reply + accept_request + notification_summary + drain_notifications
33
+ // ---------------------------------------------------------------------------
34
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
35
+ tools: [
36
+ {
37
+ name: 'send_message',
38
+ description: 'Send a direct message to another agent on inErrata',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ to_handle: { type: 'string', description: 'Recipient agent handle' },
43
+ body: { type: 'string', description: 'Message body' },
44
+ },
45
+ required: ['to_handle', 'body'],
46
+ },
47
+ },
48
+ {
49
+ name: 'message_request',
50
+ description: 'Accept or decline a pending first-contact message request',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ request_id: { type: 'string', description: 'The message request ID' },
55
+ action: { type: 'string', enum: ['accept', 'decline'], description: 'Accept or decline the request' },
56
+ },
57
+ required: ['request_id', 'action'],
58
+ },
59
+ },
60
+ {
61
+ name: 'notification_summary',
62
+ description: 'Summarize unread inErrata notifications buffered by this channel relay',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {},
66
+ },
67
+ },
68
+ {
69
+ name: 'drain_notifications',
70
+ description: 'Read buffered inErrata notifications, optionally marking them as read',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ limit: { type: 'number', description: 'Maximum unread notifications to return (default 10)' },
75
+ mark_read: { type: 'boolean', description: 'Mark returned notifications as read (default true)' },
76
+ },
77
+ },
78
+ },
79
+ ],
80
+ }));
81
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
82
+ const { name, arguments: args } = req.params;
83
+ if (name === 'send_message') {
84
+ const res = await apiFetch('/messages', {
85
+ method: 'POST',
86
+ body: JSON.stringify({
87
+ toHandle: args.to_handle,
88
+ body: args.body,
89
+ }),
90
+ });
91
+ if (!res.ok) {
92
+ const err = await res.json().catch(() => ({}));
93
+ return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
94
+ }
95
+ return { content: [{ type: 'text', text: 'Message sent.' }] };
96
+ }
97
+ if (name === 'message_request') {
98
+ const a = args;
99
+ if (!a.action) {
100
+ return { content: [{ type: 'text', text: 'Error: action is required (accept or decline)' }], isError: true };
101
+ }
102
+ const res = await apiFetch(`/messages/requests/${a.request_id}`, {
103
+ method: 'PATCH',
104
+ body: JSON.stringify({ action: a.action }),
105
+ });
106
+ if (!res.ok) {
107
+ const err = await res.json().catch(() => ({}));
108
+ return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
109
+ }
110
+ markNotificationRead(String(a.request_id));
111
+ const resultText = a.action === 'accept' ? 'Request accepted - conversation is now open.' : 'Request declined.';
112
+ return { content: [{ type: 'text', text: resultText }] };
113
+ }
114
+ if (name === 'notification_summary') {
115
+ return { content: [{ type: 'text', text: buildNotificationSummary() }] };
116
+ }
117
+ if (name === 'drain_notifications') {
118
+ const a = args ?? {};
119
+ const limit = Math.max(1, Math.min(50, Number(a.limit ?? 10) || 10));
120
+ const markRead = a.mark_read === undefined ? true : Boolean(a.mark_read);
121
+ return { content: [{ type: 'text', text: drainNotifications(limit, markRead) }] };
122
+ }
123
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
124
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Notification buffer — deduplication, queueing, summary, and drain logic
3
+ * for inErrata channel notifications.
4
+ */
5
+ export interface StoredNotification {
6
+ id: string;
7
+ type: string;
8
+ content: string;
9
+ meta: Record<string, string>;
10
+ createdAt: string;
11
+ read: boolean;
12
+ }
13
+ export declare function isDuplicate(id: string): boolean;
14
+ export declare function queueNotification(data: Record<string, unknown>): void;
15
+ export declare function markNotificationRead(notificationId: string): void;
16
+ export declare function getUnreadNotifications(): StoredNotification[];
17
+ export declare function buildNotificationSummary(): string;
18
+ export declare function drainNotifications(limit: number, markRead: boolean): string;