@pellux/goodvibes-tui 0.24.0 → 0.25.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -5
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/daemon/calendar/caldav-client.ts +657 -0
  6. package/src/daemon/calendar/ics.ts +556 -0
  7. package/src/daemon/calendar/index.ts +52 -0
  8. package/src/daemon/calendar/register.ts +527 -0
  9. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  10. package/src/daemon/channels/drafts/index.ts +22 -0
  11. package/src/daemon/channels/drafts/register.ts +449 -0
  12. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  13. package/src/daemon/channels/inbox/index.ts +58 -0
  14. package/src/daemon/channels/inbox/mapping.ts +190 -0
  15. package/src/daemon/channels/inbox/poller.ts +155 -0
  16. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  17. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  18. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  19. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  20. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  21. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  22. package/src/daemon/channels/inbox/register.ts +247 -0
  23. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  24. package/src/daemon/channels/routing/index.ts +39 -0
  25. package/src/daemon/channels/routing/register.ts +296 -0
  26. package/src/daemon/channels/routing/route-store.ts +278 -0
  27. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  28. package/src/daemon/email/imap-connector.ts +441 -0
  29. package/src/daemon/email/imap-parsing.ts +499 -0
  30. package/src/daemon/email/index.ts +68 -0
  31. package/src/daemon/email/register.ts +715 -0
  32. package/src/daemon/email/smtp-connector.ts +557 -0
  33. package/src/daemon/operator/credential-store.ts +129 -0
  34. package/src/daemon/operator/index.ts +43 -0
  35. package/src/daemon/operator/register-helper.ts +150 -0
  36. package/src/daemon/operator/sqlite-store.ts +124 -0
  37. package/src/daemon/operator/surfaces.ts +137 -0
  38. package/src/daemon/operator/types.ts +207 -0
  39. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  40. package/src/daemon/remote/backends/docker.ts +80 -0
  41. package/src/daemon/remote/backends/index.ts +34 -0
  42. package/src/daemon/remote/backends/local-process.ts +113 -0
  43. package/src/daemon/remote/backends/process-runner.ts +151 -0
  44. package/src/daemon/remote/backends/ssh.ts +120 -0
  45. package/src/daemon/remote/backends/types.ts +71 -0
  46. package/src/daemon/remote/dispatcher.ts +160 -0
  47. package/src/daemon/remote/index.ts +74 -0
  48. package/src/daemon/remote/peer-registry.ts +321 -0
  49. package/src/daemon/remote/register.ts +411 -0
  50. package/src/daemon/triage/index.ts +59 -0
  51. package/src/daemon/triage/integration.ts +179 -0
  52. package/src/daemon/triage/pipeline.ts +285 -0
  53. package/src/daemon/triage/register.ts +231 -0
  54. package/src/daemon/triage/scorer.ts +287 -0
  55. package/src/daemon/triage/tagger.ts +777 -0
  56. package/src/runtime/services.ts +35 -0
  57. package/src/version.ts +1 -1
