voicemode-channel 0.2.0 → 0.3.1
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/gateway.js +1 -1
- package/dist/index.js +292 -2
- package/dist/maildir.d.ts +164 -0
- package/dist/maildir.js +458 -0
- package/dist/maildir.test.d.ts +6 -0
- package/dist/maildir.test.js +712 -0
- package/package.json +1 -1
package/dist/gateway.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { GatewayClient, get_project_context } from './gateway.js';
|
|
23
|
+
import { write_message, list_messages, read_message, mark_read, count_unread } from './maildir.js';
|
|
23
24
|
import { login } from './auth.js';
|
|
24
25
|
import { load_credentials, is_expired, CREDENTIALS_FILE, get_valid_token } from './credentials.js';
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -121,7 +122,16 @@ if (process.env.VOICEMODE_CHANNEL_ENABLED !== 'true') {
|
|
|
121
122
|
}
|
|
122
123
|
const WS_URL = process.env.VOICEMODE_CONNECT_WS_URL ?? 'wss://voicemode.dev/ws';
|
|
123
124
|
const CHANNEL_NAME = 'voicemode-channel';
|
|
124
|
-
|
|
125
|
+
// Read version from package.json at runtime to avoid hardcoded version drift
|
|
126
|
+
const CHANNEL_VERSION = (() => {
|
|
127
|
+
try {
|
|
128
|
+
const pkg_path = new URL('../package.json', import.meta.url);
|
|
129
|
+
return JSON.parse(readFileSync(pkg_path, 'utf8')).version ?? '0.0.0';
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return '0.0.0';
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
125
135
|
const INSTRUCTIONS = [
|
|
126
136
|
'Events from VoiceMode appear as <channel source="voicemode-channel" caller="NAME">TRANSCRIPT</channel>.',
|
|
127
137
|
'These are inbound voice messages from a user speaking on their phone or web app.',
|
|
@@ -141,6 +151,9 @@ let currentProfile = {
|
|
|
141
151
|
voice: null,
|
|
142
152
|
presence: 'available',
|
|
143
153
|
};
|
|
154
|
+
// Track the last inbound caller name so outbound replies can reference it.
|
|
155
|
+
// Falls back to 'user' if no inbound message has been received yet.
|
|
156
|
+
let last_caller_name = 'user';
|
|
144
157
|
// ---------------------------------------------------------------------------
|
|
145
158
|
// MCP server with channel capability
|
|
146
159
|
// ---------------------------------------------------------------------------
|
|
@@ -167,7 +180,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
167
180
|
name: 'reply',
|
|
168
181
|
description: 'Send a voice reply to the user through VoiceMode. ' +
|
|
169
182
|
'Use this to respond to inbound voice channel events. ' +
|
|
170
|
-
'The reply is spoken aloud on the user\'s device via TTS.'
|
|
183
|
+
'The reply is spoken aloud on the user\'s device via TTS. ' +
|
|
184
|
+
'Pass in_reply_to to mark the source message with the R (Replied) flag.',
|
|
171
185
|
inputSchema: {
|
|
172
186
|
type: 'object',
|
|
173
187
|
properties: {
|
|
@@ -183,6 +197,10 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
183
197
|
type: 'boolean',
|
|
184
198
|
description: 'Whether to listen for user response after speaking (default: false)',
|
|
185
199
|
},
|
|
200
|
+
in_reply_to: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
description: 'Filename of the inbound message being replied to; receives R flag on successful send',
|
|
203
|
+
},
|
|
186
204
|
},
|
|
187
205
|
required: ['text'],
|
|
188
206
|
},
|
|
@@ -228,6 +246,89 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
228
246
|
},
|
|
229
247
|
},
|
|
230
248
|
},
|
|
249
|
+
{
|
|
250
|
+
name: 'list_messages',
|
|
251
|
+
description: 'List voice messages from the Maildir conversation history. ' +
|
|
252
|
+
'By default, only shows messages from the current agent session. ' +
|
|
253
|
+
'Use all_sessions to see messages from all sessions. ' +
|
|
254
|
+
'Pass include_body: true to fetch bodies in bulk (truncated by body_max_length). ' +
|
|
255
|
+
'Pass unread: true to see only unread messages (no S flag).',
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
all_sessions: {
|
|
260
|
+
type: 'boolean',
|
|
261
|
+
description: 'If true, show messages from all sessions (default: false, current session only)',
|
|
262
|
+
},
|
|
263
|
+
direction: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'Filter by message direction',
|
|
266
|
+
enum: ['inbound', 'outbound'],
|
|
267
|
+
},
|
|
268
|
+
limit: {
|
|
269
|
+
type: 'number',
|
|
270
|
+
description: 'Maximum number of messages to return (default: 50)',
|
|
271
|
+
},
|
|
272
|
+
include_body: {
|
|
273
|
+
type: 'boolean',
|
|
274
|
+
description: 'If true, include message bodies in the response (default: false)',
|
|
275
|
+
},
|
|
276
|
+
body_max_length: {
|
|
277
|
+
type: 'number',
|
|
278
|
+
description: 'Max body length when include_body is true; 0 = unlimited (default: 2000)',
|
|
279
|
+
},
|
|
280
|
+
unread: {
|
|
281
|
+
type: 'boolean',
|
|
282
|
+
description: 'If true, only return unread messages; if false, only read; omit for both',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'read_message',
|
|
289
|
+
description: 'Read the full content of a voice message by filename. ' +
|
|
290
|
+
'Use list_messages first to find message filenames. ' +
|
|
291
|
+
'By default, marks the message as Seen (moves from new/ to cur/ with S flag). ' +
|
|
292
|
+
'Pass mark_read: false to leave the message unread.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
filename: {
|
|
297
|
+
type: 'string',
|
|
298
|
+
description: 'The message filename (e.g. "vm-abc123def456")',
|
|
299
|
+
},
|
|
300
|
+
mark_read: {
|
|
301
|
+
type: 'boolean',
|
|
302
|
+
description: 'Whether to mark the message as Seen on read (default: true)',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
required: ['filename'],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'mark_read',
|
|
310
|
+
description: 'Apply Maildir flags to one or more messages (bulk supported). ' +
|
|
311
|
+
'Default flag is "S" (Seen). Files are moved from new/ to cur/ with a ' +
|
|
312
|
+
':2,FLAGS suffix per the Maildir spec. Flags are merged with any existing ' +
|
|
313
|
+
'flags on the file (e.g. marking an "R" message as Seen yields ":2,RS"). ' +
|
|
314
|
+
'Use this when you want to mark multiple messages read in one call, or to ' +
|
|
315
|
+
'apply non-Seen flags (R=Replied, F=Flagged, T=Trashed, D=Draft).',
|
|
316
|
+
inputSchema: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {
|
|
319
|
+
filenames: {
|
|
320
|
+
type: 'array',
|
|
321
|
+
items: { type: 'string' },
|
|
322
|
+
description: 'Message filenames to mark (from list_messages)',
|
|
323
|
+
},
|
|
324
|
+
flags: {
|
|
325
|
+
type: 'string',
|
|
326
|
+
description: 'Flag letters to apply, e.g. "S", "RS" (default: "S")',
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
required: ['filenames'],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
231
332
|
],
|
|
232
333
|
};
|
|
233
334
|
});
|
|
@@ -242,6 +343,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
242
343
|
if (name === 'reply') {
|
|
243
344
|
return handle_reply_tool(args);
|
|
244
345
|
}
|
|
346
|
+
if (name === 'list_messages') {
|
|
347
|
+
return handle_list_messages_tool(args);
|
|
348
|
+
}
|
|
349
|
+
if (name === 'read_message') {
|
|
350
|
+
return handle_read_message_tool(args);
|
|
351
|
+
}
|
|
352
|
+
if (name === 'mark_read') {
|
|
353
|
+
return handle_mark_read_tool(args);
|
|
354
|
+
}
|
|
245
355
|
return {
|
|
246
356
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
247
357
|
isError: true,
|
|
@@ -280,6 +390,16 @@ function handle_status_tool() {
|
|
|
280
390
|
lines.push('Auth: not authenticated');
|
|
281
391
|
}
|
|
282
392
|
lines.push('');
|
|
393
|
+
// Unread message count (supports notification-append pattern)
|
|
394
|
+
try {
|
|
395
|
+
const unread = count_unread();
|
|
396
|
+
lines.push(`Unread: ${unread}`);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
400
|
+
lines.push(`Unread: unavailable (${message})`);
|
|
401
|
+
}
|
|
402
|
+
lines.push('');
|
|
283
403
|
// Profile
|
|
284
404
|
lines.push(`Profile: set`);
|
|
285
405
|
lines.push(` Name: ${currentProfile.name}`);
|
|
@@ -305,6 +425,9 @@ function handle_reply_tool(args) {
|
|
|
305
425
|
}
|
|
306
426
|
const voice = typeof args?.voice === 'string' ? args.voice : (currentProfile.voice || undefined);
|
|
307
427
|
const wait_for_response = typeof args?.wait_for_response === 'boolean' ? args.wait_for_response : undefined;
|
|
428
|
+
const in_reply_to = typeof args?.in_reply_to === 'string' && args.in_reply_to.trim().length > 0
|
|
429
|
+
? args.in_reply_to.trim()
|
|
430
|
+
: undefined;
|
|
308
431
|
// Check gateway connection
|
|
309
432
|
if (!gateway || gateway.state !== 'connected') {
|
|
310
433
|
return {
|
|
@@ -332,6 +455,40 @@ function handle_reply_tool(args) {
|
|
|
332
455
|
};
|
|
333
456
|
}
|
|
334
457
|
log(`Sent reply via gateway: id=${msg_id} text="${truncate(text.trim(), 80)}"`);
|
|
458
|
+
// Apply R (Replied) flag to the source message when reply-context is provided.
|
|
459
|
+
// Best-effort -- never break reply flow if the source file is gone.
|
|
460
|
+
if (in_reply_to) {
|
|
461
|
+
try {
|
|
462
|
+
const results = mark_read([in_reply_to], 'R');
|
|
463
|
+
const r = results[0];
|
|
464
|
+
if (r?.found) {
|
|
465
|
+
log(`Marked ${in_reply_to} with R flag -> ${r.new_filename}`, 'DEBUG');
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
log(`R flag: source message not found: ${in_reply_to}`, 'WARN');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
473
|
+
log(`R flag application failed (non-fatal): ${message}`, 'WARN');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Persist outbound message to Maildir (best-effort -- never break reply flow)
|
|
477
|
+
try {
|
|
478
|
+
write_message({
|
|
479
|
+
direction: 'outbound',
|
|
480
|
+
from_name: currentProfile.name,
|
|
481
|
+
to_name: last_caller_name,
|
|
482
|
+
text: text.trim(),
|
|
483
|
+
session_id: gateway.session_id ?? 'unknown',
|
|
484
|
+
agent_session_id: gateway.agent_session_id ?? 'unknown',
|
|
485
|
+
agent_name: currentProfile.name,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
490
|
+
log(`Maildir write failed (non-fatal): ${message}`, 'WARN');
|
|
491
|
+
}
|
|
335
492
|
return {
|
|
336
493
|
content: [{
|
|
337
494
|
type: 'text',
|
|
@@ -379,6 +536,118 @@ function handle_profile_tool(args) {
|
|
|
379
536
|
};
|
|
380
537
|
}
|
|
381
538
|
// ---------------------------------------------------------------------------
|
|
539
|
+
// Tool handler: list_messages
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
function handle_list_messages_tool(args) {
|
|
542
|
+
const all_sessions = args?.all_sessions === true;
|
|
543
|
+
const direction = args?.direction;
|
|
544
|
+
const limit = typeof args?.limit === 'number' ? Math.max(1, Math.min(args.limit, 500)) : 50;
|
|
545
|
+
const include_body = args?.include_body === true;
|
|
546
|
+
const body_max_length = typeof args?.body_max_length === 'number' ? Math.max(0, args.body_max_length) : 2000;
|
|
547
|
+
const unread = typeof args?.unread === 'boolean' ? args.unread : undefined;
|
|
548
|
+
// Default to current session unless all_sessions is true
|
|
549
|
+
const agent_session_id = all_sessions ? undefined : (gateway?.agent_session_id ?? undefined);
|
|
550
|
+
// Validate direction if provided
|
|
551
|
+
if (direction !== undefined && direction !== 'inbound' && direction !== 'outbound') {
|
|
552
|
+
return {
|
|
553
|
+
content: [{ type: 'text', text: 'Error: direction must be "inbound" or "outbound"' }],
|
|
554
|
+
isError: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const messages = list_messages({ agent_session_id, direction, limit, include_body, body_max_length, unread });
|
|
558
|
+
if (messages.length === 0) {
|
|
559
|
+
const scope = all_sessions ? 'any session' : 'the current session';
|
|
560
|
+
const state = unread === true ? 'unread ' : unread === false ? 'read ' : '';
|
|
561
|
+
return {
|
|
562
|
+
content: [{ type: 'text', text: `No ${state}messages found for ${scope}.` }],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// When bodies are requested, return structured JSON so the caller can work with them.
|
|
566
|
+
if (include_body) {
|
|
567
|
+
return {
|
|
568
|
+
content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Format as a summary list (filename, direction, date, subject preview)
|
|
572
|
+
const lines = messages.map((msg) => {
|
|
573
|
+
const dir_arrow = msg.direction === 'inbound' ? '<-' : '->';
|
|
574
|
+
const preview = msg.subject.length > 60 ? msg.subject.slice(0, 60) + '...' : msg.subject;
|
|
575
|
+
return `${msg.filename} ${dir_arrow} ${msg.date} ${preview}`;
|
|
576
|
+
});
|
|
577
|
+
const header = `Messages (${messages.length}${messages.length >= limit ? '+' : ''}):`;
|
|
578
|
+
return {
|
|
579
|
+
content: [{ type: 'text', text: [header, ...lines].join('\n') }],
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Tool handler: read_message
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
function handle_read_message_tool(args) {
|
|
586
|
+
const filename = args?.filename;
|
|
587
|
+
if (typeof filename !== 'string' || filename.trim().length === 0) {
|
|
588
|
+
return {
|
|
589
|
+
content: [{ type: 'text', text: 'Error: filename parameter is required and must be non-empty' }],
|
|
590
|
+
isError: true,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
// mark_read defaults to true -- callers pass false to opt out
|
|
594
|
+
const should_mark = typeof args?.mark_read === 'boolean' ? args.mark_read : true;
|
|
595
|
+
const message = read_message(filename.trim(), { mark_read: should_mark });
|
|
596
|
+
if (!message) {
|
|
597
|
+
return {
|
|
598
|
+
content: [{ type: 'text', text: `Message not found: ${filename}` }],
|
|
599
|
+
isError: true,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
content: [{ type: 'text', text: JSON.stringify(message, null, 2) }],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// Tool handler: mark_read
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
function handle_mark_read_tool(args) {
|
|
610
|
+
const filenames_arg = args?.filenames;
|
|
611
|
+
if (!Array.isArray(filenames_arg) || filenames_arg.length === 0) {
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: 'text', text: 'Error: filenames parameter is required and must be a non-empty array' }],
|
|
614
|
+
isError: true,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
// Validate each filename is a non-empty string
|
|
618
|
+
const filenames = [];
|
|
619
|
+
for (const item of filenames_arg) {
|
|
620
|
+
if (typeof item !== 'string' || item.trim().length === 0) {
|
|
621
|
+
return {
|
|
622
|
+
content: [{ type: 'text', text: 'Error: every filename must be a non-empty string' }],
|
|
623
|
+
isError: true,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
filenames.push(item.trim());
|
|
627
|
+
}
|
|
628
|
+
const flags = typeof args?.flags === 'string' && args.flags.length > 0 ? args.flags : 'S';
|
|
629
|
+
const results = mark_read(filenames, flags);
|
|
630
|
+
// Format a human-readable summary alongside the structured result
|
|
631
|
+
const found_count = results.filter(r => r.found).length;
|
|
632
|
+
const missing_count = results.length - found_count;
|
|
633
|
+
const lines = [];
|
|
634
|
+
lines.push(`mark_read: ${found_count}/${results.length} marked with flags=${flags}`);
|
|
635
|
+
if (missing_count > 0) {
|
|
636
|
+
lines.push(` ${missing_count} not found`);
|
|
637
|
+
}
|
|
638
|
+
for (const r of results) {
|
|
639
|
+
if (r.found) {
|
|
640
|
+
lines.push(` [OK] ${r.filename} -> ${r.new_filename}`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
lines.push(` [??] ${r.filename} (not found)`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
382
651
|
// Push a channel notification for an inbound voice event
|
|
383
652
|
// ---------------------------------------------------------------------------
|
|
384
653
|
const EXTERNAL_MESSAGE_PREFIX = '[VoiceMode Connect - External Message]: ';
|
|
@@ -472,11 +741,32 @@ function start_gateway() {
|
|
|
472
741
|
const device_id = typeof user_id === 'string' && user_id.length > 0
|
|
473
742
|
? user_id
|
|
474
743
|
: undefined;
|
|
744
|
+
// Remember caller name for outbound reply attribution
|
|
745
|
+
last_caller_name = caller;
|
|
475
746
|
log(`Received voice event: from="${caller}" text="${truncate(safe_text.trim(), 80)}"`);
|
|
476
747
|
push_voice_event(caller, safe_text.trim(), device_id).catch((err) => {
|
|
477
748
|
const message = err instanceof Error ? err.message : String(err);
|
|
478
749
|
log(`Error pushing voice event to channel: ${message}`);
|
|
479
750
|
});
|
|
751
|
+
// Persist inbound message to Maildir (best-effort -- never break voice pipeline)
|
|
752
|
+
try {
|
|
753
|
+
const filename = write_message({
|
|
754
|
+
direction: 'inbound',
|
|
755
|
+
from_name: caller,
|
|
756
|
+
to_name: currentProfile.name,
|
|
757
|
+
text: safe_text.trim(),
|
|
758
|
+
session_id: gateway?.session_id ?? 'unknown',
|
|
759
|
+
agent_session_id: gateway?.agent_session_id ?? 'unknown',
|
|
760
|
+
agent_name: currentProfile.name,
|
|
761
|
+
});
|
|
762
|
+
if (filename) {
|
|
763
|
+
log(`Maildir: wrote inbound message ${filename}`, 'DEBUG');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
768
|
+
log(`Maildir write failed (non-fatal): ${message}`, 'WARN');
|
|
769
|
+
}
|
|
480
770
|
});
|
|
481
771
|
// Start the connection (non-blocking -- reconnects in the background)
|
|
482
772
|
gateway.start().catch((err) => {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maildir persistence for VoiceMode voice messages.
|
|
3
|
+
*
|
|
4
|
+
* Writes inbound (user speaks) and outbound (agent replies) messages to Maildir
|
|
5
|
+
* format so they can be indexed by notmuch and accessed via MCP tools.
|
|
6
|
+
*
|
|
7
|
+
* Maildir protocol: write to tmp/ first, then rename() to new/ (atomic, no locking).
|
|
8
|
+
* Dedup: content-hash filename (sha256(from|timestamp|text)[:16]) prevents duplicates.
|
|
9
|
+
*
|
|
10
|
+
* Environment variables:
|
|
11
|
+
* VOICEMODE_MAILDIR_PATH Override default ~/.voicemode/maildir/channel
|
|
12
|
+
* VOICEMODE_MAILDIR_ENABLED Set to 'false' to disable persistence
|
|
13
|
+
*/
|
|
14
|
+
export interface VoiceMessage {
|
|
15
|
+
direction: 'inbound' | 'outbound';
|
|
16
|
+
from_name: string;
|
|
17
|
+
to_name: string;
|
|
18
|
+
text: string;
|
|
19
|
+
session_id: string;
|
|
20
|
+
agent_session_id: string;
|
|
21
|
+
agent_name: string;
|
|
22
|
+
timestamp?: Date;
|
|
23
|
+
}
|
|
24
|
+
export interface MaildirMessage {
|
|
25
|
+
filename: string;
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
date: string;
|
|
29
|
+
subject: string;
|
|
30
|
+
direction: string;
|
|
31
|
+
session_id: string;
|
|
32
|
+
agent_session_id: string;
|
|
33
|
+
agent_name: string;
|
|
34
|
+
body: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function get_maildir_path(): string;
|
|
37
|
+
/**
|
|
38
|
+
* Write a voice message to Maildir.
|
|
39
|
+
*
|
|
40
|
+
* Returns the filename if written (or already exists), null if persistence is disabled.
|
|
41
|
+
* Uses atomic tmp/ -> new/ rename to guarantee no partial reads.
|
|
42
|
+
* Silently skips duplicate messages (same from/timestamp/text hash).
|
|
43
|
+
*/
|
|
44
|
+
export declare function write_message(msg: VoiceMessage): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Read a single message by filename from the Maildir.
|
|
47
|
+
*
|
|
48
|
+
* Security: rejects filenames containing '..' or '/' and verifies the
|
|
49
|
+
* resolved path is within the Maildir directory. Only returns messages
|
|
50
|
+
* with X-Transport: voicemode-connect header.
|
|
51
|
+
*
|
|
52
|
+
* Base-name lookup: the caller can pass either the bare filename or an
|
|
53
|
+
* already-flagged `:2,FLAGS` form -- we look up by base name across new/
|
|
54
|
+
* and cur/, matching the behaviour of mark_read.
|
|
55
|
+
*
|
|
56
|
+
* By default, a successful read marks the message as Seen (moves to cur/
|
|
57
|
+
* with S flag). Pass `{ mark_read: false }` to opt out. The returned
|
|
58
|
+
* MaildirMessage's `filename` field reflects the on-disk name after any
|
|
59
|
+
* rename (so callers can use it for subsequent operations).
|
|
60
|
+
*
|
|
61
|
+
* Returns null if the file doesn't exist, fails security checks, or
|
|
62
|
+
* has the wrong X-Transport header.
|
|
63
|
+
*/
|
|
64
|
+
export declare function read_message(filename: string, options?: {
|
|
65
|
+
mark_read?: boolean;
|
|
66
|
+
}): MaildirMessage | null;
|
|
67
|
+
/** Marker appended to bodies that exceed body_max_length. */
|
|
68
|
+
export declare const TRUNCATION_MARKER = "... [truncated]";
|
|
69
|
+
/**
|
|
70
|
+
* Truncate a body string to at most `max_length` characters, appending a
|
|
71
|
+
* clear marker when truncation happens. A `max_length` of 0 means unlimited
|
|
72
|
+
* (returns the body unchanged). Negative values are treated as 0.
|
|
73
|
+
*/
|
|
74
|
+
export declare function truncate_body(body: string, max_length: number): string;
|
|
75
|
+
/**
|
|
76
|
+
* List messages from the Maildir, filtered by session, direction, and read state.
|
|
77
|
+
*
|
|
78
|
+
* Scans both new/ and cur/ directories. Only includes messages with
|
|
79
|
+
* X-Transport: voicemode-connect header.
|
|
80
|
+
*
|
|
81
|
+
* Options:
|
|
82
|
+
* - include_body: when false (default), returned messages have body=''
|
|
83
|
+
* to keep responses compact. Set true to return bodies (truncated by
|
|
84
|
+
* body_max_length).
|
|
85
|
+
* - body_max_length: maximum body length when include_body is true.
|
|
86
|
+
* Default 2000. Pass 0 for unlimited. Bodies past the limit get a
|
|
87
|
+
* "... [truncated]" marker appended.
|
|
88
|
+
* - unread: undefined (default) returns both read and unread. true
|
|
89
|
+
* returns only unread messages (in new/, or in cur/ without S flag).
|
|
90
|
+
* false returns only read messages (in cur/ with S flag).
|
|
91
|
+
*
|
|
92
|
+
* Returns messages sorted by date descending (newest first).
|
|
93
|
+
*/
|
|
94
|
+
export declare function list_messages(options: {
|
|
95
|
+
agent_session_id?: string;
|
|
96
|
+
direction?: 'inbound' | 'outbound';
|
|
97
|
+
limit?: number;
|
|
98
|
+
include_body?: boolean;
|
|
99
|
+
body_max_length?: number;
|
|
100
|
+
unread?: boolean;
|
|
101
|
+
}): MaildirMessage[];
|
|
102
|
+
/**
|
|
103
|
+
* Parse a Maildir filename into its base and flag components.
|
|
104
|
+
*
|
|
105
|
+
* Maildir spec: filenames in cur/ use the form `<base>:2,<flags>` where flags
|
|
106
|
+
* are ASCII letters sorted alphabetically (e.g. "RS" = Replied + Seen).
|
|
107
|
+
* Files in new/ typically have no suffix.
|
|
108
|
+
*
|
|
109
|
+
* Only the experimental `:2,` variant is recognized -- `:1,` filenames are
|
|
110
|
+
* treated as having no flags, since that form is not used by notmuch/neomutt.
|
|
111
|
+
*/
|
|
112
|
+
export declare function parse_maildir_filename(filename: string): {
|
|
113
|
+
base: string;
|
|
114
|
+
flags: string;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Merge two flag strings into a single set of unique, alphabetically sorted
|
|
118
|
+
* uppercase ASCII-letter flags.
|
|
119
|
+
*
|
|
120
|
+
* Non-letter characters are dropped. Case is normalized to uppercase.
|
|
121
|
+
* Duplicates are removed. Output is sorted (standard Maildir flag order).
|
|
122
|
+
*/
|
|
123
|
+
export declare function merge_flags(existing: string, new_flags: string): string;
|
|
124
|
+
/**
|
|
125
|
+
* Build a full Maildir filename from its base and flag components.
|
|
126
|
+
* When flags is empty, returns just the base (suitable for new/ files).
|
|
127
|
+
*/
|
|
128
|
+
export declare function build_maildir_filename(base: string, flags: string): string;
|
|
129
|
+
/**
|
|
130
|
+
* Count unread messages in the Maildir.
|
|
131
|
+
*
|
|
132
|
+
* Unread = files in new/ (never touched) + files in cur/ without the S flag.
|
|
133
|
+
* Filters by filename prefix `vm-` so we don't have to read file contents to
|
|
134
|
+
* decide whether a file is a voicemode-channel message -- keeps the counter
|
|
135
|
+
* cheap enough to call on every status check / notification append.
|
|
136
|
+
*/
|
|
137
|
+
export declare function count_unread(): number;
|
|
138
|
+
export interface MarkReadResult {
|
|
139
|
+
/** The filename as passed in by the caller. */
|
|
140
|
+
filename: string;
|
|
141
|
+
/** New filename (with flags applied) if the file was found and renamed. */
|
|
142
|
+
new_filename: string | null;
|
|
143
|
+
/** True if the file was located in new/ or cur/. */
|
|
144
|
+
found: boolean;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Mark one or more messages with Maildir flags, moving them from new/ to cur/.
|
|
148
|
+
*
|
|
149
|
+
* For each filename:
|
|
150
|
+
* 1. Locate the file by its base name (with or without a :2,FLAGS suffix)
|
|
151
|
+
* in either new/ or cur/.
|
|
152
|
+
* 2. Merge the requested flags with any existing flags (unique, sorted).
|
|
153
|
+
* 3. Rename the file to `<base>:2,<flags>` in cur/.
|
|
154
|
+
*
|
|
155
|
+
* Security: filenames containing '..' or '/' are rejected (returns found=false).
|
|
156
|
+
*
|
|
157
|
+
* Idempotent: marking an already-marked file with the same flags is a no-op
|
|
158
|
+
* rename (the file is already in cur/ with those flags).
|
|
159
|
+
*
|
|
160
|
+
* @param filenames Basenames of Maildir files (as returned by list_messages)
|
|
161
|
+
* @param flags Letters to apply (default "S" = Seen). Case-insensitive.
|
|
162
|
+
* @returns One result per input filename, in the same order.
|
|
163
|
+
*/
|
|
164
|
+
export declare function mark_read(filenames: string[], flags?: string): MarkReadResult[];
|