@pellux/goodvibes-tui 0.24.1 → 0.26.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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
  6. package/src/daemon/handlers/calendar/ics.ts +556 -0
  7. package/src/daemon/handlers/calendar/index.ts +316 -0
  8. package/src/daemon/handlers/context.ts +29 -0
  9. package/src/daemon/handlers/contracts.ts +77 -0
  10. package/src/daemon/handlers/credentials.ts +129 -0
  11. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  12. package/src/daemon/handlers/drafts/index.ts +17 -0
  13. package/src/daemon/handlers/drafts/register.ts +331 -0
  14. package/src/daemon/handlers/email/config.ts +164 -0
  15. package/src/daemon/handlers/email/imap-connector.ts +441 -0
  16. package/src/daemon/handlers/email/imap-parsing.ts +499 -0
  17. package/src/daemon/handlers/email/index.ts +43 -0
  18. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  19. package/src/daemon/handlers/email/runtime.ts +140 -0
  20. package/src/daemon/handlers/email/smtp-connector.ts +557 -0
  21. package/src/daemon/handlers/email/validation.ts +147 -0
  22. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  23. package/src/daemon/handlers/errors.ts +18 -0
  24. package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
  25. package/src/daemon/handlers/inbox/index.ts +210 -0
  26. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  27. package/src/daemon/handlers/inbox/poller.ts +155 -0
  28. package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
  29. package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
  30. package/src/daemon/handlers/inbox/providers/email.ts +151 -0
  31. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  32. package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
  33. package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
  34. package/src/daemon/handlers/index.ts +107 -0
  35. package/src/daemon/handlers/register.ts +161 -0
  36. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
  37. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  38. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  39. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  40. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  41. package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
  42. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  43. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  44. package/src/daemon/handlers/remote/index.ts +119 -0
  45. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  46. package/src/daemon/handlers/remote/service.ts +191 -0
  47. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  48. package/src/daemon/handlers/routing/index.ts +261 -0
  49. package/src/daemon/handlers/routing/route-store.ts +319 -0
  50. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  51. package/src/daemon/handlers/sqlite-store.ts +124 -0
  52. package/src/daemon/handlers/triage/index.ts +57 -0
  53. package/src/daemon/handlers/triage/integration.ts +212 -0
  54. package/src/daemon/handlers/triage/pipeline.ts +273 -0
  55. package/src/daemon/handlers/triage/scorer.ts +287 -0
  56. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  57. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  58. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  59. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  60. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  61. package/src/daemon/handlers/triage/types.ts +50 -0
  62. package/src/runtime/services.ts +48 -0
  63. package/src/version.ts +1 -1