@@ -0,0 +1,441 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Daemon-owned IMAP connector.
3
+ //
4
+ // A dependency-free IMAP4rev1 client built on node:tls / node:net (Bun-compatible).
5
+ // The daemon owns this connector outright — it does NOT import any agent code.
6
+ //
7
+ // Read paths use EXAMINE (read-only) and BODY.PEEK so messages are never
8
+ // implicitly marked \Seen. Draft creation uses APPEND to the configured Drafts
9
+ // mailbox. Credentials are supplied by the caller (resolved from the daemon
10
+ // credential store) and are never logged.
11
+ //
12
+ // The pure wire-format / MIME parsing helpers live in `./imap-parsing.ts` and
13
+ // are re-exported from here so consumers keep a single import path.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import { connect as tlsConnect } from 'node:tls';
17
+ import { Socket } from 'node:net';
18
+ import { once } from 'node:events';
19
+ import {
20
+ ImapError,
21
+ quoteImapString,
22
+ parseSearchUids,
23
+ parseFetchSummaries,
24
+ parseFullMessage,
25
+ parseAppendUid,
26
+ toImapSearchDate,
27
+ type ImapEnvelopeSummary,
28
+ type ImapFullMessage,
29
+ } from './imap-parsing.ts';
30
+
31
+ // Re-export the pure parsing helpers + shared message types so existing
32
+ // consumers (and tests) can continue importing them from './imap-connector.ts'.
33
+ export {
34
+ ImapError,
35
+ toImapSearchDate,
36
+ quoteImapString,
37
+ parseSearchUids,
38
+ parseAppendUid,
39
+ parseEnvelope,
40
+ parseFetchSummaries,
41
+ parseFullMessage,
42
+ parseAddressList,
43
+ parseMimeMessage,
44
+ splitHeadersBody,
45
+ tokenizeParen,
46
+ extractParenValue,
47
+ extractLiteralFor,
48
+ unescapeImapString,
49
+ decodeTransferEncoding,
50
+ decodeQuotedPrintable,
51
+ decodeMimeWords,
52
+ stripHtml,
53
+ collapseWhitespace,
54
+ } from './imap-parsing.ts';
55
+ export type {
56
+ ImapEnvelopeSummary,
57
+ ImapFullMessage,
58
+ ImapAttachmentSummary,
59
+ ParsedMime,
60
+ } from './imap-parsing.ts';
61
+
62
+ export interface ImapConnectionSettings {
63
+ readonly host: string;
64
+ readonly port: number;
65
+ readonly user: string;
66
+ readonly password: string;
67
+ /** Use implicit TLS (port 993). When false, a plain socket is used. */
68
+ readonly secure: boolean;
69
+ /** Mailbox to read from. Defaults to 'INBOX'. */
70
+ readonly mailbox?: string;
71
+ /** Mailbox to append drafts to. Defaults to 'Drafts'. */
72
+ readonly draftsMailbox?: string;
73
+ /** Socket connect / command timeout in ms. Defaults to 30000. */
74
+ readonly timeoutMs?: number;
75
+ }
76
+
77
+ export interface ImapListOptions {
78
+ readonly limit: number;
79
+ readonly since?: string; // ISO-8601 date string
80
+ readonly unreadOnly: boolean;
81
+ }
82
+
83
+ export interface ImapAppendResult {
84
+ readonly uid: number;
85
+ readonly mailbox: string;
86
+ }
87
+
88
+ type RawSocket = Socket;
89
+
90
+ const CRLF = '\r\n';
91
+ const DEFAULT_TIMEOUT_MS = 30_000;
92
+
93
+ /**
94
+ * Low-level line/tag oriented IMAP client. One tagged command in flight at a
95
+ * time, which is sufficient for the daemon's sequential list/read/append flows.
96
+ */
97
+ export class ImapConnector {
98
+ private socket: RawSocket | null = null;
99
+ private buffer = '';
100
+ private tagCounter = 0;
101
+ private readonly settings: ImapConnectionSettings;
102
+ private readonly timeoutMs: number;
103
+ private dataWaiters: Array<() => void> = [];
104
+ private closed = false;
105
+
106
+ constructor(settings: ImapConnectionSettings) {
107
+ if (!settings.host) throw new ImapError('IMAP host is required', 'IMAP_CONFIG');
108
+ if (!settings.user) throw new ImapError('IMAP user is required', 'IMAP_CONFIG');
109
+ if (!settings.password) throw new ImapError('IMAP password is required', 'IMAP_CONFIG');
110
+ this.settings = settings;
111
+ this.timeoutMs = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // Connection lifecycle
116
+ // -------------------------------------------------------------------------
117
+
118
+ async connect(): Promise<void> {
119
+ const { host, port, secure } = this.settings;
120
+ const socket: RawSocket = secure
121
+ ? (tlsConnect({ host, port, servername: host }) as unknown as RawSocket)
122
+ : new Socket();
123
+
124
+ socket.setEncoding('utf-8');
125
+ socket.setTimeout(this.timeoutMs);
126
+ this.socket = socket;
127
+
128
+ socket.on('data', (chunk: string | Buffer) => {
129
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
130
+ this.flushWaiters();
131
+ });
132
+ socket.on('close', () => {
133
+ this.closed = true;
134
+ this.flushWaiters();
135
+ });
136
+ socket.on('error', () => {
137
+ this.closed = true;
138
+ this.flushWaiters();
139
+ });
140
+
141
+ const eventName = secure ? 'secureConnect' : 'connect';
142
+ if (!secure) {
143
+ (socket as Socket).connect({ host, port });
144
+ }
145
+ await this.waitForEvent(socket, eventName);
146
+ // Server greeting (untagged OK).
147
+ await this.readUntagged();
148
+ await this.login();
149
+ }
150
+
151
+ private async waitForEvent(socket: RawSocket, eventName: string): Promise<void> {
152
+ const timer = this.armTimeout(`Timed out waiting for ${eventName}`);
153
+ try {
154
+ await once(socket, eventName);
155
+ } finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+
160
+ private armTimeout(message: string): NodeJS.Timeout {
161
+ return setTimeout(() => {
162
+ this.destroy(new ImapError(message, 'IMAP_TIMEOUT'));
163
+ }, this.timeoutMs);
164
+ }
165
+
166
+ private destroy(error?: ImapError): void {
167
+ this.closed = true;
168
+ if (this.socket) {
169
+ this.socket.removeAllListeners('data');
170
+ this.socket.destroy();
171
+ this.socket = null;
172
+ }
173
+ this.flushWaiters();
174
+ if (error) throw error;
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ if (this.closed || !this.socket) {
179
+ this.closed = true;
180
+ this.socket = null;
181
+ return;
182
+ }
183
+ try {
184
+ await this.command('LOGOUT');
185
+ } catch {
186
+ // Ignore — logout best-effort.
187
+ } finally {
188
+ if (this.socket) {
189
+ this.socket.removeAllListeners('data');
190
+ this.socket.destroy();
191
+ }
192
+ this.socket = null;
193
+ this.closed = true;
194
+ }
195
+ }
196
+
197
+ // -------------------------------------------------------------------------
198
+ // Wire I/O
199
+ // -------------------------------------------------------------------------
200
+
201
+ private nextTag(): string {
202
+ this.tagCounter += 1;
203
+ return `A${String(this.tagCounter).padStart(4, '0')}`;
204
+ }
205
+
206
+ private write(line: string): void {
207
+ if (!this.socket || this.closed) {
208
+ throw new ImapError('IMAP socket is not connected', 'IMAP_CLOSED');
209
+ }
210
+ this.socket.write(line);
211
+ }
212
+
213
+ private flushWaiters(): void {
214
+ const waiters = this.dataWaiters;
215
+ this.dataWaiters = [];
216
+ for (const resolve of waiters) resolve();
217
+ }
218
+
219
+ private waitForData(): Promise<void> {
220
+ if (this.closed) return Promise.resolve();
221
+ return new Promise<void>((resolve) => {
222
+ this.dataWaiters.push(resolve);
223
+ });
224
+ }
225
+
226
+ /** Read the initial untagged greeting line. */
227
+ private async readUntagged(): Promise<string> {
228
+ const timer = this.armTimeout('Timed out waiting for server greeting');
229
+ try {
230
+ while (!this.buffer.includes(CRLF)) {
231
+ if (this.closed) throw new ImapError('Connection closed before greeting', 'IMAP_CLOSED');
232
+ await this.waitForData();
233
+ }
234
+ const idx = this.buffer.indexOf(CRLF);
235
+ const line = this.buffer.slice(0, idx);
236
+ this.buffer = this.buffer.slice(idx + CRLF.length);
237
+ if (!/^\* (OK|PREAUTH)/i.test(line)) {
238
+ throw new ImapError(`Unexpected greeting: ${line}`, 'IMAP_GREETING');
239
+ }
240
+ return line;
241
+ } finally {
242
+ clearTimeout(timer);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Send a tagged command and collect the full response (all untagged lines
248
+ * plus the tagged completion line). Handles IMAP literals ({n}) transparently.
249
+ */
250
+ private async command(commandText: string): Promise<{ lines: string[]; status: string }> {
251
+ const tag = this.nextTag();
252
+ this.write(`${tag} ${commandText}${CRLF}`);
253
+ return this.readResponse(tag);
254
+ }
255
+
256
+ private async readResponse(tag: string): Promise<{ lines: string[]; status: string }> {
257
+ const lines: string[] = [];
258
+ const timer = this.armTimeout(`Timed out waiting for response to ${tag}`);
259
+ try {
260
+ for (;;) {
261
+ const line = await this.readLine();
262
+ if (line === null) {
263
+ throw new ImapError('Connection closed during command', 'IMAP_CLOSED');
264
+ }
265
+ // Handle a trailing literal: {n} at end of line means n octets follow.
266
+ const literalMatch = /\{(\d+)\}$/.exec(line);
267
+ if (literalMatch) {
268
+ const octets = Number(literalMatch[1]);
269
+ const literal = await this.readOctets(octets);
270
+ lines.push(`${line}\n${literal}`);
271
+ continue;
272
+ }
273
+ const tagged = new RegExp(`^${tag} (OK|NO|BAD)`, 'i').exec(line);
274
+ if (tagged) {
275
+ const status = tagged[1].toUpperCase();
276
+ if (status !== 'OK') {
277
+ throw new ImapError(`IMAP command failed: ${line}`, `IMAP_${status}`);
278
+ }
279
+ // Include the tagged completion line so callers can read response
280
+ // codes carried there (e.g. [APPENDUID validity uid]).
281
+ lines.push(line);
282
+ return { lines, status };
283
+ }
284
+ lines.push(line);
285
+ }
286
+ } finally {
287
+ clearTimeout(timer);
288
+ }
289
+ }
290
+
291
+ private async readLine(): Promise<string | null> {
292
+ for (;;) {
293
+ const idx = this.buffer.indexOf(CRLF);
294
+ if (idx >= 0) {
295
+ const line = this.buffer.slice(0, idx);
296
+ this.buffer = this.buffer.slice(idx + CRLF.length);
297
+ return line;
298
+ }
299
+ if (this.closed) {
300
+ if (this.buffer.length > 0) {
301
+ const remaining = this.buffer;
302
+ this.buffer = '';
303
+ return remaining;
304
+ }
305
+ return null;
306
+ }
307
+ await this.waitForData();
308
+ }
309
+ }
310
+
311
+ private async readOctets(count: number): Promise<string> {
312
+ while (this.buffer.length < count) {
313
+ if (this.closed) break;
314
+ await this.waitForData();
315
+ }
316
+ const data = this.buffer.slice(0, count);
317
+ this.buffer = this.buffer.slice(count);
318
+ return data;
319
+ }
320
+
321
+ // -------------------------------------------------------------------------
322
+ // Auth + mailbox selection
323
+ // -------------------------------------------------------------------------
324
+
325
+ private async login(): Promise<void> {
326
+ // LOGIN with quoted-string arguments (escaping backslash + quote per RFC 3501).
327
+ const user = quoteImapString(this.settings.user);
328
+ const pass = quoteImapString(this.settings.password);
329
+ await this.command(`LOGIN ${user} ${pass}`);
330
+ }
331
+
332
+ /** Open a mailbox read-only (EXAMINE) — never sets \Seen on fetch. */
333
+ private async examine(mailbox: string): Promise<void> {
334
+ await this.command(`EXAMINE ${quoteImapString(mailbox)}`);
335
+ }
336
+
337
+ /** Open a mailbox read-write (SELECT) — required before APPEND validation. */
338
+ private async select(mailbox: string): Promise<void> {
339
+ await this.command(`SELECT ${quoteImapString(mailbox)}`);
340
+ }
341
+
342
+ // -------------------------------------------------------------------------
343
+ // High-level operations
344
+ // -------------------------------------------------------------------------
345
+
346
+ /**
347
+ * List recent messages via read-only EXAMINE + UID SEARCH + UID FETCH.
348
+ * Does NOT mark messages as read (EXAMINE + BODY.PEEK).
349
+ */
350
+ async listMessages(options: ImapListOptions): Promise<ImapEnvelopeSummary[]> {
351
+ const mailbox = this.settings.mailbox ?? 'INBOX';
352
+ await this.examine(mailbox);
353
+
354
+ const criteria: string[] = [];
355
+ if (options.unreadOnly) criteria.push('UNSEEN');
356
+ if (options.since) criteria.push(`SINCE ${toImapSearchDate(options.since)}`);
357
+ if (criteria.length === 0) criteria.push('ALL');
358
+
359
+ const searchResp = await this.command(`UID SEARCH ${criteria.join(' ')}`);
360
+ const uids = parseSearchUids(searchResp.lines);
361
+ if (uids.length === 0) return [];
362
+
363
+ // Most-recent-first, capped at limit.
364
+ const selected = uids.slice(-options.limit).reverse();
365
+ const set = selected.join(',');
366
+ const fetchResp = await this.command(
367
+ `UID FETCH ${set} (UID FLAGS ENVELOPE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] BODY.PEEK[TEXT]<0.512>)`,
368
+ );
369
+ const parsed = parseFetchSummaries(fetchResp.lines);
370
+ // Preserve the most-recent-first ordering established above.
371
+ const byUid = new Map(parsed.map((m) => [m.uid, m]));
372
+ return selected
373
+ .map((uid) => byUid.get(uid))
374
+ .filter((m): m is ImapEnvelopeSummary => m !== undefined);
375
+ }
376
+
377
+ /**
378
+ * Fetch a full message body by UID using BODY.PEEK (no \Seen).
379
+ */
380
+ async readMessage(uid: number): Promise<ImapFullMessage> {
381
+ if (!Number.isInteger(uid) || uid <= 0) {
382
+ throw new ImapError(`Invalid UID: ${uid}`, 'IMAP_BAD_UID');
383
+ }
384
+ const mailbox = this.settings.mailbox ?? 'INBOX';
385
+ await this.examine(mailbox);
386
+ const fetchResp = await this.command(
387
+ `UID FETCH ${uid} (UID ENVELOPE BODY.PEEK[])`,
388
+ );
389
+ const message = parseFullMessage(uid, fetchResp.lines);
390
+ if (!message) {
391
+ throw new ImapError(`Message UID ${uid} not found`, 'IMAP_NOT_FOUND');
392
+ }
393
+ return message;
394
+ }
395
+
396
+ /**
397
+ * Append a fully-formed RFC 5322 message to the Drafts mailbox.
398
+ * Returns the assigned UID when the server reports APPENDUID, else 0.
399
+ */
400
+ async appendDraft(rawMessage: string): Promise<ImapAppendResult> {
401
+ const mailbox = this.settings.draftsMailbox ?? 'Drafts';
402
+ // Ensure the mailbox exists / is selectable; ignore CREATE failure when it
403
+ // already exists.
404
+ try {
405
+ await this.command(`CREATE ${quoteImapString(mailbox)}`);
406
+ } catch {
407
+ // Already exists — fine.
408
+ }
409
+ await this.select(mailbox);
410
+
411
+ const normalized = rawMessage.replace(/\r?\n/g, CRLF);
412
+ const octets = Buffer.byteLength(normalized, 'utf-8');
413
+ const tag = this.nextTag();
414
+ // Send the command line ending with the literal length; server replies with
415
+ // a continuation request ('+ ...') before we stream the message bytes.
416
+ this.write(`${tag} APPEND ${quoteImapString(mailbox)} (\\Draft) {${octets}}${CRLF}`);
417
+ await this.waitForContinuation();
418
+ this.write(normalized + CRLF);
419
+ const resp = await this.readResponse(tag);
420
+ const uid = parseAppendUid(resp.lines);
421
+ return { uid, mailbox };
422
+ }
423
+
424
+ private async waitForContinuation(): Promise<void> {
425
+ const timer = this.armTimeout('Timed out waiting for APPEND continuation');
426
+ try {
427
+ for (;;) {
428
+ const line = await this.readLine();
429
+ if (line === null) {
430
+ throw new ImapError('Connection closed awaiting continuation', 'IMAP_CLOSED');
431
+ }
432
+ if (line.startsWith('+')) return;
433
+ if (/^A\d+ (NO|BAD)/i.test(line)) {
434
+ throw new ImapError(`APPEND rejected: ${line}`, 'IMAP_APPEND_REJECTED');
435
+ }
436
+ }
437
+ } finally {
438
+ clearTimeout(timer);
439
+ }
440
+ }
441
+ }