@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,221 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbox surface: attaches the HOST handler to the SDK-registered
3
+ // `channels.inbox.list` gateway descriptor.
4
+ //
5
+ // The SDK already declares the method id, input schema, output schema, scopes
6
+ // (read:channels) and HTTP binding (GET /api/channels/inbox). This module does
7
+ // NOT re-declare any of that — it looks the descriptor up via the catalog and
8
+ // attaches an implementation with `registerCatalogHandler` ({ replace: true }).
9
+ //
10
+ // register(ctx, routing) =>
11
+ // 1. registers built-in provider adapter factories (slack/discord/email)
12
+ // 2. constructs adapters with the daemon credential store + a route resolver
13
+ // bridged from the routing surface (best-effort, never throws)
14
+ // 3. opens the inbox cursor store and seeds an initial poll
15
+ // 4. starts the per-provider polling loops
16
+ // 5. attaches the read-only `channels.inbox.list` handler (no confirm)
17
+ // 6. returns an Unregister that detaches the handler, stops the poller, and
18
+ // closes the store.
19
+ //
20
+ // The handler composes the answer in ./aggregator.ts — the merged, paginated
21
+ // page plus every provider's standing. Read that file's header for WHY the
22
+ // answer comes from the synced mirror rather than a fresh remote fetch. The
23
+ // redacted `fromDigest` is the only sender value emitted (as `from`); raw
24
+ // sender ids and unredacted bodies never leave the daemon.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ import type { HandlerContext } from '../context.ts';
28
+ import type { Unregister } from '../register.ts';
29
+ import { registerCatalogHandler } from '../register.ts';
30
+ import type { RoutingRegistration } from '../index.ts';
31
+ import {
32
+ buildAdapters,
33
+ registerAdapterFactory,
34
+ type AdapterContext,
35
+ type InboundChannelItem,
36
+ type RouteResolver,
37
+ } from './provider-adapter.ts';
38
+ import { InboxCursorStore } from './cursor-store.ts';
39
+ import { InboundPoller } from './poller.ts';
40
+ import { aggregateInbox, normalizeInboxQuery } from './aggregator.ts';
41
+ import type { InboxListInput, InboxListOutput } from './aggregator.ts';
42
+ import { createSlackAdapter, SLACK_PROVIDER_ID } from './providers/slack.ts';
43
+ import { createDiscordAdapter, DISCORD_PROVIDER_ID } from './providers/discord.ts';
44
+ import { createEmailAdapter, EMAIL_PROVIDER_ID } from './providers/email.ts';
45
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
46
+
47
+ export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
48
+
49
+ export type {
50
+ ChannelInboxItem,
51
+ ChannelInboxProviderStatus,
52
+ InboxListInput,
53
+ InboxListOutput,
54
+ } from './aggregator.ts';
55
+
56
+ /** Start/stop control over the poll loops, handed to a leadership gate. */
57
+ export interface InboxPollingControl {
58
+ /** Seed one poll and arm the loops. Resolves once polling has begun. */
59
+ start(): Promise<void>;
60
+ /** Disarm the loops. Resolves once no further poll can run. */
61
+ stop(): Promise<void>;
62
+ }
63
+
64
+ export interface RegisterInboxOptions {
65
+ /** Override the cursor-store filename (tests). */
66
+ storeFileName?: string;
67
+ /** Skip the initial seed poll (tests that drive polling manually). */
68
+ skipInitialPoll?: boolean;
69
+ /** Register the built-in slack/discord/email adapters (default true). */
70
+ registerBuiltins?: boolean;
71
+ /**
72
+ * Hand polling to a leadership gate instead of starting it here.
73
+ *
74
+ * When supplied, register() prepares the store and the handler but does NOT
75
+ * poll: the callback receives start/stop control and something else decides
76
+ * when this node is the one that should be fetching. When absent the loops
77
+ * start immediately, which is the behaviour every existing caller and test
78
+ * relies on.
79
+ *
80
+ * The READ path is never gated. `channels.inbox.list` serves the persisted
81
+ * feed on every node — a node that is not fetching still answers questions
82
+ * about what has already arrived.
83
+ *
84
+ * Called once PER PROVIDER, with that provider's id and a control that
85
+ * starts and stops only its loop. Each inbox account is its own surface in
86
+ * the LAN election, so the machine reading the work Slack account need not
87
+ * be the machine reading the mailbox — and handing one account over must not
88
+ * take the others down with it.
89
+ */
90
+ gatePolling?: (providerId: string, control: InboxPollingControl) => void;
91
+ }
92
+
93
+ function registerBuiltinAdapters(): void {
94
+ registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
95
+ registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
96
+ registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
97
+ }
98
+
99
+ /**
100
+ * Bridge the routing surface's profile resolver into the adapter `RouteResolver`
101
+ * seam. Resolution is by provider surface (best-effort wildcard); a resolved
102
+ * profile id is surfaced as the item's routeId binding. Never throws.
103
+ */
104
+ function routeResolverFromRouting(routing: RoutingRegistration): RouteResolver {
105
+ return ({ provider }) => {
106
+ try {
107
+ return routing.resolveProfileId(provider) ?? undefined;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Register the inbox surface. Attaches the `channels.inbox.list` handler to the
116
+ * SDK catalog and returns an Unregister that detaches it, stops the poller, and
117
+ * closes the store.
118
+ */
119
+ export function registerInboxMethods(
120
+ ctx: HandlerContext,
121
+ routing?: RoutingRegistration,
122
+ options: RegisterInboxOptions = {},
123
+ ): Unregister {
124
+ if (options.registerBuiltins !== false) {
125
+ registerBuiltinAdapters();
126
+ }
127
+
128
+ const adapterContext: AdapterContext = {
129
+ credentials: ctx.credentials,
130
+ logger: ctx.logger,
131
+ ...(routing ? { resolveRouteId: routeResolverFromRouting(routing) } : {}),
132
+ };
133
+ const adapters = buildAdapters(adapterContext);
134
+ // Retention runs inside the store (age TTL + count cap, at init and then on a
135
+ // timer). Both hooks carry COUNTS ONLY — no sender ids, subjects or bodies.
136
+ const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName, {
137
+ onSweep: (summary) => {
138
+ ctx.logger.info('inbox retention sweep reclaimed items', {
139
+ expired: summary.expired,
140
+ capped: summary.capped,
141
+ remaining: summary.remaining,
142
+ });
143
+ },
144
+ onSweepError: (message) => {
145
+ ctx.logger.warn('inbox retention sweep failed', { error: message });
146
+ },
147
+ });
148
+ const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
149
+
150
+ const gated = options.gatePolling !== undefined;
151
+
152
+ // Async bootstrap: init store, and (ungated) seed one poll and start loops.
153
+ // Failures are logged but never thrown out of register() — the handler still
154
+ // serves the (possibly empty) persisted feed.
155
+ const ready: Promise<void> = (async () => {
156
+ await store.init();
157
+ if (gated) return;
158
+ if (!options.skipInitialPoll) {
159
+ await poller.pollOnce();
160
+ }
161
+ poller.start();
162
+ })().catch((error: unknown) => {
163
+ ctx.logger.error('inbox surface bootstrap failed', {
164
+ error: summarizeError(error),
165
+ });
166
+ });
167
+
168
+ const gate = options.gatePolling;
169
+ if (gate) {
170
+ // One gate per provider, not one for the poller. Leadership is decided per
171
+ // inbox account, so each account's loop has to be startable and stoppable
172
+ // on its own — otherwise handing one account to another machine would stop
173
+ // fetching for every account this node reads.
174
+ for (const providerId of poller.providerIds()) {
175
+ gate(providerId, {
176
+ start: async () => {
177
+ // The store must be ready before the first fetch, or the seed poll
178
+ // would write cursors into an uninitialised store.
179
+ await ready;
180
+ if (!options.skipInitialPoll) {
181
+ await poller.pollProviderOnce(providerId);
182
+ }
183
+ poller.startProvider(providerId);
184
+ },
185
+ // Synchronous underneath: it clears that provider's interval, so once
186
+ // it returns no further poll of that account can be scheduled.
187
+ // Declared async because the gate contract promises "resolves when
188
+ // consumption has ceased", and a future adapter with an in-flight
189
+ // request would need to await it.
190
+ stop: async () => {
191
+ poller.stopProvider(providerId);
192
+ },
193
+ });
194
+ }
195
+ }
196
+
197
+ const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
198
+ ctx.catalog,
199
+ INBOX_LIST_METHOD_ID,
200
+ async (invocation) => {
201
+ // The store bootstrap is kicked off without being awaited at register
202
+ // time, so the first call is where it gets waited on. Waiting here rather
203
+ // than answering early is what keeps a cold start from reporting an empty
204
+ // inbox that is only empty because the file is not open yet.
205
+ await ready;
206
+ return aggregateInbox(
207
+ { store, poller },
208
+ normalizeInboxQuery(invocation.body, invocation.query),
209
+ );
210
+ },
211
+ );
212
+
213
+ return () => {
214
+ try {
215
+ unregisterMethod();
216
+ } finally {
217
+ poller.stop();
218
+ void store.close();
219
+ }
220
+ };
221
+ }
@@ -0,0 +1,192 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure mapping + redaction helpers shared by every adapter.
3
+ //
4
+ // Rules (from the handoff contract):
5
+ // - fromDigest = sha256First(senderExternalId, 16) (never the raw id)
6
+ // - bodyPreview = plain-text, PII-stripped, truncated to 500 chars
7
+ // - subjectPreview <= 200 chars
8
+ // These are pure and deterministic so they are unit-testable in isolation.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import { createHash } from 'node:crypto';
12
+
13
+ export const SUBJECT_PREVIEW_MAX = 200;
14
+ export const BODY_PREVIEW_MAX = 500;
15
+
16
+ /** First `hexChars` hex chars of the SHA-256 digest of `input` (utf-8). */
17
+ export function sha256First(input: string, hexChars: number): string {
18
+ const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
19
+ return digest.slice(0, Math.max(0, hexChars));
20
+ }
21
+
22
+ /**
23
+ * Digest a sender's external id to a stable 16-hex-char token (the first 8
24
+ * bytes of the SHA-256 digest). The raw id is never emitted.
25
+ */
26
+ export function digestSender(senderExternalId: string): string {
27
+ return sha256First(senderExternalId, 16);
28
+ }
29
+
30
+ // PII patterns stripped from body previews before they ever leave the daemon.
31
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
32
+ // E.164-ish and common separated phone numbers (>=7 digits with separators).
33
+ const PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/g;
34
+ // Long digit runs that look like card / account numbers (13-19 digits).
35
+ const LONG_NUMBER_RE = /\b\d{13,19}\b/g;
36
+ // IPv4 addresses.
37
+ const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
38
+
39
+ // --- Secret / token patterns -----------------------------------------------
40
+ // These must run BEFORE the generic numeric/email scrubbers and are ordered
41
+ // most-specific first so a long opaque secret is never partially redacted.
42
+ //
43
+ // `Authorization: Bearer <token>` / bare `Bearer <token>`.
44
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
45
+ // JSON Web Tokens: three base64url segments separated by dots (header.payload.sig).
46
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g;
47
+ // Slack tokens: xoxb-/xoxp-/xoxa-/xoxr-/xoxs-... and legacy xox*-.
48
+ const SLACK_TOKEN_RE = /\bxox[abeoprs]-[A-Za-z0-9-]{8,}/gi;
49
+ // Common prefixed provider keys: OpenAI sk-/sk-proj-, GitHub gh[poursa]_,
50
+ // Google AIza..., Stripe sk_live_/pk_live_/rk_live_, Slack-app xapp-, AWS AKIA...
51
+ const PREFIXED_KEY_RE =
52
+ /\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{16,}|gh[poursa]_[A-Za-z0-9]{16,}|AIza[A-Za-z0-9_-]{16,}|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|xapp-[A-Za-z0-9-]{8,}|AKIA[A-Z0-9]{16})/g;
53
+ // `token=`, `api_key=`, `access_token: ...`, `secret = ...` style key/value pairs.
54
+ const KV_SECRET_RE =
55
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd|authorization|auth)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[A-Za-z0-9._~+/=-]{6,})/gi;
56
+ // Generic high-entropy opaque blobs (>=24 chars of base64url/hex) not already
57
+ // caught above — catches raw API keys pasted without a recognizable prefix.
58
+ const OPAQUE_SECRET_RE = /\b[A-Za-z0-9_-]{24,}\b/g;
59
+
60
+ /**
61
+ * Replace PII and credentials with stable redaction tokens (does not alter the
62
+ * length budget). Secrets/tokens are scrubbed before the email/phone/number
63
+ * passes so an OAuth/bearer/API token can never leak into a preview.
64
+ */
65
+ export function stripPii(input: string): string {
66
+ return input
67
+ .replace(BEARER_RE, '[token]')
68
+ .replace(JWT_RE, '[token]')
69
+ .replace(SLACK_TOKEN_RE, '[token]')
70
+ .replace(PREFIXED_KEY_RE, '[token]')
71
+ .replace(KV_SECRET_RE, (match) => {
72
+ const sep = match.includes('=') ? '=' : ':';
73
+ const key = match.slice(0, match.indexOf(sep)).trimEnd();
74
+ return `${key}${sep}[token]`;
75
+ })
76
+ .replace(EMAIL_RE, '[email]')
77
+ .replace(IPV4_RE, '[ip]')
78
+ .replace(LONG_NUMBER_RE, '[number]')
79
+ .replace(PHONE_RE, (match) => {
80
+ // Avoid eating short numeric tokens that survived LONG_NUMBER_RE; only
81
+ // redact when there are at least 7 digits.
82
+ const digits = match.replace(/\D/g, '');
83
+ return digits.length >= 7 ? '[phone]' : match;
84
+ })
85
+ // Sweep any remaining long opaque blob (e.g. a bare API key) last so we do
86
+ // not clobber the redaction tokens we just inserted.
87
+ .replace(OPAQUE_SECRET_RE, (match) => (/^[A-Za-z]+$/.test(match) ? match : '[token]'));
88
+ }
89
+
90
+ // --- Markup / MIME de-structuring ------------------------------------------
91
+ // Email BODY[TEXT] is frequently raw HTML and/or a multipart/MIME payload with
92
+ // boundary lines and Content-* headers. Previews must be human-readable plain
93
+ // text, so we de-MIME (prefer the text/plain part), strip tags, and decode the
94
+ // handful of HTML entities that survive into previews. This is a no-op on text
95
+ // that is already plain.
96
+ const HTML_ENTITIES: Record<string, string> = {
97
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
98
+ '&#39;': "'", '&apos;': "'", '&nbsp;': ' ',
99
+ };
100
+
101
+ /** True when the text looks like a MIME multipart payload (has boundary lines). */
102
+ function isMultipart(text: string): boolean {
103
+ return /^--[^\r\n]+\r?\n/m.test(text) && /content-type:/i.test(text);
104
+ }
105
+
106
+ /**
107
+ * Given a multipart body, return the decoded text/plain part if present,
108
+ * otherwise the text/html part, otherwise the original input. Strips the
109
+ * per-part MIME headers so only the body bytes remain.
110
+ */
111
+ function extractMimePart(text: string): string {
112
+ const boundaryMatch = /^--([^\r\n]+?)(?:--)?\r?$/m.exec(text);
113
+ if (!boundaryMatch) return text;
114
+ const boundary = boundaryMatch[1]!;
115
+ const parts = text.split(new RegExp(`--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?\r?\n?`));
116
+ let htmlPart: string | undefined;
117
+ for (const rawPart of parts) {
118
+ const split = /\r?\n\r?\n/.exec(rawPart);
119
+ if (!split) continue;
120
+ const headers = rawPart.slice(0, split.index);
121
+ const body = rawPart.slice(split.index + split[0].length);
122
+ const ctype = /content-type:\s*([^\r\n;]+)/i.exec(headers)?.[1]?.trim().toLowerCase();
123
+ if (!ctype) continue;
124
+ if (ctype === 'text/plain') return body.trim();
125
+ if (ctype === 'text/html' && htmlPart === undefined) htmlPart = body;
126
+ }
127
+ return (htmlPart ?? text).trim();
128
+ }
129
+
130
+ /** Strip stray top-level MIME/Content-* headers from a single-part body. */
131
+ function stripMimeHeaders(text: string): string {
132
+ return text.replace(
133
+ /^(?:content-type|content-transfer-encoding|content-disposition|content-id|mime-version|--[^\r\n]+)\b[^\r\n]*\r?\n/gim,
134
+ '',
135
+ );
136
+ }
137
+
138
+ /** Decode the small set of HTML entities that matter for plain-text previews. */
139
+ function decodeEntities(text: string): string {
140
+ return text
141
+ .replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&nbsp;/gi, (m) => HTML_ENTITIES[m.toLowerCase()] ?? m)
142
+ .replace(/&#(\d{1,7});/g, (_m, dec: string) => {
143
+ const code = Number.parseInt(dec, 10);
144
+ return Number.isFinite(code) ? String.fromCodePoint(code) : _m;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Convert HTML/MIME body text into readable plain text. Drops <script>/<style>
150
+ * blocks entirely, turns block-level tags into spaces, removes all remaining
151
+ * tags, decodes entities, and de-MIMEs multipart payloads. Plain text passes
152
+ * through unchanged (modulo entity decoding).
153
+ */
154
+ export function stripMarkup(input: string): string {
155
+ let text = input;
156
+ if (isMultipart(text)) {
157
+ text = extractMimePart(text);
158
+ }
159
+ text = stripMimeHeaders(text);
160
+ // Drop script/style contents before tag removal so their bodies never leak.
161
+ text = text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ');
162
+ // Only treat as HTML if there is an actual tag; avoids mangling plain text
163
+ // that merely contains a stray '<'.
164
+ if (/<[a-z!/][^>]*>/i.test(text)) {
165
+ text = text
166
+ .replace(/<\/?(?:br|p|div|tr|li|h[1-6]|table|ul|ol|blockquote|hr)\b[^>]*>/gi, ' ')
167
+ .replace(/<[^>]+>/g, '');
168
+ }
169
+ return decodeEntities(text);
170
+ }
171
+
172
+ /** Collapse whitespace and trim — keeps previews single-line and tidy. */
173
+ export function normalizeWhitespace(input: string): string {
174
+ return input.replace(/\s+/g, ' ').trim();
175
+ }
176
+
177
+ /** Build a display-safe subject preview (<=200 chars, no PII, single line). */
178
+ export function toSubjectPreview(raw: string | undefined | null): string {
179
+ const normalized = normalizeWhitespace(stripPii(raw ?? ''));
180
+ return normalized.slice(0, SUBJECT_PREVIEW_MAX);
181
+ }
182
+
183
+ /**
184
+ * Build a display-safe body preview (<=500 chars, plain text, PII-stripped,
185
+ * single line). HTML/MIME is de-structured to readable text first so previews
186
+ * of real-world (HTML/multipart) emails never leak tags or MIME headers.
187
+ */
188
+ export function toBodyPreview(raw: string | undefined | null): string {
189
+ const plain = stripMarkup(raw ?? '');
190
+ const normalized = normalizeWhitespace(stripPii(plain));
191
+ return normalized.slice(0, BODY_PREVIEW_MAX);
192
+ }
@@ -0,0 +1,239 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbound provider poller.
3
+ //
4
+ // Runs one setInterval per provider at the provider's own cadence (Slack/
5
+ // Discord 30s, email 60s, others 120s). Each tick:
6
+ // 1. resolves the provider's persisted cursor (nextSince)
7
+ // 2. calls adapter.poll({ since, limit })
8
+ // 3. dedups + persists items into the cursor store (upsert)
9
+ // 4. advances the cursor monotonically to max(receivedAt)
10
+ // 5. records the last per-provider state for channels.inbox.list to report
11
+ //
12
+ // One bad provider can never crash the loop: adapter.poll() resolves with
13
+ // state:'unavailable' instead of rejecting, and any thrown error is caught and
14
+ // downgraded to an 'unavailable' status here.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import type { InboundProviderAdapter, ProviderState } from './provider-adapter.ts';
18
+ import type { InboxCursorStore } from './cursor-store.ts';
19
+ import type { HandlerLogger } from '../context.ts';
20
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
21
+
22
+ export interface ProviderStatus {
23
+ id: string;
24
+ state: ProviderState;
25
+ /** NEW items the last poll persisted. Not the provider's stored total. */
26
+ itemCount: number;
27
+ error?: string;
28
+ lastPolledAt?: number;
29
+ /**
30
+ * Whether the provider's credentials resolved on the last poll, as the
31
+ * adapter reported it. Absent until a poll has happened (or when the
32
+ * credential store itself failed and the adapter could not find out).
33
+ */
34
+ configured?: boolean;
35
+ /**
36
+ * True once this provider has completed at least one poll on this node.
37
+ *
38
+ * A never-polled provider and a polled-and-empty one both hold zero items,
39
+ * and `channels.inbox.list` must not present the first as the second: on a
40
+ * node that is not the elected fetcher for an account, "we have not looked"
41
+ * is the whole truth and "there is nothing" would be a fabrication.
42
+ */
43
+ polled: boolean;
44
+ }
45
+
46
+ export interface PollerOptions {
47
+ adapters: Map<string, InboundProviderAdapter>;
48
+ store: InboxCursorStore;
49
+ logger: HandlerLogger;
50
+ /** Max items fetched per provider per tick. */
51
+ perProviderLimit?: number;
52
+ /** Inject a timer factory for tests (defaults to global setInterval). */
53
+ setIntervalImpl?: typeof setInterval;
54
+ clearIntervalImpl?: typeof clearInterval;
55
+ }
56
+
57
+ const DEFAULT_PER_PROVIDER_LIMIT = 50;
58
+
59
+ export class InboundPoller {
60
+ private readonly adapters: Map<string, InboundProviderAdapter>;
61
+ private readonly store: InboxCursorStore;
62
+ private readonly logger: HandlerLogger;
63
+ private readonly perProviderLimit: number;
64
+ private readonly setIntervalImpl: typeof setInterval;
65
+ private readonly clearIntervalImpl: typeof clearInterval;
66
+ private readonly timers = new Map<string, ReturnType<typeof setInterval>>();
67
+ private readonly statuses = new Map<string, ProviderStatus>();
68
+ private readonly inFlight = new Set<string>();
69
+ private started = false;
70
+ /**
71
+ * Set by stop(), which the surface teardown calls and nothing else — unlike
72
+ * stopProvider(), which a leadership handover uses and which must stay
73
+ * resumable. Once the surface is released no interval may be armed again,
74
+ * including by work that was already in flight: registerInboxMethods() starts
75
+ * the store bootstrap without awaiting it, so a teardown during that window
76
+ * would otherwise be followed by a start() that nothing is left to undo.
77
+ */
78
+ private released = false;
79
+
80
+ constructor(options: PollerOptions) {
81
+ this.adapters = options.adapters;
82
+ this.store = options.store;
83
+ this.logger = options.logger;
84
+ this.perProviderLimit = options.perProviderLimit ?? DEFAULT_PER_PROVIDER_LIMIT;
85
+ this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
86
+ this.clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
87
+ for (const id of this.adapters.keys()) {
88
+ // `polled: false` until a poll actually completes — see ProviderStatus.
89
+ this.statuses.set(id, { id, state: 'empty', itemCount: 0, polled: false });
90
+ }
91
+ }
92
+
93
+ /** Begin per-provider interval loops. Idempotent. Does NOT poll immediately. */
94
+ start(): void {
95
+ if (this.released || this.started) return;
96
+ this.started = true;
97
+ for (const id of this.adapters.keys()) this.startProvider(id);
98
+ }
99
+
100
+ /**
101
+ * Begin ONE provider's interval loop. Idempotent.
102
+ *
103
+ * Each inbox account is its own surface in the LAN election, so the machine
104
+ * that reads the work Slack account may not be the machine that reads the
105
+ * mailbox. A blanket start/stop cannot express that: it would take every
106
+ * account down to hand one of them over. Everything below is therefore
107
+ * addressable per provider, and the blanket calls are fan-outs over it.
108
+ */
109
+ startProvider(id: string): void {
110
+ if (this.released || this.timers.has(id)) return;
111
+ const adapter = this.adapters.get(id);
112
+ if (!adapter) return;
113
+ const handle = this.setIntervalImpl(() => {
114
+ void this.pollProvider(id, adapter);
115
+ }, adapter.pollIntervalMs);
116
+ // Do not keep the event loop alive solely for polling (Bun/Node unref).
117
+ (handle as unknown as { unref?: () => void }).unref?.();
118
+ this.timers.set(id, handle);
119
+ }
120
+
121
+ /**
122
+ * Stop ONE provider's interval loop. Idempotent.
123
+ *
124
+ * Synchronous underneath — clearing the interval means no further tick can
125
+ * be scheduled — which is what lets the RESIGN that follows a handoff be an
126
+ * honest claim that this node has stopped reading that account.
127
+ */
128
+ stopProvider(id: string): void {
129
+ const handle = this.timers.get(id);
130
+ if (handle === undefined) return;
131
+ this.clearIntervalImpl(handle);
132
+ this.timers.delete(id);
133
+ if (this.timers.size === 0) this.started = false;
134
+ }
135
+
136
+ /** True when this node is polling the given provider right now. */
137
+ isProviderRunning(id: string): boolean {
138
+ return this.timers.has(id);
139
+ }
140
+
141
+ /** Every provider this node has an adapter for. */
142
+ providerIds(): string[] {
143
+ return [...this.adapters.keys()];
144
+ }
145
+
146
+ /** Run a single poll across all providers now (used on register + tests). */
147
+ async pollOnce(): Promise<void> {
148
+ await Promise.all(
149
+ [...this.adapters.entries()].map(([id, adapter]) => this.pollProvider(id, adapter)),
150
+ );
151
+ }
152
+
153
+ /** Run a single poll for ONE provider now. Never throws. */
154
+ async pollProviderOnce(id: string): Promise<void> {
155
+ const adapter = this.adapters.get(id);
156
+ if (!adapter) return;
157
+ await this.pollProvider(id, adapter);
158
+ }
159
+
160
+ /** Poll a single provider, dedup + persist, update status. Never throws. */
161
+ async pollProvider(id: string, adapter: InboundProviderAdapter): Promise<void> {
162
+ if (this.inFlight.has(id)) return; // skip overlapping ticks
163
+ this.inFlight.add(id);
164
+ const since = this.store.getCursor(id) || undefined;
165
+ try {
166
+ const result = await adapter.poll({ since, limit: this.perProviderLimit });
167
+ if (result.state === 'unavailable') {
168
+ this.setStatus(id, {
169
+ id,
170
+ state: 'unavailable',
171
+ itemCount: 0,
172
+ error: result.error ?? 'provider unavailable',
173
+ lastPolledAt: Date.now(),
174
+ ...(result.configured === undefined ? {} : { configured: result.configured }),
175
+ polled: true,
176
+ });
177
+ return;
178
+ }
179
+ const newCount = this.store.upsertItems(result.items);
180
+ let maxReceived = since ?? 0;
181
+ for (const item of result.items) {
182
+ if (item.receivedAt > maxReceived) maxReceived = item.receivedAt;
183
+ }
184
+ if (maxReceived > 0) this.store.advanceCursor(id, maxReceived);
185
+ await this.store.flush();
186
+ this.setStatus(id, {
187
+ id,
188
+ state: result.items.length > 0 ? 'ready' : 'empty',
189
+ itemCount: newCount,
190
+ lastPolledAt: Date.now(),
191
+ ...(result.configured === undefined ? {} : { configured: result.configured }),
192
+ polled: true,
193
+ });
194
+ } catch (error) {
195
+ const message = summarizeError(error);
196
+ this.logger.warn('inbound poll failed', { provider: id, error: message });
197
+ // A THROW rather than an honest 'unavailable' result. The adapter got far
198
+ // enough to run, so the credentials are not the thing that failed —
199
+ // reporting it as configured is what keeps this out of the
200
+ // "you never set this up" bucket.
201
+ this.setStatus(id, {
202
+ id,
203
+ state: 'unavailable',
204
+ itemCount: 0,
205
+ error: message,
206
+ lastPolledAt: Date.now(),
207
+ configured: true,
208
+ polled: true,
209
+ });
210
+ } finally {
211
+ this.inFlight.delete(id);
212
+ }
213
+ }
214
+
215
+ /** Snapshot of the last known status for each provider. */
216
+ snapshotStatuses(providerIds?: readonly string[]): ProviderStatus[] {
217
+ const ids = providerIds && providerIds.length > 0
218
+ ? providerIds.filter((id) => this.statuses.has(id))
219
+ : [...this.statuses.keys()];
220
+ return ids.map((id) => {
221
+ const status = this.statuses.get(id)!;
222
+ return { ...status };
223
+ });
224
+ }
225
+
226
+ /** Release the poller for good: stop every interval loop, and refuse to arm another. Idempotent. */
227
+ stop(): void {
228
+ this.released = true;
229
+ for (const handle of this.timers.values()) {
230
+ this.clearIntervalImpl(handle);
231
+ }
232
+ this.timers.clear();
233
+ this.started = false;
234
+ }
235
+
236
+ private setStatus(id: string, status: ProviderStatus): void {
237
+ this.statuses.set(id, status);
238
+ }
239
+ }