@@ -0,0 +1,300 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Minimal, dependency-free IMAPS client (RFC 3501 subset) over node:tls.
3
+ //
4
+ // Implements exactly what the inbound poller needs:
5
+ // LOGIN, SELECT, UID SEARCH (SINCE / ALL), UID FETCH (ENVELOPE + body peek),
6
+ // LOGOUT. No external npm dependency — uses node:tls (Bun-compatible).
7
+ //
8
+ // This is intentionally conservative: line-buffered tagged-command protocol,
9
+ // per-command timeout, and a hard cap on response size to avoid unbounded
10
+ // memory growth from a hostile/large mailbox.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { connect as tlsConnect } from 'node:tls';
14
+ import type { TLSSocket } from 'node:tls';
15
+
16
+ export interface ImapConfig {
17
+ host: string;
18
+ port: number; // 993 for IMAPS
19
+ user: string;
20
+ password: string;
21
+ /** Per-command timeout in ms. */
22
+ timeoutMs?: number;
23
+ /** Hard cap on bytes buffered per command (defense against huge fetches). */
24
+ maxResponseBytes?: number;
25
+ }
26
+
27
+ export interface ImapEnvelope {
28
+ uid: number;
29
+ from: string; // raw From header value (digested by the adapter)
30
+ subject: string;
31
+ date: number; // Unix ms (0 when unparseable)
32
+ seen: boolean;
33
+ bodyPreview: string; // first text fragment, raw (sanitized by the adapter)
34
+ }
35
+
36
+ const DEFAULT_TIMEOUT_MS = 20_000;
37
+ const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
38
+
39
+ export class ImapClient {
40
+ private socket: TLSSocket | null = null;
41
+ private tagCounter = 0;
42
+ private buffer = '';
43
+ private readonly cfg: Required<ImapConfig>;
44
+
45
+ constructor(cfg: ImapConfig) {
46
+ this.cfg = {
47
+ timeoutMs: DEFAULT_TIMEOUT_MS,
48
+ maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,
49
+ ...cfg,
50
+ };
51
+ }
52
+
53
+ async connect(): Promise<void> {
54
+ await new Promise<void>((resolve, reject) => {
55
+ const socket = tlsConnect(
56
+ { host: this.cfg.host, port: this.cfg.port, servername: this.cfg.host },
57
+ () => {
58
+ resolve();
59
+ },
60
+ );
61
+ socket.setEncoding('utf-8');
62
+ socket.once('error', reject);
63
+ this.socket = socket;
64
+ });
65
+ // Consume the server greeting (untagged * OK ...).
66
+ await this.readUntil((chunk) => /\r?\n/.test(chunk), 'greeting');
67
+ }
68
+
69
+ async login(): Promise<void> {
70
+ const user = quote(this.cfg.user);
71
+ const pass = quote(this.cfg.password);
72
+ await this.command(`LOGIN ${user} ${pass}`);
73
+ }
74
+
75
+ /** SELECT a mailbox (default INBOX). */
76
+ async select(mailbox = 'INBOX'): Promise<void> {
77
+ await this.command(`SELECT ${quote(mailbox)}`);
78
+ }
79
+
80
+ /** UID SEARCH; returns matching UIDs. `since` filters by internal date. */
81
+ async searchUids(since?: number): Promise<number[]> {
82
+ const criteria = since ? `SINCE ${imapDate(since)}` : 'ALL';
83
+ const lines = await this.command(`UID SEARCH ${criteria}`);
84
+ const uids: number[] = [];
85
+ for (const line of lines) {
86
+ const match = /^\* SEARCH(.*)$/i.exec(line.trim());
87
+ if (match) {
88
+ for (const tok of match[1]!.trim().split(/\s+/)) {
89
+ const n = Number.parseInt(tok, 10);
90
+ if (Number.isFinite(n)) uids.push(n);
91
+ }
92
+ }
93
+ }
94
+ return uids;
95
+ }
96
+
97
+ /**
98
+ * UID FETCH envelope + flags + a small text body peek for the given uids.
99
+ * Returns one ImapEnvelope per uid that parsed successfully.
100
+ */
101
+ async fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]> {
102
+ if (uids.length === 0) return [];
103
+ const set = uids.join(',');
104
+ // BODY.PEEK[HEADER.FIELDS (...)] avoids setting \Seen; TEXT peek for preview.
105
+ const lines = await this.command(
106
+ `UID FETCH ${set} (UID FLAGS INTERNALDATE `
107
+ + `BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] `
108
+ + `BODY.PEEK[TEXT]<0.600>)`,
109
+ );
110
+ return parseFetchResponse(lines.join('\r\n'));
111
+ }
112
+
113
+ async logout(): Promise<void> {
114
+ if (!this.socket) return;
115
+ try {
116
+ await this.command('LOGOUT');
117
+ } catch {
118
+ // ignore logout failures
119
+ }
120
+ }
121
+
122
+ close(): void {
123
+ if (this.socket) {
124
+ this.socket.destroy();
125
+ this.socket = null;
126
+ }
127
+ }
128
+
129
+ // -------------------------------------------------------------------------
130
+ // Protocol plumbing
131
+ // -------------------------------------------------------------------------
132
+
133
+ private nextTag(): string {
134
+ this.tagCounter += 1;
135
+ return `A${this.tagCounter.toString().padStart(4, '0')}`;
136
+ }
137
+
138
+ private requireSocket(): TLSSocket {
139
+ if (!this.socket) throw new Error('IMAP socket not connected');
140
+ return this.socket;
141
+ }
142
+
143
+ /** Send a tagged command and collect all response lines up to the tagged OK. */
144
+ private async command(text: string): Promise<string[]> {
145
+ const tag = this.nextTag();
146
+ const socket = this.requireSocket();
147
+ socket.write(`${tag} ${text}\r\n`);
148
+ const taggedOk = new RegExp(`^${tag} (OK|NO|BAD)\\b`, 'm');
149
+ const raw = await this.readUntil((buf) => taggedOk.test(buf), text);
150
+ const lines = raw.split(/\r?\n/);
151
+ const statusLine = lines.find((l) => new RegExp(`^${tag} `).test(l)) ?? '';
152
+ const status = /^A\d+ (OK|NO|BAD)/.exec(statusLine)?.[1];
153
+ if (status !== 'OK') {
154
+ throw new Error(`IMAP command failed: ${redactCommand(text)} -> ${statusLine.trim()}`);
155
+ }
156
+ return lines.filter((l) => l.startsWith('*'));
157
+ }
158
+
159
+ /** Read from the socket until `predicate(buffer)` is true or timeout. */
160
+ private readUntil(predicate: (buf: string) => boolean, label: string): Promise<string> {
161
+ const socket = this.requireSocket();
162
+ return new Promise<string>((resolve, reject) => {
163
+ const onData = (chunk: string): void => {
164
+ this.buffer += chunk;
165
+ if (this.buffer.length > this.cfg.maxResponseBytes) {
166
+ cleanup();
167
+ reject(new Error(`IMAP response exceeded ${this.cfg.maxResponseBytes} bytes (${label})`));
168
+ return;
169
+ }
170
+ if (predicate(this.buffer)) {
171
+ const out = this.buffer;
172
+ this.buffer = '';
173
+ cleanup();
174
+ resolve(out);
175
+ }
176
+ };
177
+ const onError = (err: Error): void => {
178
+ cleanup();
179
+ reject(err);
180
+ };
181
+ const onClose = (): void => {
182
+ cleanup();
183
+ reject(new Error(`IMAP connection closed during ${label}`));
184
+ };
185
+ const timer = setTimeout(() => {
186
+ cleanup();
187
+ reject(new Error(`IMAP timeout after ${this.cfg.timeoutMs}ms (${label})`));
188
+ }, this.cfg.timeoutMs);
189
+ const cleanup = (): void => {
190
+ clearTimeout(timer);
191
+ socket.off('data', onData);
192
+ socket.off('error', onError);
193
+ socket.off('close', onClose);
194
+ };
195
+ socket.on('data', onData);
196
+ socket.on('error', onError);
197
+ socket.on('close', onClose);
198
+ });
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Pure parsers (exported for unit testing)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** Quote an IMAP astring, escaping backslashes and double quotes. */
207
+ function quote(value: string): string {
208
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
209
+ }
210
+
211
+ /** Never echo a LOGIN password in an error message. */
212
+ function redactCommand(text: string): string {
213
+ return /^LOGIN\b/i.test(text) ? 'LOGIN <redacted>' : text;
214
+ }
215
+
216
+ /** Format a Unix-ms timestamp as an IMAP date (dd-Mon-yyyy). */
217
+ export function imapDate(ms: number): string {
218
+ const d = new Date(ms);
219
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
220
+ const day = String(d.getUTCDate()).padStart(2, '0');
221
+ return `${day}-${months[d.getUTCMonth()]}-${d.getUTCFullYear()}`;
222
+ }
223
+
224
+ const HEADER_FROM_RE = /^From:\s*(.*)$/im;
225
+ const HEADER_SUBJECT_RE = /^Subject:\s*(.*)$/im;
226
+ const HEADER_DATE_RE = /^Date:\s*(.*)$/im;
227
+
228
+ /**
229
+ * Parse a UID FETCH response block into envelopes. Robust to interleaving and
230
+ * partial fields; entries that lack a UID are skipped.
231
+ */
232
+ export function parseFetchResponse(raw: string): ImapEnvelope[] {
233
+ const envelopes: ImapEnvelope[] = [];
234
+ // Split on each "* <n> FETCH (" boundary.
235
+ const blocks = raw.split(/\* \d+ FETCH \(/i).slice(1);
236
+ for (const block of blocks) {
237
+ const uidMatch = /UID (\d+)/i.exec(block);
238
+ if (!uidMatch) continue;
239
+ const uid = Number.parseInt(uidMatch[1]!, 10);
240
+ const seen = /FLAGS \([^)]*\\Seen/i.test(block);
241
+
242
+ // Header literal: BODY[HEADER.FIELDS (...)] {n}\r\n<header bytes>
243
+ const headerText = extractLiteral(block, /BODY\[HEADER\.FIELDS[^\]]*\]/i);
244
+ const bodyText = extractLiteral(block, /BODY\[TEXT\](?:<\d+(?:\.\d+)?>)?/i);
245
+
246
+ const from = HEADER_FROM_RE.exec(headerText)?.[1]?.trim() ?? '';
247
+ const subject = decodeHeader(HEADER_SUBJECT_RE.exec(headerText)?.[1]?.trim() ?? '');
248
+ const dateRaw = HEADER_DATE_RE.exec(headerText)?.[1]?.trim() ?? '';
249
+ const parsedDate = dateRaw ? Date.parse(dateRaw) : NaN;
250
+
251
+ envelopes.push({
252
+ uid,
253
+ from,
254
+ subject,
255
+ date: Number.isFinite(parsedDate) ? parsedDate : 0,
256
+ seen,
257
+ bodyPreview: bodyText,
258
+ });
259
+ }
260
+ return envelopes;
261
+ }
262
+
263
+ /**
264
+ * Extract a literal `{n}\r\n<bytes>` that follows a section header matched by
265
+ * `sectionRe`. Returns the literal content (n bytes) or ''.
266
+ */
267
+ function extractLiteral(block: string, sectionRe: RegExp): string {
268
+ const sectionMatch = sectionRe.exec(block);
269
+ if (!sectionMatch) return '';
270
+ const after = block.slice(sectionMatch.index + sectionMatch[0].length);
271
+ const litMatch = /^\s*\{(\d+)\}\r?\n/.exec(after);
272
+ if (!litMatch) {
273
+ // Quoted-string form: BODY[...] "value"
274
+ const q = /^\s*"((?:[^"\\]|\\.)*)"/.exec(after);
275
+ return q ? q[1]!.replace(/\\"/g, '"') : '';
276
+ }
277
+ const n = Number.parseInt(litMatch[1]!, 10);
278
+ const start = litMatch.index + litMatch[0].length;
279
+ return after.slice(start, start + n);
280
+ }
281
+
282
+ /** Decode RFC 2047 encoded-word subjects (UTF-8 B/Q) best-effort. */
283
+ export function decodeHeader(value: string): string {
284
+ return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_m, _charset, enc, text) => {
285
+ try {
286
+ if (enc.toUpperCase() === 'B') {
287
+ return Buffer.from(text, 'base64').toString('utf-8');
288
+ }
289
+ // Q-encoding: _ -> space, =XX -> byte
290
+ const replaced = String(text)
291
+ .replace(/_/g, ' ')
292
+ .replace(/=([0-9A-Fa-f]{2})/g, (_s: string, hex: string) =>
293
+ String.fromCharCode(Number.parseInt(hex, 16)),
294
+ );
295
+ return replaced;
296
+ } catch {
297
+ return text;
298
+ }
299
+ });
300
+ }
@@ -0,0 +1,23 @@
1
+ // Shared best-effort route resolution wrapper used by all adapters.
2
+ // Swallows resolver failures (routing is an optional, concurrently-wired
3
+ // surface) so a route lookup can never crash a provider poll.
4
+
5
+ import type { AdapterContext, InboundChannelItem } from '../provider-adapter.ts';
6
+
7
+ export async function resolveRouteId(
8
+ ctx: AdapterContext,
9
+ provider: string,
10
+ fromDigest: string,
11
+ kind: InboundChannelItem['kind'],
12
+ ): Promise<string | undefined> {
13
+ if (!ctx.resolveRouteId) return undefined;
14
+ try {
15
+ return (await ctx.resolveRouteId({ provider, fromDigest, kind })) ?? undefined;
16
+ } catch (error) {
17
+ ctx.logger.warn('route resolution failed', {
18
+ provider,
19
+ error: error instanceof Error ? error.message : String(error),
20
+ });
21
+ return undefined;
22
+ }
23
+ }
@@ -0,0 +1,262 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Slack inbound adapter.
3
+ //
4
+ // Transport note (contract fidelity): the daemon-handoff checklist names the
5
+ // Slack Events API / RTM for DM polling. Those are push transports (HTTP event
6
+ // callbacks / a persistent websocket) and cannot be driven by the inbound
7
+ // poller, whose contract is a stateless, cadence-driven adapter.poll() that
8
+ // MUST resolve each call (see provider-adapter.ts). A websocket/event-callback
9
+ // would require an inbound HTTP endpoint or a long-lived socket the poller
10
+ // neither owns nor supervises. We therefore satisfy the DM-polling goal over
11
+ // the supported request/response surface (the Slack Web API), which the Events
12
+ // API itself documents as the canonical pull-based equivalent for reading DM
13
+ // history on an interval:
14
+ // conversations.list (types=im) -> open DM channels (cursor-paginated)
15
+ // conversations.history -> recent messages per DM
16
+ // auth.test -> our own user id (for @-mention class.);
17
+ // id is digested, never emitted raw.
18
+ //
19
+ // Credential: surfaces.slack.botToken. Missing => 'unavailable'.
20
+ // Cadence: 30s (realtime tier).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import type {
24
+ AdapterContext,
25
+ InboundChannelItem,
26
+ InboundProviderAdapter,
27
+ ProviderPollOptions,
28
+ ProviderPollResult,
29
+ } from '../provider-adapter.ts';
30
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
31
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
32
+ import { resolveRouteId } from './route-util.ts';
33
+
34
+ const SLACK_API = 'https://slack.com/api';
35
+ export const SLACK_PROVIDER_ID = 'slack';
36
+ export const SLACK_CREDENTIAL_KEY = 'surfaces.slack.botToken';
37
+
38
+ interface SlackConversationsListResponse {
39
+ ok: boolean;
40
+ error?: string;
41
+ channels?: Array<{ id: string; user?: string }>;
42
+ response_metadata?: { next_cursor?: string };
43
+ }
44
+
45
+ interface SlackAuthTestResponse {
46
+ ok: boolean;
47
+ error?: string;
48
+ user_id?: string;
49
+ }
50
+
51
+ interface SlackReaction {
52
+ name?: string;
53
+ count?: number;
54
+ users?: string[];
55
+ }
56
+
57
+ interface SlackMessage {
58
+ type?: string;
59
+ subtype?: string;
60
+ user?: string;
61
+ bot_id?: string;
62
+ text?: string;
63
+ ts?: string; // "1700000000.000200"
64
+ thread_ts?: string;
65
+ reactions?: SlackReaction[];
66
+ }
67
+
68
+ interface SlackHistoryResponse {
69
+ ok: boolean;
70
+ error?: string;
71
+ messages?: SlackMessage[];
72
+ has_more?: boolean;
73
+ response_metadata?: { next_cursor?: string };
74
+ }
75
+
76
+ /**
77
+ * Classify a Slack message into the InboundChannelItem `kind`.
78
+ * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
79
+ * event. Requires the message to be authored by us (its user id
80
+ * is selfUserId) AND to carry a non-empty reactions[]. A message
81
+ * authored by someone else that merely carries reactions[] is a
82
+ * normal DM, not a reaction event.
83
+ * - mention: the message @-mentions us (text contains `<@SELF>`)
84
+ * - thread: a threaded reply (thread_ts present and not the root ts)
85
+ * - dm: a plain direct message
86
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
87
+ */
88
+ function classifySlackKind(
89
+ msg: SlackMessage,
90
+ selfUserId: string | undefined,
91
+ ): InboundChannelItem['kind'] {
92
+ if (
93
+ selfUserId &&
94
+ msg.user === selfUserId &&
95
+ Array.isArray(msg.reactions) &&
96
+ msg.reactions.length > 0
97
+ ) {
98
+ return 'reaction';
99
+ }
100
+ if (selfUserId && typeof msg.text === 'string' && msg.text.includes(`<@${selfUserId}>`)) {
101
+ return 'mention';
102
+ }
103
+ if (msg.thread_ts && msg.thread_ts !== msg.ts) return 'thread';
104
+ return 'dm';
105
+ }
106
+
107
+ /** Slack ts ("1700000000.000200") -> Unix ms. */
108
+ function tsToMs(ts: string | undefined): number {
109
+ if (!ts) return 0;
110
+ const seconds = Number.parseFloat(ts);
111
+ return Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0;
112
+ }
113
+
114
+ async function slackGet<T>(
115
+ token: string,
116
+ method: string,
117
+ params: Record<string, string>,
118
+ ): Promise<T> {
119
+ const url = new URL(`${SLACK_API}/${method}`);
120
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
121
+ const res = await fetch(url, {
122
+ method: 'GET',
123
+ headers: {
124
+ Authorization: `Bearer ${token}`,
125
+ Accept: 'application/json',
126
+ },
127
+ });
128
+ if (!res.ok) {
129
+ throw new Error(`Slack ${method} HTTP ${res.status}`);
130
+ }
131
+ return (await res.json()) as T;
132
+ }
133
+
134
+ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter {
135
+ return {
136
+ id: SLACK_PROVIDER_ID,
137
+ pollIntervalMs: POLL_CADENCE_MS.realtime,
138
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
139
+ let token: string | null;
140
+ try {
141
+ token = await ctx.credentials.resolveConfigSecret(SLACK_CREDENTIAL_KEY);
142
+ } catch (error) {
143
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
144
+ }
145
+ if (!token || token.trim().length === 0) {
146
+ return unavailable('missing surfaces.slack.botToken');
147
+ }
148
+ if (!token.startsWith('xoxb-') && !token.startsWith('xoxp-')) {
149
+ return unavailable('surfaces.slack.botToken is not a valid Slack bot/user token');
150
+ }
151
+
152
+ // Resolve our own user id so we can distinguish @-mentions of us from
153
+ // plain DMs. Best-effort: a failure here only means mentions are not
154
+ // separately classified, it never fails the provider.
155
+ let selfUserId: string | undefined;
156
+ try {
157
+ const auth = await slackGet<SlackAuthTestResponse>(token, 'auth.test', {});
158
+ if (auth.ok && auth.user_id) selfUserId = auth.user_id;
159
+ } catch (error) {
160
+ ctx.logger.warn('slack auth.test failed; mentions will not be classified', {
161
+ error: errMsg(error),
162
+ });
163
+ }
164
+
165
+ try {
166
+ // Page through ALL open DM channels. conversations.list returns at most
167
+ // `limit` channels per page and a response_metadata.next_cursor when more
168
+ // remain; without following the cursor, accounts with >100 open DMs would
169
+ // silently lose every channel past the first page. MAX_LIST_PAGES bounds
170
+ // the loop so a misbehaving cursor can never spin forever.
171
+ const MAX_LIST_PAGES = 50;
172
+ const channels: Array<{ id: string; user?: string }> = [];
173
+ let cursor: string | undefined;
174
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
175
+ const list = await slackGet<SlackConversationsListResponse>(token, 'conversations.list', {
176
+ types: 'im',
177
+ limit: '100',
178
+ ...(cursor ? { cursor } : {}),
179
+ });
180
+ if (!list.ok) {
181
+ return unavailable(`conversations.list: ${list.error ?? 'unknown_error'}`);
182
+ }
183
+ if (list.channels) channels.push(...list.channels);
184
+ const next = list.response_metadata?.next_cursor;
185
+ if (!next || next.length === 0) break;
186
+ cursor = next;
187
+ }
188
+ const oldest = opts.since ? (opts.since / 1000).toFixed(6) : undefined;
189
+ // Page through conversations.history per channel: a single call returns at
190
+ // most `limit` (<=50) messages and sets has_more + a next_cursor when a DM
191
+ // accrued more new messages than fit in one page. Without following the
192
+ // cursor a busy DM would silently drop everything past the first page.
193
+ // MAX_HISTORY_PAGES bounds the loop so a misbehaving cursor cannot spin.
194
+ const MAX_HISTORY_PAGES = 20;
195
+ const items: InboundChannelItem[] = [];
196
+ channelLoop: for (const channel of channels) {
197
+ if (items.length >= opts.limit) break;
198
+ let historyCursor: string | undefined;
199
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
200
+ const history = await slackGet<SlackHistoryResponse>(token, 'conversations.history', {
201
+ channel: channel.id,
202
+ limit: String(Math.min(opts.limit, 50)),
203
+ ...(oldest ? { oldest } : {}),
204
+ ...(historyCursor ? { cursor: historyCursor } : {}),
205
+ });
206
+ if (!history.ok) {
207
+ // Skip this DM but keep going; do not fail the whole provider.
208
+ ctx.logger.warn('slack conversations.history failed', {
209
+ channel: channel.id,
210
+ error: history.error,
211
+ });
212
+ continue channelLoop;
213
+ }
214
+ for (const msg of history.messages ?? []) {
215
+ if (items.length >= opts.limit) break;
216
+ if (msg.subtype === 'bot_message' || msg.bot_id) continue;
217
+ const senderId = msg.user ?? channel.user ?? channel.id;
218
+ const receivedAt = tsToMs(msg.ts);
219
+ if (opts.since && receivedAt <= opts.since) continue;
220
+ // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
221
+ // provider user id. Slack user ids (U...) are unique within a
222
+ // workspace, and the item.id / provider fields already namespace
223
+ // by provider.
224
+ const fromDigest = digestSender(senderId);
225
+ const kind = classifySlackKind(msg, selfUserId);
226
+ const item: InboundChannelItem = {
227
+ id: `slack:${channel.id}:${msg.ts ?? String(receivedAt)}`,
228
+ provider: SLACK_PROVIDER_ID,
229
+ kind,
230
+ fromDigest,
231
+ subjectPreview: toSubjectPreview(`Direct message`),
232
+ bodyPreview: toBodyPreview(msg.text),
233
+ receivedAt,
234
+ unread: true,
235
+ };
236
+ const routeId = await resolveRouteId(ctx, SLACK_PROVIDER_ID, fromDigest, kind);
237
+ if (routeId) item.routeId = routeId;
238
+ items.push(item);
239
+ }
240
+ // Advance to the next history page only while the channel reported
241
+ // more messages AND we still have item budget left.
242
+ const nextHistory = history.response_metadata?.next_cursor;
243
+ if (!history.has_more || !nextHistory || nextHistory.length === 0) break;
244
+ if (items.length >= opts.limit) break;
245
+ historyCursor = nextHistory;
246
+ }
247
+ }
248
+ return { items, state: items.length > 0 ? 'ready' : 'empty' };
249
+ } catch (error) {
250
+ return unavailable(errMsg(error));
251
+ }
252
+ },
253
+ };
254
+ }
255
+
256
+ function unavailable(error: string): ProviderPollResult {
257
+ return { items: [], state: 'unavailable', error };
258
+ }
259
+
260
+ function errMsg(error: unknown): string {
261
+ return error instanceof Error ? error.message : String(error);
262
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Composition root for the daemon handler layer — the only module that
3
+ * `src/runtime/services.ts` imports. It assembles every surface (routing,
4
+ * inbox+triage, drafts, calendar, email, remote) onto the SDK gateway catalog
5
+ * and returns a single teardown plus the cross-surface handles the runtime
6
+ * needs (routing resolver, the remote `DistributedRuntimeRouteService` the SDK
7
+ * facade injects, and the `remote.peers.invoke` dispatch adapter).
8
+ *
9
+ * Surfaces are provided by the caller as `SurfaceRegister` functions so this
10
+ * foundation module stays free of import cycles and does not author any SDK
11
+ * descriptor or schema. Registration order is fixed; teardown runs in reverse.
12
+ */
13
+ import type { DistributedRuntimeRouteService } from './contracts.ts';
14
+ import type { HandlerContext, SurfaceRegister } from './context.ts';
15
+ import type { Unregister } from './register.ts';
16
+
17
+ /** Routing surface handle: teardown plus the resolver other surfaces consume. */
18
+ export interface RoutingRegistration {
19
+ readonly unregister: Unregister;
20
+ /** Resolve an inbound channel target to a profile id (exact → wildcard → null). */
21
+ readonly resolveProfileId: (surfaceKind: string, routeId?: string) => string | null;
22
+ }
23
+
24
+ /** Adapter the runtime wires into the published `remote.peers.invoke` route. */
25
+ export interface RemoteInvokeAdapter {
26
+ invoke(input: Record<string, unknown>): Promise<unknown>;
27
+ }
28
+
29
+ /** Remote surface handle: the host service the SDK facade injects + its teardown. */
30
+ export interface RemoteSurfaceRegistration {
31
+ readonly unregister: Unregister;
32
+ readonly service: DistributedRuntimeRouteService;
33
+ readonly dispatch: RemoteInvokeAdapter;
34
+ }
35
+
36
+ /** Aggregate result returned to the runtime for teardown and cross-surface wiring. */
37
+ export interface DaemonHandlerSurfaces {
38
+ readonly unregister: Unregister;
39
+ readonly routing: RoutingRegistration;
40
+ readonly remoteSurface: { readonly service: DistributedRuntimeRouteService };
41
+ readonly remoteDispatch: RemoteInvokeAdapter;
42
+ }
43
+
44
+ /**
45
+ * The surface implementations the runtime supplies. Each builds its stores and
46
+ * attaches handlers to the SDK catalog via `registerCatalogHandler(s)`.
47
+ *
48
+ * Order of composition (and reverse teardown) is enforced by
49
+ * `registerDaemonHandlers`: routing → inbox(+triage) → drafts → calendar →
50
+ * email → remote.
51
+ */
52
+ export interface DaemonHandlerSurfaceProviders {
53
+ /** channels.routing.* — returns the resolver consumed by the inbox surface. */
54
+ readonly registerRouting: (ctx: HandlerContext) => RoutingRegistration;
55
+ /** channels.inbox.list (triage-decorated) + inbox.triage.* (daemon-internal). */
56
+ readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
57
+ /** channels.drafts.* */
58
+ readonly registerDrafts: SurfaceRegister;
59
+ /** calendar.* */
60
+ readonly registerCalendar: SurfaceRegister;
61
+ /** email.* */
62
+ readonly registerEmail: SurfaceRegister;
63
+ /** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
64
+ readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
65
+ }
66
+
67
+ /**
68
+ * Compose all daemon handler surfaces onto the gateway catalog held by `ctx`.
69
+ * Returns a single teardown (reverse order, best-effort) and the handles the
70
+ * runtime must expose: the routing registration, the remote service the SDK
71
+ * facade injects into `DaemonRemoteRouteContext.distributedRuntime`, and the
72
+ * remote invoke dispatch adapter.
73
+ */
74
+ export function registerDaemonHandlers(
75
+ ctx: HandlerContext,
76
+ providers: DaemonHandlerSurfaceProviders,
77
+ ): DaemonHandlerSurfaces {
78
+ const teardowns: Unregister[] = [];
79
+
80
+ const routing = providers.registerRouting(ctx);
81
+ teardowns.push(routing.unregister);
82
+
83
+ teardowns.push(providers.registerInbox(ctx, routing));
84
+ teardowns.push(providers.registerDrafts(ctx));
85
+ teardowns.push(providers.registerCalendar(ctx));
86
+ teardowns.push(providers.registerEmail(ctx));
87
+
88
+ const remote = providers.registerRemote(ctx);
89
+ teardowns.push(remote.unregister);
90
+
91
+ const unregister: Unregister = () => {
92
+ for (let i = teardowns.length - 1; i >= 0; i -= 1) {
93
+ try {
94
+ teardowns[i]!();
95
+ } catch (error) {
96
+ ctx.logger.warn('daemon handler surface teardown failed', { error });
97
+ }
98
+ }
99
+ };
100
+
101
+ return {
102
+ unregister,
103
+ routing,
104
+ remoteSurface: { service: remote.service },
105
+ remoteDispatch: remote.dispatch,
106
+ };
107
+ }