@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,171 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbound provider adapter contract + registry.
3
+ //
4
+ // Each adapter owns ONE provider (slack, discord, email, ...). The poller calls
5
+ // adapter.poll() on a cadence, dedups by item.id, and persists into the cursor
6
+ // store. Adapters resolve credentials ONLY through the daemon credential store
7
+ // and NEVER return raw sender ids or unredacted bodies — mapping/redaction is
8
+ // the adapter's responsibility (see mapping helpers in `./mapping.ts`).
9
+ //
10
+ // CRITICAL: when a credential is missing/misconfigured an adapter returns
11
+ // state 'unavailable' WITH an error string. It is NEVER silently omitted — the
12
+ // caller must be able to distinguish "configured-but-empty" from "not wired".
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { DaemonCredentialStore } from '../credentials.ts';
16
+ import type { HandlerLogger } from '../context.ts';
17
+
18
+ /**
19
+ * Daemon-internal inbound item. This is NOT the SDK wire shape — the inbox
20
+ * surface maps it onto the SDK `CHANNEL_INBOX_ITEM_SCHEMA` shape
21
+ * (`from`/`subject`/`bodyPreview`/...) before returning. `fromDigest` is the
22
+ * redacted sender (the only sender value that ever leaves the daemon).
23
+ */
24
+ export interface InboundChannelItem {
25
+ /** Stable, provider-scoped dedup key. Idempotent across polls. */
26
+ id: string;
27
+ provider: string;
28
+ kind: 'dm' | 'thread' | 'mention' | 'reaction';
29
+ /**
30
+ * sha256First(senderExternalId, 16) — NEVER the raw id. 16 hex chars == the
31
+ * first 8 bytes of the SHA-256 digest.
32
+ */
33
+ fromDigest: string;
34
+ /** <= 200 chars, safe for display. */
35
+ subjectPreview: string;
36
+ /** <= 500 chars, plain text, PII-stripped. */
37
+ bodyPreview: string;
38
+ /** Daemon route binding id, when resolvable. */
39
+ routeId?: string;
40
+ /** Unix ms. */
41
+ receivedAt: number;
42
+ unread: boolean;
43
+ /** Optional triage metadata written by the triage surface (read-only here). */
44
+ triageScore?: number;
45
+ triageTags?: string[];
46
+ }
47
+
48
+ export type ProviderState = 'ready' | 'unavailable' | 'empty';
49
+
50
+ export interface ProviderPollResult {
51
+ items: InboundChannelItem[];
52
+ state: ProviderState;
53
+ /** Present only when state === 'unavailable'. */
54
+ error?: string;
55
+ /**
56
+ * Whether this provider's credentials resolved.
57
+ *
58
+ * 'unavailable' alone conflates two things a reader has to tell apart: a
59
+ * provider nobody has wired up yet, and a wired-up provider whose API just
60
+ * refused us. The first is a normal state of a fresh install and the second
61
+ * is an outage hiding items that exist, and `channels.inbox.list` reports
62
+ * them as different states because a caller acts on them differently.
63
+ *
64
+ * `false` — no credential (or an unusable one), so nothing was even asked.
65
+ * `true` — credentials resolved; whatever happened next happened WITH them.
66
+ * absent — the adapter could not find out (the credential store itself
67
+ * failed), which is neither claim and is reported as neither.
68
+ */
69
+ configured?: boolean;
70
+ }
71
+
72
+ export interface ProviderPollOptions {
73
+ /** Only return items newer than this Unix-ms timestamp, when supported. */
74
+ since?: number;
75
+ /** Max items to return this poll. */
76
+ limit: number;
77
+ }
78
+
79
+ /** Context handed to every adapter at construction time. */
80
+ export interface AdapterContext {
81
+ readonly credentials: DaemonCredentialStore;
82
+ readonly logger: HandlerLogger;
83
+ /**
84
+ * Optional route resolver supplied by the routing surface. Returns the route
85
+ * binding id for a given inbound item, or undefined when no route matches /
86
+ * the routing surface is not wired yet. Adapters/poller call this best-effort.
87
+ */
88
+ readonly resolveRouteId?: RouteResolver;
89
+ }
90
+
91
+ /**
92
+ * Best-effort route resolution seam. The routing surface injects its
93
+ * profile-backed implementation via the inbox bridge. Until then it is
94
+ * undefined and items carry no routeId.
95
+ */
96
+ export type RouteResolver = (input: {
97
+ provider: string;
98
+ fromDigest: string;
99
+ kind: InboundChannelItem['kind'];
100
+ }) => Promise<string | undefined> | string | undefined;
101
+
102
+ export interface InboundProviderAdapter {
103
+ /** Provider id, e.g. 'slack'. Must be unique within the registry. */
104
+ readonly id: string;
105
+ /**
106
+ * Poll cadence in ms. Slack/Discord 30s, email 60s, everything else 120s.
107
+ * The poller reads this to schedule its per-provider interval.
108
+ */
109
+ readonly pollIntervalMs: number;
110
+ /**
111
+ * Pull recent DMs/threads/mentions. MUST resolve (never reject) — failures
112
+ * are reported via state:'unavailable' + error so one bad provider cannot
113
+ * crash the aggregate feed.
114
+ */
115
+ poll(opts: ProviderPollOptions): Promise<ProviderPollResult>;
116
+ }
117
+
118
+ /** Factory signature: adapters are constructed lazily with shared context. */
119
+ export type AdapterFactory = (ctx: AdapterContext) => InboundProviderAdapter;
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Cadence constants (single source of truth, referenced by adapters + tests).
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export const POLL_CADENCE_MS = {
126
+ realtime: 30_000, // slack, discord
127
+ email: 60_000,
128
+ default: 120_000,
129
+ } as const;
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Registry
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const FACTORIES = new Map<string, AdapterFactory>();
136
+
137
+ /** Register a provider factory. Last registration for an id wins (idempotent). */
138
+ export function registerAdapterFactory(id: string, factory: AdapterFactory): void {
139
+ FACTORIES.set(id, factory);
140
+ }
141
+
142
+ /** All registered provider ids, in insertion order. */
143
+ export function registeredProviderIds(): string[] {
144
+ return [...FACTORIES.keys()];
145
+ }
146
+
147
+ /**
148
+ * Construct adapters for the requested provider ids (or all registered ids when
149
+ * `requested` is undefined/empty). Unknown ids are dropped: the returned map
150
+ * only contains known providers.
151
+ */
152
+ export function buildAdapters(
153
+ ctx: AdapterContext,
154
+ requested?: readonly string[],
155
+ ): Map<string, InboundProviderAdapter> {
156
+ const ids = requested && requested.length > 0
157
+ ? requested.filter((id) => FACTORIES.has(id))
158
+ : registeredProviderIds();
159
+ const out = new Map<string, InboundProviderAdapter>();
160
+ for (const id of ids) {
161
+ const factory = FACTORIES.get(id);
162
+ if (!factory) continue;
163
+ out.set(id, factory(ctx));
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Test/seam hook: clear the registry. */
169
+ export function clearAdapterRegistry(): void {
170
+ FACTORIES.clear();
171
+ }
@@ -0,0 +1,276 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Discord inbound adapter.
3
+ //
4
+ // Transport note (contract fidelity): the daemon-handoff checklist names the
5
+ // Discord Gateway for DM polling. The Gateway is a persistent websocket push
6
+ // transport and cannot be driven by the inbound poller, whose contract is a
7
+ // stateless, cadence-driven adapter.poll() that MUST resolve each call (see
8
+ // provider-adapter.ts) — a long-lived socket the poller neither owns nor
9
+ // supervises is out of scope for that contract. We therefore satisfy the
10
+ // DM-polling goal over Discord's supported request/response surface, the REST
11
+ // API — the same DM data the Gateway streams, fetched on the poll cadence and
12
+ // paged so a busy DM is never truncated:
13
+ // GET /users/@me/channels -> list DM channels the bot participates in
14
+ // GET /channels/{id}/messages -> recent messages per DM channel (paged via
15
+ // the `before` snowflake cursor)
16
+ //
17
+ // Discord snowflake ids encode their creation timestamp, so receivedAt is
18
+ // derived from the message id (snowflake) when no explicit timestamp is present.
19
+ //
20
+ // Credential: surfaces.discord.botToken. Missing => 'unavailable'.
21
+ // Cadence: 30s (realtime tier).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ import type {
25
+ AdapterContext,
26
+ InboundChannelItem,
27
+ InboundProviderAdapter,
28
+ ProviderPollOptions,
29
+ ProviderPollResult,
30
+ } from '../provider-adapter.ts';
31
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
32
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
33
+ import { resolveRouteId } from './route-util.ts';
34
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
+
36
+ const DISCORD_API = 'https://discord.com/api/v10';
37
+ // Discord epoch (2015-01-01T00:00:00Z) in ms, used to decode snowflakes.
38
+ const DISCORD_EPOCH_MS = 1_420_070_400_000;
39
+ export const DISCORD_PROVIDER_ID = 'discord';
40
+ export const DISCORD_CREDENTIAL_KEY = 'surfaces.discord.botToken';
41
+
42
+ interface DiscordChannel {
43
+ id: string;
44
+ type: number; // 1 = DM, 3 = group DM
45
+ }
46
+
47
+ interface DiscordSelf {
48
+ id: string;
49
+ }
50
+
51
+ interface DiscordReaction {
52
+ count?: number;
53
+ emoji?: { id?: string | null; name?: string | null };
54
+ }
55
+
56
+ interface DiscordMessage {
57
+ id: string;
58
+ channel_id?: string;
59
+ content?: string;
60
+ timestamp?: string; // ISO-8601
61
+ author?: { id: string; bot?: boolean };
62
+ referenced_message?: unknown;
63
+ mentions?: Array<{ id: string }>;
64
+ reactions?: DiscordReaction[];
65
+ }
66
+
67
+ /**
68
+ * Classify a Discord message into the InboundChannelItem `kind`.
69
+ * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
70
+ * event. Requires the message to be authored by us (its author id
71
+ * is selfId) AND to carry a non-empty reactions[]. A message
72
+ * authored by someone else that merely carries reactions[] is a
73
+ * normal DM, not a reaction event.
74
+ * - mention: the message mentions us (our id is in mentions[])
75
+ * - thread: the message is a reply (referenced_message present)
76
+ * - dm: a plain direct message
77
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
78
+ */
79
+ function classifyDiscordKind(
80
+ msg: DiscordMessage,
81
+ selfId: string | undefined,
82
+ ): InboundChannelItem['kind'] {
83
+ if (
84
+ selfId &&
85
+ msg.author?.id === selfId &&
86
+ Array.isArray(msg.reactions) &&
87
+ msg.reactions.length > 0
88
+ ) {
89
+ return 'reaction';
90
+ }
91
+ if (selfId && Array.isArray(msg.mentions) && msg.mentions.some((m) => m.id === selfId)) {
92
+ return 'mention';
93
+ }
94
+ if (msg.referenced_message) return 'thread';
95
+ return 'dm';
96
+ }
97
+
98
+ /** Decode a Discord snowflake id to its creation Unix-ms timestamp. */
99
+ function snowflakeToMs(id: string): number {
100
+ try {
101
+ const asBig = BigInt(id);
102
+ return Number((asBig >> 22n)) + DISCORD_EPOCH_MS;
103
+ } catch {
104
+ return 0;
105
+ }
106
+ }
107
+
108
+ async function discordGet<T>(token: string, path: string): Promise<T> {
109
+ const res = await fetch(`${DISCORD_API}${path}`, {
110
+ method: 'GET',
111
+ headers: {
112
+ Authorization: `Bot ${token}`,
113
+ Accept: 'application/json',
114
+ },
115
+ });
116
+ if (res.status === 401) throw new Error('Discord authentication failed (401)');
117
+ if (!res.ok) throw new Error(`Discord GET ${path} HTTP ${res.status}`);
118
+ return (await res.json()) as T;
119
+ }
120
+
121
+ export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapter {
122
+ return {
123
+ id: DISCORD_PROVIDER_ID,
124
+ pollIntervalMs: POLL_CADENCE_MS.realtime,
125
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
126
+ let token: string | null;
127
+ try {
128
+ token = await ctx.credentials.resolveConfigSecret(DISCORD_CREDENTIAL_KEY);
129
+ } catch (error) {
130
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
131
+ }
132
+ if (!token || token.trim().length === 0) {
133
+ return notConfigured('missing surfaces.discord.botToken');
134
+ }
135
+
136
+ // Resolve our own user id so we can classify @-mentions of us. Best-effort:
137
+ // a failure only disables mention classification, never the provider.
138
+ let selfId: string | undefined;
139
+ try {
140
+ const self = await discordGet<DiscordSelf>(token, '/users/@me');
141
+ if (self.id) selfId = self.id;
142
+ } catch (error) {
143
+ ctx.logger.warn('discord /users/@me failed; mentions will not be classified', {
144
+ error: errMsg(error),
145
+ });
146
+ }
147
+
148
+ try {
149
+ const channels = await discordGet<DiscordChannel[]>(token, '/users/@me/channels');
150
+ const dmChannels = channels.filter((c) => c.type === 1 || c.type === 3);
151
+ const items: InboundChannelItem[] = [];
152
+ // /channels/{id}/messages returns at most `perChannel` (<=50) messages
153
+ // newest-first. A DM that accrued more than one page of new messages in
154
+ // the `since` window would silently drop the oldest beyond the first page
155
+ // unless we page backwards via the `before` cursor (the id of the oldest
156
+ // message seen so far). MAX_HISTORY_PAGES bounds the walk.
157
+ const MAX_HISTORY_PAGES = 20;
158
+ const perChannel = Math.min(opts.limit, 50);
159
+ channelLoop: for (const channel of dmChannels) {
160
+ if (items.length >= opts.limit) break;
161
+ let before: string | undefined;
162
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
163
+ const params = new URLSearchParams({ limit: String(perChannel) });
164
+ // Discord treats after/before/around as mutually exclusive: sending
165
+ // both silently drops older in-window messages. Use `after` only on
166
+ // the first page (to bound the window at `since`); once paging
167
+ // backwards via `before`, drop `after` and rely on the
168
+ // oldestMs<=since stop condition below to terminate the walk.
169
+ if (before) params.set('before', before);
170
+ else if (opts.since) params.set('after', msToSnowflake(opts.since));
171
+ let messages: DiscordMessage[];
172
+ try {
173
+ messages = await discordGet<DiscordMessage[]>(
174
+ token,
175
+ `/channels/${channel.id}/messages?${params.toString()}`,
176
+ );
177
+ } catch (error) {
178
+ ctx.logger.warn('discord channel messages failed', {
179
+ channel: channel.id,
180
+ error: errMsg(error),
181
+ });
182
+ continue channelLoop;
183
+ }
184
+ if (messages.length === 0) break;
185
+ // Track the oldest id on this page for the next `before` cursor before
186
+ // we filter/skip, so pagination is driven by the raw page, not by what
187
+ // survived classification.
188
+ let oldestId: string | undefined;
189
+ let oldestMs = Number.POSITIVE_INFINITY;
190
+ for (const msg of messages) {
191
+ const msgMs = msg.timestamp
192
+ ? Date.parse(msg.timestamp) || snowflakeToMs(msg.id)
193
+ : snowflakeToMs(msg.id);
194
+ if (msgMs < oldestMs) {
195
+ oldestMs = msgMs;
196
+ oldestId = msg.id;
197
+ }
198
+ if (items.length >= opts.limit) break;
199
+ if (msg.author?.bot) continue;
200
+ const receivedAt = msgMs;
201
+ if (opts.since && receivedAt <= opts.since) continue;
202
+ const senderId = msg.author?.id ?? channel.id;
203
+ // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
204
+ // provider user id. Discord user ids (snowflakes) are globally
205
+ // unique, and the item.id / provider fields already namespace by
206
+ // provider.
207
+ const fromDigest = digestSender(senderId);
208
+ const kind = classifyDiscordKind(msg, selfId);
209
+ const item: InboundChannelItem = {
210
+ id: `discord:${channel.id}:${msg.id}`,
211
+ provider: DISCORD_PROVIDER_ID,
212
+ kind,
213
+ fromDigest,
214
+ subjectPreview: toSubjectPreview('Direct message'),
215
+ bodyPreview: toBodyPreview(msg.content),
216
+ receivedAt,
217
+ unread: true,
218
+ };
219
+ const routeId = await resolveRouteId(ctx, DISCORD_PROVIDER_ID, fromDigest, kind);
220
+ if (routeId) item.routeId = routeId;
221
+ items.push(item);
222
+ }
223
+ // Stop paging this channel when the page was not full (no older
224
+ // messages exist), the item budget is spent, or the oldest message on
225
+ // this page is already at/older than the `since` floor.
226
+ if (messages.length < perChannel || items.length >= opts.limit) break;
227
+ if (opts.since && oldestMs <= opts.since) break;
228
+ if (!oldestId) break;
229
+ before = oldestId;
230
+ }
231
+ }
232
+ return { items, state: items.length > 0 ? 'ready' : 'empty', configured: true };
233
+ } catch (error) {
234
+ return failed(errMsg(error));
235
+ }
236
+ },
237
+ };
238
+ }
239
+
240
+ /** Build a synthetic snowflake from a Unix-ms timestamp for the `after` cursor. */
241
+ function msToSnowflake(ms: number): string {
242
+ const delta = Math.max(0, Math.floor(ms) - DISCORD_EPOCH_MS);
243
+ return (BigInt(delta) << 22n).toString();
244
+ }
245
+
246
+ /**
247
+ * The provider is wired up but this attempt failed — an outage, a refusal, a
248
+ * bad response. Items that exist are missing from the feed, which is what
249
+ * `configured: true` here tells the aggregator to report as a partial answer
250
+ * rather than as an empty one.
251
+ */
252
+ function failed(error: string): ProviderPollResult {
253
+ return { items: [], state: 'unavailable', error, configured: true };
254
+ }
255
+
256
+ /**
257
+ * Nothing to poll with: no credential, or an unusable one. Normal on a fresh
258
+ * install, and deliberately NOT a partial answer — nothing is missing from a
259
+ * provider nobody asked us to read.
260
+ */
261
+ function notConfigured(error: string): ProviderPollResult {
262
+ return { items: [], state: 'unavailable', error, configured: false };
263
+ }
264
+
265
+ /**
266
+ * The credential store itself failed, so we do not know whether this provider
267
+ * is configured. Neither claim is made — reporting a guess here is how a
268
+ * transient store fault would get read as "you never set this up".
269
+ */
270
+ function unavailable(error: string): ProviderPollResult {
271
+ return { items: [], state: 'unavailable', error };
272
+ }
273
+
274
+ function errMsg(error: unknown): string {
275
+ return summarizeError(error);
276
+ }
@@ -0,0 +1,176 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Email (IMAP) inbound adapter.
3
+ //
4
+ // Uses the dependency-free ImapClient (node:tls) to pull recent INBOX messages.
5
+ // Connection params resolve through the daemon credential store (env/secrets
6
+ // backend) so nothing sensitive is read from plaintext config:
7
+ // surfaces.email.imapHost (e.g. imap.fastmail.com)
8
+ // surfaces.email.imapPort (default 993)
9
+ // surfaces.email.imapUser
10
+ // surfaces.email.imapPassword (app password / token)
11
+ //
12
+ // Any missing required field => state 'unavailable' WITH an explanatory error.
13
+ // Cadence: 60s (email tier).
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type {
17
+ AdapterContext,
18
+ InboundChannelItem,
19
+ InboundProviderAdapter,
20
+ ProviderPollOptions,
21
+ ProviderPollResult,
22
+ } from '../provider-adapter.ts';
23
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
24
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
25
+ import { resolveRouteId } from './route-util.ts';
26
+ import { ImapClient } from './imap-client.ts';
27
+ import type { ImapConfig, ImapEnvelope } from './imap-client.ts';
28
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
29
+
30
+ export const EMAIL_PROVIDER_ID = 'email';
31
+ export const EMAIL_HOST_KEY = 'surfaces.email.imapHost';
32
+ export const EMAIL_PORT_KEY = 'surfaces.email.imapPort';
33
+ export const EMAIL_USER_KEY = 'surfaces.email.imapUser';
34
+ export const EMAIL_PASSWORD_KEY = 'surfaces.email.imapPassword';
35
+
36
+ /**
37
+ * Injectable client factory so tests can substitute a fake IMAP client without
38
+ * opening a TLS socket. Production default constructs the real ImapClient.
39
+ */
40
+ export interface ImapLike {
41
+ connect(): Promise<void>;
42
+ login(): Promise<void>;
43
+ select(mailbox?: string): Promise<void>;
44
+ searchUids(since?: number): Promise<number[]>;
45
+ fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]>;
46
+ logout(): Promise<void>;
47
+ close(): void;
48
+ }
49
+
50
+ export type ImapClientFactory = (cfg: ImapConfig) => ImapLike;
51
+
52
+ const defaultFactory: ImapClientFactory = (cfg) => new ImapClient(cfg);
53
+
54
+ export function createEmailAdapter(
55
+ ctx: AdapterContext,
56
+ factory: ImapClientFactory = defaultFactory,
57
+ ): InboundProviderAdapter {
58
+ return {
59
+ id: EMAIL_PROVIDER_ID,
60
+ pollIntervalMs: POLL_CADENCE_MS.email,
61
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
62
+ let host: string | null;
63
+ let user: string | null;
64
+ let password: string | null;
65
+ let portRaw: string | null;
66
+ try {
67
+ [host, portRaw, user, password] = await Promise.all([
68
+ ctx.credentials.resolveConfigSecret(EMAIL_HOST_KEY),
69
+ ctx.credentials.resolveConfigSecret(EMAIL_PORT_KEY),
70
+ ctx.credentials.resolveConfigSecret(EMAIL_USER_KEY),
71
+ ctx.credentials.resolveConfigSecret(EMAIL_PASSWORD_KEY),
72
+ ]);
73
+ } catch (error) {
74
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
75
+ }
76
+
77
+ const missing: string[] = [];
78
+ if (!host) missing.push('imapHost');
79
+ if (!user) missing.push('imapUser');
80
+ if (!password) missing.push('imapPassword');
81
+ if (missing.length > 0) {
82
+ return notConfigured(`missing email IMAP credentials: ${missing.join(', ')}`);
83
+ }
84
+ const port = portRaw ? Number.parseInt(portRaw, 10) : 993;
85
+ if (!Number.isFinite(port) || port <= 0) {
86
+ return notConfigured(`invalid surfaces.email.imapPort: ${portRaw}`);
87
+ }
88
+
89
+ const client = factory({
90
+ host: host!,
91
+ port,
92
+ user: user!,
93
+ password: password!,
94
+ });
95
+
96
+ try {
97
+ await client.connect();
98
+ await client.login();
99
+ await client.select('INBOX');
100
+ const uids = await client.searchUids(opts.since);
101
+ // Newest UIDs first, capped at limit.
102
+ const selected = uids.sort((a, b) => b - a).slice(0, opts.limit);
103
+ const envelopes = await client.fetchEnvelopes(selected);
104
+ const items: InboundChannelItem[] = [];
105
+ for (const env of envelopes) {
106
+ const receivedAt = env.date > 0 ? env.date : Date.now();
107
+ if (opts.since && receivedAt <= opts.since) continue;
108
+ const fromDigest = digestSender(`email:${normalizeAddress(env.from)}`);
109
+ const kind = 'dm';
110
+ const item: InboundChannelItem = {
111
+ id: `email:${user}:${env.uid}`,
112
+ provider: EMAIL_PROVIDER_ID,
113
+ kind,
114
+ fromDigest,
115
+ subjectPreview: toSubjectPreview(env.subject),
116
+ bodyPreview: toBodyPreview(env.bodyPreview),
117
+ receivedAt,
118
+ unread: !env.seen,
119
+ };
120
+ const routeId = await resolveRouteId(ctx, EMAIL_PROVIDER_ID, fromDigest, kind);
121
+ if (routeId) item.routeId = routeId;
122
+ items.push(item);
123
+ }
124
+ return { items, state: items.length > 0 ? 'ready' : 'empty', configured: true };
125
+ } catch (error) {
126
+ return failed(errMsg(error));
127
+ } finally {
128
+ try {
129
+ await client.logout();
130
+ } catch {
131
+ // ignore
132
+ }
133
+ client.close();
134
+ }
135
+ },
136
+ };
137
+ }
138
+
139
+ /** Extract a bare address from a `Name <addr@host>` From header for digesting. */
140
+ function normalizeAddress(from: string): string {
141
+ const angle = /<([^>]+)>/.exec(from);
142
+ const addr = (angle ? angle[1]! : from).trim().toLowerCase();
143
+ return addr.length > 0 ? addr : from.trim().toLowerCase();
144
+ }
145
+
146
+ /**
147
+ * The provider is wired up but this attempt failed — an outage, a refusal, a
148
+ * bad response. Items that exist are missing from the feed, which is what
149
+ * `configured: true` here tells the aggregator to report as a partial answer
150
+ * rather than as an empty one.
151
+ */
152
+ function failed(error: string): ProviderPollResult {
153
+ return { items: [], state: 'unavailable', error, configured: true };
154
+ }
155
+
156
+ /**
157
+ * Nothing to poll with: no credential, or an unusable one. Normal on a fresh
158
+ * install, and deliberately NOT a partial answer — nothing is missing from a
159
+ * provider nobody asked us to read.
160
+ */
161
+ function notConfigured(error: string): ProviderPollResult {
162
+ return { items: [], state: 'unavailable', error, configured: false };
163
+ }
164
+
165
+ /**
166
+ * The credential store itself failed, so we do not know whether this provider
167
+ * is configured. Neither claim is made — reporting a guess here is how a
168
+ * transient store fault would get read as "you never set this up".
169
+ */
170
+ function unavailable(error: string): ProviderPollResult {
171
+ return { items: [], state: 'unavailable', error };
172
+ }
173
+
174
+ function errMsg(error: unknown): string {
175
+ return summarizeError(error);
176
+ }