@pellux/goodvibes-tui 0.25.0 → 0.27.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 (146) hide show
  1. package/CHANGELOG.md +22 -3
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +2422 -1040
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/conversation-rendering.ts +2 -2
  8. package/src/core/conversation.ts +49 -2
  9. package/src/core/session-recovery.ts +24 -18
  10. package/src/core/system-message-router.ts +42 -26
  11. package/src/core/turn-event-wiring.ts +12 -16
  12. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
  13. package/src/daemon/handlers/calendar/index.ts +316 -0
  14. package/src/daemon/handlers/context.ts +29 -0
  15. package/src/daemon/handlers/contracts.ts +77 -0
  16. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  17. package/src/daemon/handlers/drafts/index.ts +17 -0
  18. package/src/daemon/handlers/drafts/register.ts +331 -0
  19. package/src/daemon/handlers/email/config.ts +164 -0
  20. package/src/daemon/handlers/email/index.ts +43 -0
  21. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  22. package/src/daemon/handlers/email/runtime.ts +140 -0
  23. package/src/daemon/handlers/email/validation.ts +147 -0
  24. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  25. package/src/daemon/handlers/errors.ts +18 -0
  26. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  27. package/src/daemon/handlers/inbox/index.ts +211 -0
  28. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  29. package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
  30. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  31. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
  32. package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
  33. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  34. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
  35. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
  36. package/src/daemon/handlers/index.ts +107 -0
  37. package/src/daemon/handlers/register.ts +161 -0
  38. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  39. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  40. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  41. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  42. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  43. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  44. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  45. package/src/daemon/handlers/remote/index.ts +119 -0
  46. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  47. package/src/daemon/handlers/remote/service.ts +191 -0
  48. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  49. package/src/daemon/handlers/routing/index.ts +261 -0
  50. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  51. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  52. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  53. package/src/daemon/handlers/triage/index.ts +57 -0
  54. package/src/daemon/handlers/triage/integration.ts +213 -0
  55. package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
  56. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  57. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  58. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  59. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  60. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  61. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  62. package/src/daemon/handlers/triage/types.ts +50 -0
  63. package/src/export/gist-uploader.ts +3 -1
  64. package/src/input/command-registry.ts +1 -0
  65. package/src/input/commands/cloudflare-runtime.ts +2 -1
  66. package/src/input/commands/guidance-runtime.ts +2 -4
  67. package/src/input/commands/health-runtime.ts +13 -7
  68. package/src/input/commands/knowledge.ts +2 -1
  69. package/src/input/commands/runtime-services.ts +40 -10
  70. package/src/input/handler-command-route.ts +1 -1
  71. package/src/input/handler-interactions.ts +2 -1
  72. package/src/input/handler-modal-routes.ts +2 -1
  73. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  74. package/src/input/handler-onboarding.ts +8 -8
  75. package/src/input/handler-ui-state.ts +1 -1
  76. package/src/input/tts-settings-actions.ts +2 -1
  77. package/src/main.ts +52 -59
  78. package/src/panels/agent-inspector-panel.ts +72 -2
  79. package/src/panels/agent-logs-panel.ts +127 -19
  80. package/src/panels/base-panel.ts +2 -1
  81. package/src/panels/builtin/agent.ts +3 -1
  82. package/src/panels/builtin/development.ts +1 -0
  83. package/src/panels/builtin/session.ts +1 -1
  84. package/src/panels/context-visualizer-panel.ts +24 -14
  85. package/src/panels/cost-tracker-panel.ts +7 -13
  86. package/src/panels/debug-panel.ts +11 -22
  87. package/src/panels/docs-panel.ts +14 -24
  88. package/src/panels/file-explorer-panel.ts +2 -1
  89. package/src/panels/file-preview-panel.ts +2 -1
  90. package/src/panels/git-panel.ts +10 -17
  91. package/src/panels/marketplace-panel.ts +2 -1
  92. package/src/panels/memory-panel.ts +2 -1
  93. package/src/panels/project-planning-panel.ts +5 -4
  94. package/src/panels/provider-health-panel.ts +56 -67
  95. package/src/panels/schedule-panel.ts +4 -7
  96. package/src/panels/session-browser-panel.ts +13 -21
  97. package/src/panels/skills-panel.ts +2 -1
  98. package/src/panels/tasks-panel.ts +2 -7
  99. package/src/panels/thinking-panel.ts +6 -11
  100. package/src/panels/token-budget-panel.ts +31 -15
  101. package/src/panels/tool-inspector-panel.ts +10 -18
  102. package/src/panels/work-plan-panel.ts +2 -1
  103. package/src/panels/wrfc-panel.ts +37 -35
  104. package/src/renderer/agent-detail-modal.ts +2 -2
  105. package/src/renderer/modal-utils.ts +0 -10
  106. package/src/renderer/process-modal.ts +2 -2
  107. package/src/runtime/bootstrap-command-context.ts +3 -0
  108. package/src/runtime/bootstrap-command-parts.ts +3 -1
  109. package/src/runtime/bootstrap-core.ts +31 -31
  110. package/src/runtime/bootstrap-shell.ts +1 -0
  111. package/src/runtime/onboarding/apply.ts +4 -3
  112. package/src/runtime/onboarding/snapshot.ts +4 -3
  113. package/src/runtime/process-lifecycle.ts +195 -0
  114. package/src/runtime/services.ts +52 -36
  115. package/src/runtime/wrfc-persistence.ts +20 -5
  116. package/src/shell/ui-openers.ts +3 -2
  117. package/src/verification/live-verifier.ts +4 -3
  118. package/src/version.ts +1 -1
  119. package/src/core/context-auto-compact.ts +0 -110
  120. package/src/daemon/calendar/index.ts +0 -52
  121. package/src/daemon/calendar/register.ts +0 -527
  122. package/src/daemon/channels/drafts/index.ts +0 -22
  123. package/src/daemon/channels/drafts/register.ts +0 -449
  124. package/src/daemon/channels/inbox/index.ts +0 -58
  125. package/src/daemon/channels/inbox/register.ts +0 -247
  126. package/src/daemon/channels/routing/index.ts +0 -39
  127. package/src/daemon/channels/routing/register.ts +0 -296
  128. package/src/daemon/email/index.ts +0 -68
  129. package/src/daemon/email/register.ts +0 -715
  130. package/src/daemon/operator/index.ts +0 -43
  131. package/src/daemon/operator/register-helper.ts +0 -150
  132. package/src/daemon/operator/surfaces.ts +0 -137
  133. package/src/daemon/operator/types.ts +0 -207
  134. package/src/daemon/remote/backends/index.ts +0 -34
  135. package/src/daemon/remote/index.ts +0 -74
  136. package/src/daemon/remote/register.ts +0 -411
  137. package/src/daemon/triage/index.ts +0 -59
  138. package/src/daemon/triage/integration.ts +0 -179
  139. package/src/daemon/triage/register.ts +0 -231
  140. package/src/daemon/triage/tagger.ts +0 -777
  141. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  142. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  143. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  144. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  145. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  146. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -1,777 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Daemon-internal triage TAGGER.
3
- //
4
- // Applies user-defined triage labels back on the provider side:
5
- // - IMAP : STORE a keyword flag on the message (IMAP4rev1 over TLS).
6
- // - Slack: reactions.add emoji on the source message.
7
- // - Discord: when the item is a forum/media-channel THREAD and a forum-tag
8
- // mapping is configured, apply real Discord thread tags (PATCH the thread's
9
- // applied_tags array — exact fidelity to the contract's "Discord thread
10
- // tags"). Otherwise fall back to a unicode reaction on the source message
11
- // as a documented analog (Discord has no arbitrary per-message tags).
12
- //
13
- // Hard rules honored here:
14
- // - All provider credentials come ONLY from the daemon credential store.
15
- // They are never returned in results and never logged.
16
- // - The whole tagger is gated behind a config flag (surfaces.triage.autoTag);
17
- // when disabled, applyTags() is a no-op that reports skipped:true.
18
- // - Provider-side writes are EFFECTFUL; callers must pass an explicitly
19
- // confirmed request (confirm === true). Unconfirmed calls throw
20
- // OperatorError(REQUIRE_CONFIRM).
21
- //
22
- // Contract-fidelity note (handoff doc, line 52): the triage surface
23
- // (`inbox.triage.*`) is classified NOT PUBLISHED — it is a daemon-internal
24
- // pipeline, not a published operator method. There is therefore NO external
25
- // spec to certify these tag-method request/response shapes against; that
26
- // contract-fidelity dimension is structurally UNCERTIFIABLE here, not a
27
- // defect. We do not invent a fake external contract. What we DO guarantee is
28
- // the provider-side behavior below: no silent data loss (Discord thread tags
29
- // are merged, never blindly overwritten) and no command injection (IMAP
30
- // quoting rejects control characters).
31
- // ---------------------------------------------------------------------------
32
-
33
- import { connect as tlsConnect } from 'node:tls';
34
- import type { TLSSocket } from 'node:tls';
35
- import {
36
- OperatorError,
37
- REQUIRE_CONFIRM,
38
- type DaemonCredentialStore,
39
- type InboundChannelItem,
40
- type OperatorContext,
41
- } from '../operator/index.ts';
42
- import { createDaemonCredentialStore } from '../operator/index.ts';
43
- import type { TriageLabel } from './scorer.ts';
44
- import { labelToTag } from './scorer.ts';
45
-
46
- export const TRIAGE_AUTOTAG_FLAG = 'surfaces.triage.autoTag';
47
-
48
- export interface TaggerProviderConfig {
49
- /** IMAP host:port (default port 993, TLS). Credentials resolved separately. */
50
- imap?: { host: string; port?: number; user: string; passwordConfigKey: string; mailbox?: string };
51
- /** Slack bot token config key (resolved from credential store). */
52
- slack?: { tokenConfigKey: string };
53
- /**
54
- * Discord bot token config key (resolved from credential store), plus an
55
- * optional forum-tag mapping. When `forumTagIds` maps a GoodVibes triage tag
56
- * (e.g. 'GoodVibes/Spam') to a forum tag SNOWFLAKE id, items that target a
57
- * forum/media-channel thread get that REAL thread tag applied (PATCH
58
- * applied_tags) — exact fidelity to the contract's "Discord thread tags".
59
- * Without a mapping (or for non-thread messages) tagging degrades to a
60
- * unicode reaction analog.
61
- */
62
- discord?: { tokenConfigKey: string; forumTagIds?: Record<string, string> };
63
- }
64
-
65
- export interface TriageTaggerOptions {
66
- credentials?: DaemonCredentialStore;
67
- /** Override the autotag flag lookup (used in tests). */
68
- autoTagEnabled?: boolean;
69
- /** Per-surface provider config; usually derived from configManager. */
70
- providers?: TaggerProviderConfig;
71
- /** Injectable fetch (Slack/Discord HTTP). Defaults to global fetch. */
72
- fetchImpl?: typeof fetch;
73
- /** Injectable IMAP flag-setter (used in tests to avoid a live socket). */
74
- imapStoreFlag?: (args: ImapStoreArgs) => Promise<void>;
75
- /**
76
- * Transient-failure retry policy for the default IMAP store implementation.
77
- * Network blips (ECONNRESET/ETIMEDOUT/EPIPE, unexpected close, timeouts) are
78
- * retried with exponential backoff. Protocol-level NO/BAD responses are NOT
79
- * retried (they are deterministic). Ignored when imapStoreFlag is injected.
80
- */
81
- imapRetry?: ImapRetryOptions;
82
- }
83
-
84
- export interface ImapRetryOptions {
85
- /** Total attempts including the first (default 3). Values < 1 disable retry. */
86
- maxAttempts?: number;
87
- /** Base backoff in ms for the first retry (default 250). */
88
- baseDelayMs?: number;
89
- /** Cap on any single backoff delay in ms (default 2000). */
90
- maxDelayMs?: number;
91
- /** Injectable sleep (tests). Defaults to a real timer. */
92
- sleep?: (ms: number) => Promise<void>;
93
- }
94
-
95
- export interface ImapStoreArgs {
96
- host: string;
97
- port: number;
98
- user: string;
99
- password: string;
100
- mailbox: string;
101
- uid: string;
102
- flag: string;
103
- }
104
-
105
- export interface ApplyTagsRequest {
106
- item: InboundChannelItem;
107
- /** Provider-side tags to apply. Defaults to [labelToTag(label)] when omitted. */
108
- tags?: readonly string[];
109
- label?: TriageLabel;
110
- /** Must be true — provider-side mutation requires explicit confirmation. */
111
- confirm?: boolean;
112
- /** Mirror of the operator invocation context flag. */
113
- explicitUserRequest?: boolean;
114
- }
115
-
116
- export interface ApplyTagsResult {
117
- surface: string;
118
- itemId: string;
119
- appliedTags: string[];
120
- /** True when the autotag flag is disabled or no provider matched. */
121
- skipped: boolean;
122
- reason?: string;
123
- }
124
-
125
- export interface TriageTagger {
126
- /** Whether provider-side tagging is currently enabled. */
127
- enabled(): boolean;
128
- applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult>;
129
- }
130
-
131
- function readBoolFlag(
132
- configManager: OperatorContext['configManager'],
133
- key: string,
134
- ): boolean {
135
- try {
136
- const value = configManager.get(key as never) as unknown;
137
- return value === true || value === 'true' || value === 1;
138
- } catch {
139
- return false;
140
- }
141
- }
142
-
143
- function resolveProvidersFromConfig(
144
- configManager: OperatorContext['configManager'],
145
- ): TaggerProviderConfig {
146
- const out: TaggerProviderConfig = {};
147
- const slackToken = safeGet(configManager, 'surfaces.slack.botToken');
148
- if (typeof slackToken === 'string' && slackToken.length > 0) {
149
- out.slack = { tokenConfigKey: 'surfaces.slack.botToken' };
150
- }
151
- const discordToken = safeGet(configManager, 'surfaces.discord.botToken');
152
- if (typeof discordToken === 'string' && discordToken.length > 0) {
153
- out.discord = { tokenConfigKey: 'surfaces.discord.botToken' };
154
- }
155
- const imapHost = safeGet(configManager, 'surfaces.email.imap.host');
156
- const imapUser = safeGet(configManager, 'surfaces.email.imap.user');
157
- if (typeof imapHost === 'string' && imapHost.length > 0 && typeof imapUser === 'string') {
158
- const portRaw = safeGet(configManager, 'surfaces.email.imap.port');
159
- const mailbox = safeGet(configManager, 'surfaces.email.imap.mailbox');
160
- out.imap = {
161
- host: imapHost,
162
- port: typeof portRaw === 'number' ? portRaw : 993,
163
- user: imapUser,
164
- passwordConfigKey: 'surfaces.email.imap.password',
165
- mailbox: typeof mailbox === 'string' && mailbox.length > 0 ? mailbox : 'INBOX',
166
- };
167
- }
168
- return out;
169
- }
170
-
171
- function safeGet(configManager: OperatorContext['configManager'], key: string): unknown {
172
- try {
173
- return configManager.get(key as never) as unknown;
174
- } catch {
175
- return undefined;
176
- }
177
- }
178
-
179
- /**
180
- * Create the triage tagger. Reads the autotag flag and provider config from the
181
- * operator context; credentials are resolved lazily, per apply, from the daemon
182
- * credential store.
183
- */
184
- export function createTriageTagger(
185
- ctx: OperatorContext,
186
- options: TriageTaggerOptions = {},
187
- ): TriageTagger {
188
- const credentials = options.credentials ?? createDaemonCredentialStore(ctx.secrets);
189
- const fetchImpl = options.fetchImpl ?? fetch;
190
- const providers = options.providers ?? resolveProvidersFromConfig(ctx.configManager);
191
- // Retry wraps whichever store impl is in use (default TLS client OR an
192
- // injected one), so transient failures are retried uniformly. Injected test
193
- // stores that succeed first-try are unaffected.
194
- const imapStoreFlag = makeRetryingImapStoreFlag(
195
- options.imapStoreFlag ?? imapStoreFlagOverTls,
196
- options.imapRetry,
197
- );
198
- const enabled = (): boolean =>
199
- options.autoTagEnabled ?? readBoolFlag(ctx.configManager, TRIAGE_AUTOTAG_FLAG);
200
-
201
- return {
202
- enabled,
203
- async applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult> {
204
- const { item } = request;
205
- const tags = resolveTags(request);
206
- const base: ApplyTagsResult = {
207
- surface: item.surface,
208
- itemId: item.id,
209
- appliedTags: [],
210
- skipped: true,
211
- };
212
-
213
- if (!enabled()) {
214
- return { ...base, reason: 'autotag-disabled' };
215
- }
216
-
217
- // Provider-side mutation is effectful — require explicit confirmation.
218
- if (request.confirm !== true || request.explicitUserRequest !== true) {
219
- throw new OperatorError(
220
- 'Provider-side triage tagging requires explicit user confirmation.',
221
- REQUIRE_CONFIRM,
222
- 403,
223
- );
224
- }
225
-
226
- switch (item.surface) {
227
- case 'email':
228
- case 'imap':
229
- return applyImap(item, tags, providers, credentials, imapStoreFlag, base);
230
- case 'slack':
231
- return applySlack(item, tags, providers, credentials, fetchImpl, ctx, base);
232
- case 'discord':
233
- return applyDiscord(item, tags, providers, credentials, fetchImpl, ctx, base);
234
- default:
235
- return { ...base, reason: `unsupported-surface:${item.surface}` };
236
- }
237
- },
238
- };
239
- }
240
-
241
- function resolveTags(request: ApplyTagsRequest): string[] {
242
- if (request.tags && request.tags.length > 0) {
243
- return [...new Set(request.tags.map((t) => t.trim()).filter((t) => t.length > 0))];
244
- }
245
- if (request.label) return [labelToTag(request.label)];
246
- return [];
247
- }
248
-
249
- // ---------------------------------------------------------------------------
250
- // IMAP
251
- // ---------------------------------------------------------------------------
252
-
253
- async function applyImap(
254
- item: InboundChannelItem,
255
- tags: string[],
256
- providers: TaggerProviderConfig,
257
- credentials: DaemonCredentialStore,
258
- storeFlag: (args: ImapStoreArgs) => Promise<void>,
259
- base: ApplyTagsResult,
260
- ): Promise<ApplyTagsResult> {
261
- const cfg = providers.imap;
262
- if (!cfg) return { ...base, reason: 'imap-not-configured' };
263
- const uid = imapUidFromItem(item);
264
- if (!uid) return { ...base, reason: 'imap-missing-uid' };
265
- if (tags.length === 0) return { ...base, reason: 'no-tags' };
266
-
267
- const password = await credentials.resolveConfigSecret(cfg.passwordConfigKey);
268
- if (!password) return { ...base, reason: 'imap-no-credentials' };
269
-
270
- const applied: string[] = [];
271
- for (const tag of tags) {
272
- await storeFlag({
273
- host: cfg.host,
274
- port: cfg.port ?? 993,
275
- user: cfg.user,
276
- password,
277
- mailbox: cfg.mailbox ?? 'INBOX',
278
- uid,
279
- flag: imapKeywordForTag(tag),
280
- });
281
- applied.push(tag);
282
- }
283
- return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
284
- }
285
-
286
- function imapUidFromItem(item: InboundChannelItem): string | null {
287
- const meta = item.metadata ?? {};
288
- const uid = meta.imapUid ?? meta.uid;
289
- if (typeof uid === 'string' && uid.length > 0) return uid;
290
- if (typeof uid === 'number' && Number.isFinite(uid)) return String(uid);
291
- return null;
292
- }
293
-
294
- /** IMAP keywords cannot contain spaces or '/'; normalize the canonical tag. */
295
- function imapKeywordForTag(tag: string): string {
296
- return tag.replace(/[^A-Za-z0-9_]+/g, '_');
297
- }
298
-
299
- /**
300
- * Error subclass carrying whether a failure is transient (worth retrying) or a
301
- * deterministic protocol rejection (NO/BAD) that must not be retried.
302
- */
303
- class ImapStoreError extends Error {
304
- readonly transient: boolean;
305
- constructor(message: string, transient: boolean) {
306
- super(message);
307
- this.name = 'ImapStoreError';
308
- this.transient = transient;
309
- }
310
- }
311
-
312
- /**
313
- * Minimal IMAP4rev1 client: connect over TLS, LOGIN, SELECT, UID STORE +FLAGS,
314
- * LOGOUT. Uses only node:tls (Bun-compatible).
315
- *
316
- * Tagged-command sequencing uses an explicit completion flag rather than a line
317
- * heuristic: the LOGOUT step is tracked by reference, so its tagged OK (or an
318
- * untagged `* BYE`) cleanly completes the operation and tells the `close`
319
- * handler the disconnect was expected. Connection/timeout/socket failures are
320
- * surfaced as transient ImapStoreError; NO/BAD protocol responses as
321
- * non-transient.
322
- */
323
- function imapStoreFlagOverTls(args: ImapStoreArgs): Promise<void> {
324
- return new Promise<void>((resolve, reject) => {
325
- // Validate every value that gets interpolated into an IMAP command line
326
- // BEFORE opening the socket. quoteImap rejects CR/LF and other control
327
- // chars, closing the command-injection vector for LOGIN user/password and
328
- // SELECT mailbox. A rejection here is deterministic, so it is surfaced as a
329
- // non-transient ImapStoreError (never retried).
330
- try {
331
- assertImapSafe(args.user, 'user');
332
- assertImapSafe(args.password, 'password');
333
- assertImapSafe(args.mailbox, 'mailbox');
334
- } catch (err) {
335
- reject(
336
- err instanceof ImapStoreError
337
- ? err
338
- : new ImapStoreError(err instanceof Error ? err.message : String(err), false),
339
- );
340
- return;
341
- }
342
- type Step = { tag: string; isLogout: boolean; resolve: () => void; reject: (e: Error) => void };
343
-
344
- let done = false;
345
- let completed = false; // LOGOUT acknowledged (or BYE seen) — close is expected.
346
- let buffer = '';
347
- let pending: Step | null = null;
348
- let counter = 0;
349
- let greeted = false;
350
-
351
- const finish = (err?: Error): void => {
352
- if (done) return;
353
- done = true;
354
- try {
355
- socket.destroy();
356
- } catch {
357
- /* ignore */
358
- }
359
- if (err) reject(err);
360
- else resolve();
361
- };
362
-
363
- const socket: TLSSocket = tlsConnect(
364
- { host: args.host, port: args.port, servername: args.host },
365
- () => {
366
- /* greeting handled in the data pump */
367
- },
368
- );
369
- socket.setEncoding('utf-8');
370
- socket.setTimeout(20_000, () =>
371
- finish(new ImapStoreError('IMAP connection timed out', true)),
372
- );
373
-
374
- const send = (command: string, isLogout = false): Promise<void> =>
375
- new Promise<void>((res, rej) => {
376
- counter += 1;
377
- const tag = `A${counter}`;
378
- pending = { tag, isLogout, resolve: res, reject: rej };
379
- socket.write(`${tag} ${command}\r\n`);
380
- });
381
-
382
- const runSequence = async (): Promise<void> => {
383
- await send(`LOGIN ${quoteImap(args.user)} ${quoteImap(args.password)}`);
384
- await send(`SELECT ${quoteImap(args.mailbox)}`);
385
- await send(`UID STORE ${args.uid} +FLAGS (${args.flag})`);
386
- await send('LOGOUT', true);
387
- };
388
-
389
- socket.on('error', (err) =>
390
- finish(new ImapStoreError(err instanceof Error ? err.message : String(err), true)),
391
- );
392
- socket.on('close', () => {
393
- // A close BEFORE completion is unexpected (transient); after the LOGOUT
394
- // acknowledgement it is the normal teardown and must resolve cleanly.
395
- if (completed) finish();
396
- else finish(new ImapStoreError('IMAP connection closed unexpectedly', true));
397
- });
398
-
399
- socket.on('data', (chunk: string) => {
400
- buffer += chunk;
401
- let newlineIdx = buffer.indexOf('\n');
402
- while (newlineIdx !== -1) {
403
- const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
404
- buffer = buffer.slice(newlineIdx + 1);
405
- handleLine(line);
406
- newlineIdx = buffer.indexOf('\n');
407
- }
408
- });
409
-
410
- const handleLine = (line: string): void => {
411
- if (!greeted) {
412
- greeted = true;
413
- if (!/^\* (OK|PREAUTH)/i.test(line)) {
414
- finish(new ImapStoreError(`IMAP server rejected connection: ${line}`, true));
415
- return;
416
- }
417
- runSequence().catch((err) =>
418
- finish(
419
- err instanceof ImapStoreError
420
- ? err
421
- : new ImapStoreError(err instanceof Error ? err.message : String(err), false),
422
- ),
423
- );
424
- return;
425
- }
426
-
427
- // An untagged BYE during LOGOUT is the server announcing a clean close.
428
- if (/^\* BYE/i.test(line) && pending?.isLogout) {
429
- completed = true;
430
- return;
431
- }
432
-
433
- if (!pending) return;
434
- if (!line.startsWith(`${pending.tag} `)) return; // untagged data line
435
- const status = line.slice(pending.tag.length + 1);
436
- const current = pending;
437
- pending = null;
438
- if (/^OK/i.test(status)) {
439
- current.resolve();
440
- if (current.isLogout) {
441
- // LOGOUT acknowledged: the upcoming socket close is expected.
442
- completed = true;
443
- finish();
444
- }
445
- } else {
446
- // NO/BAD: deterministic protocol rejection — do not retry.
447
- current.reject(new ImapStoreError(`IMAP command failed: ${status}`, false));
448
- }
449
- };
450
- });
451
- }
452
-
453
- /** True when an error is worth retrying (network/connection, not protocol). */
454
- function isTransientImapError(error: unknown): boolean {
455
- if (error instanceof ImapStoreError) return error.transient;
456
- const code = (error as { code?: unknown })?.code;
457
- if (typeof code === 'string') {
458
- return ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN']
459
- .includes(code);
460
- }
461
- return false;
462
- }
463
-
464
- const defaultSleep = (ms: number): Promise<void> =>
465
- new Promise((res) => setTimeout(res, ms));
466
-
467
- /**
468
- * Wrap an IMAP store function with bounded exponential-backoff retry on
469
- * transient failures only. Protocol-level (NO/BAD) errors are surfaced on the
470
- * first attempt without retry.
471
- */
472
- function makeRetryingImapStoreFlag(
473
- inner: (args: ImapStoreArgs) => Promise<void>,
474
- retry: ImapRetryOptions = {},
475
- ): (args: ImapStoreArgs) => Promise<void> {
476
- const maxAttempts = Math.max(1, retry.maxAttempts ?? 3);
477
- const baseDelayMs = Math.max(0, retry.baseDelayMs ?? 250);
478
- const maxDelayMs = Math.max(baseDelayMs, retry.maxDelayMs ?? 2_000);
479
- const sleep = retry.sleep ?? defaultSleep;
480
-
481
- return async (args: ImapStoreArgs): Promise<void> => {
482
- let lastError: unknown;
483
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
484
- try {
485
- await inner(args);
486
- return;
487
- } catch (error) {
488
- lastError = error;
489
- if (attempt >= maxAttempts || !isTransientImapError(error)) throw error;
490
- const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
491
- await sleep(delay);
492
- }
493
- }
494
- throw lastError;
495
- };
496
- }
497
-
498
- /**
499
- * Reject CR, LF, NUL and any other ASCII control character (0x00-0x1F, 0x7F)
500
- * in a value destined for an IMAP command line. CR/LF are the dangerous ones:
501
- * an unescaped CRLF would terminate the current command and let an attacker
502
- * inject a second IMAP command (CRLF injection). IMAP's quoted-string syntax
503
- * has no escape for these control chars — backslash only escapes `\` and `"` —
504
- * so the only safe handling is to refuse the value outright.
505
- */
506
- function assertImapSafe(value: string, field: string): void {
507
- // eslint-disable-next-line no-control-regex -- intentional control-char guard
508
- if (/[\x00-\x1F\x7F]/.test(value)) {
509
- throw new ImapStoreError(
510
- `IMAP ${field} contains illegal control characters (possible CRLF injection)`,
511
- false,
512
- );
513
- }
514
- }
515
-
516
- /**
517
- * Wrap a value as an IMAP quoted string. Backslash and double-quote are
518
- * escaped per RFC 3501; control characters (CR/LF/NUL/...) are NOT escapable in
519
- * the quoted-string grammar, so callers MUST validate with assertImapSafe
520
- * first. quoteImap re-asserts as a defense-in-depth measure so no future caller
521
- * can bypass the guard.
522
- */
523
- function quoteImap(value: string): string {
524
- assertImapSafe(value, 'value');
525
- return `"${value.replace(/([\\"])/g, '\\$1')}"`;
526
- }
527
-
528
- // ---------------------------------------------------------------------------
529
- // Slack
530
- // ---------------------------------------------------------------------------
531
-
532
- async function applySlack(
533
- item: InboundChannelItem,
534
- tags: string[],
535
- providers: TaggerProviderConfig,
536
- credentials: DaemonCredentialStore,
537
- fetchImpl: typeof fetch,
538
- ctx: OperatorContext,
539
- base: ApplyTagsResult,
540
- ): Promise<ApplyTagsResult> {
541
- const cfg = providers.slack;
542
- if (!cfg) return { ...base, reason: 'slack-not-configured' };
543
- const channel = stringMeta(item, 'channelId') ?? item.conversationId;
544
- const ts = stringMeta(item, 'ts') ?? stringMeta(item, 'messageTs');
545
- if (!channel || !ts) return { ...base, reason: 'slack-missing-target' };
546
- if (tags.length === 0) return { ...base, reason: 'no-tags' };
547
-
548
- const token = await credentials.resolveConfigSecret(cfg.tokenConfigKey);
549
- if (!token) return { ...base, reason: 'slack-no-credentials' };
550
-
551
- const applied: string[] = [];
552
- for (const tag of tags) {
553
- const emoji = slackEmojiForTag(tag);
554
- const response = await fetchImpl('https://slack.com/api/reactions.add', {
555
- method: 'POST',
556
- headers: {
557
- Authorization: `Bearer ${token}`,
558
- 'Content-Type': 'application/json; charset=utf-8',
559
- },
560
- body: JSON.stringify({ channel, timestamp: ts, name: emoji }),
561
- });
562
- const payload = (await response.json().catch(() => ({}))) as {
563
- ok?: boolean;
564
- error?: string;
565
- };
566
- if (!response.ok) {
567
- throw new OperatorError(
568
- `Slack reactions.add HTTP ${response.status}`,
569
- 'TRIAGE_SLACK_TAG_FAILED',
570
- 502,
571
- );
572
- }
573
- // 'already_reacted' is an idempotent success for our purposes.
574
- if (payload.ok !== true && payload.error !== 'already_reacted') {
575
- ctx.logger.warn('triage: slack reaction rejected', { error: payload.error });
576
- throw new OperatorError(
577
- `Slack reactions.add rejected: ${payload.error ?? 'unknown'}`,
578
- 'TRIAGE_SLACK_TAG_FAILED',
579
- 502,
580
- );
581
- }
582
- applied.push(tag);
583
- }
584
- return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
585
- }
586
-
587
- function slackEmojiForTag(tag: string): string {
588
- const lower = tag.toLowerCase();
589
- if (lower.includes('spam')) return 'no_entry_sign';
590
- if (lower.includes('priority')) return 'rotating_light';
591
- return 'inbox_tray';
592
- }
593
-
594
- // ---------------------------------------------------------------------------
595
- // Discord
596
- // ---------------------------------------------------------------------------
597
-
598
- async function applyDiscord(
599
- item: InboundChannelItem,
600
- tags: string[],
601
- providers: TaggerProviderConfig,
602
- credentials: DaemonCredentialStore,
603
- fetchImpl: typeof fetch,
604
- ctx: OperatorContext,
605
- base: ApplyTagsResult,
606
- ): Promise<ApplyTagsResult> {
607
- const cfg = providers.discord;
608
- if (!cfg) return { ...base, reason: 'discord-not-configured' };
609
- const channelId = stringMeta(item, 'channelId') ?? item.conversationId;
610
- const messageId = stringMeta(item, 'messageId') ?? item.id;
611
- if (!channelId || !messageId) return { ...base, reason: 'discord-missing-target' };
612
- if (tags.length === 0) return { ...base, reason: 'no-tags' };
613
-
614
- const token = await credentials.resolveConfigSecret(cfg.tokenConfigKey);
615
- if (!token) return { ...base, reason: 'discord-no-credentials' };
616
-
617
- // Exact-fidelity path: when the item targets a forum/media-channel thread and
618
- // a forum-tag mapping resolves at least one tag, apply REAL Discord thread
619
- // tags (PATCH the thread's applied_tags) — this is the literal "Discord thread
620
- // tags" the contract specifies. A forum post's thread id is the thread's own
621
- // channel id; we accept an explicit metadata.threadId or fall back to
622
- // channelId for that case.
623
- const threadId = stringMeta(item, 'threadId') ?? channelId;
624
- const tagIds = resolveDiscordTagIds(tags, cfg.forumTagIds);
625
- if (tagIds.length > 0) {
626
- return applyDiscordThreadTags(item, threadId, tags, tagIds, token, fetchImpl, ctx);
627
- }
628
-
629
- // Analog fallback: Discord has no arbitrary per-message tags, so apply a
630
- // unicode reaction on the source message instead.
631
- const applied: string[] = [];
632
- for (const tag of tags) {
633
- const emoji = encodeURIComponent(discordEmojiForTag(tag));
634
- const url = `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/reactions/${emoji}/@me`;
635
- const response = await fetchImpl(url, {
636
- method: 'PUT',
637
- headers: {
638
- Authorization: `Bot ${token}`,
639
- 'Content-Length': '0',
640
- },
641
- });
642
- // 204 No Content is the success case for adding a reaction.
643
- if (response.status !== 204 && response.status !== 200) {
644
- const detail = await response.text().catch(() => '');
645
- ctx.logger.warn('triage: discord reaction rejected', { status: response.status });
646
- throw new OperatorError(
647
- `Discord reaction HTTP ${response.status}${detail ? `: ${detail.slice(0, 120)}` : ''}`,
648
- 'TRIAGE_DISCORD_TAG_FAILED',
649
- 502,
650
- );
651
- }
652
- applied.push(tag);
653
- }
654
- return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
655
- }
656
-
657
- /**
658
- * Map GoodVibes triage tags to configured Discord forum-tag snowflake ids,
659
- * preserving order and dropping unmapped tags. De-duplicates ids.
660
- */
661
- function resolveDiscordTagIds(
662
- tags: readonly string[],
663
- forumTagIds: Record<string, string> | undefined,
664
- ): string[] {
665
- if (!forumTagIds) return [];
666
- const ids: string[] = [];
667
- for (const tag of tags) {
668
- const id = forumTagIds[tag];
669
- if (typeof id === 'string' && id.length > 0 && !ids.includes(id)) ids.push(id);
670
- }
671
- return ids;
672
- }
673
-
674
- /**
675
- * Apply real Discord thread tags by merging the resolved forum-tag ids into the
676
- * thread's existing applied_tags via PATCH /channels/{thread.id}. Idempotent:
677
- * already-present tag ids are kept and not duplicated.
678
- */
679
- async function applyDiscordThreadTags(
680
- item: InboundChannelItem,
681
- threadId: string,
682
- tags: readonly string[],
683
- tagIds: readonly string[],
684
- token: string,
685
- fetchImpl: typeof fetch,
686
- ctx: OperatorContext,
687
- ): Promise<ApplyTagsResult> {
688
- const url = `https://discord.com/api/v10/channels/${threadId}`;
689
- // DATA-LOSS GUARD: we PATCH the thread's full applied_tags array, so we must
690
- // first read the EXISTING tags and merge. If that read fails (network error
691
- // OR a non-ok HTTP status), we have no idea what tags are currently on the
692
- // thread — PATCHing with only our new ids would silently destroy whatever
693
- // forum tags were already applied. Abort instead of overwriting.
694
- const existing = await fetchDiscordAppliedTags(url, token, fetchImpl, ctx);
695
- if (existing === null) {
696
- throw new OperatorError(
697
- 'Discord thread tag read failed; aborting to avoid overwriting existing applied_tags.',
698
- 'TRIAGE_DISCORD_TAG_FAILED',
699
- 502,
700
- );
701
- }
702
- const merged = [...new Set([...existing, ...tagIds])];
703
- const response = await fetchImpl(url, {
704
- method: 'PATCH',
705
- headers: {
706
- Authorization: `Bot ${token}`,
707
- 'Content-Type': 'application/json',
708
- },
709
- body: JSON.stringify({ applied_tags: merged }),
710
- });
711
- if (response.status !== 200 && response.status !== 204) {
712
- const detail = await response.text().catch(() => '');
713
- ctx.logger.warn('triage: discord thread tag rejected', { status: response.status });
714
- throw new OperatorError(
715
- `Discord thread tag HTTP ${response.status}${detail ? `: ${detail.slice(0, 120)}` : ''}`,
716
- 'TRIAGE_DISCORD_TAG_FAILED',
717
- 502,
718
- );
719
- }
720
- return { surface: item.surface, itemId: item.id, appliedTags: [...tags], skipped: false };
721
- }
722
-
723
- /**
724
- * Read a thread's current applied_tags.
725
- *
726
- * Returns:
727
- * - string[] -> the read SUCCEEDED; the array is the current applied_tags
728
- * (possibly empty when the thread genuinely has no tags, or
729
- * when applied_tags is absent/malformed in a 2xx body).
730
- * - null -> the read FAILED (thrown error OR non-ok HTTP status). The
731
- * caller MUST NOT proceed with a PATCH in this case, because a
732
- * failed read means the existing tag set is unknown and a
733
- * PATCH would overwrite it (data loss).
734
- *
735
- * A successful empty/absent applied_tags is deliberately distinguished from a
736
- * failed read: [] is safe to merge, null is not.
737
- */
738
- async function fetchDiscordAppliedTags(
739
- url: string,
740
- token: string,
741
- fetchImpl: typeof fetch,
742
- ctx: OperatorContext,
743
- ): Promise<string[] | null> {
744
- try {
745
- const response = await fetchImpl(url, {
746
- method: 'GET',
747
- headers: { Authorization: `Bot ${token}` },
748
- });
749
- if (!response.ok) {
750
- ctx.logger.warn('triage: discord thread tag read failed', { status: response.status });
751
- return null;
752
- }
753
- const payload = (await response.json().catch(() => null)) as
754
- | { applied_tags?: unknown }
755
- | null;
756
- const current = payload?.applied_tags;
757
- if (!Array.isArray(current)) return [];
758
- return current.filter((t): t is string => typeof t === 'string');
759
- } catch (error) {
760
- ctx.logger.warn('triage: discord thread tag read errored', {
761
- message: error instanceof Error ? error.message : String(error),
762
- });
763
- return null;
764
- }
765
- }
766
-
767
- function discordEmojiForTag(tag: string): string {
768
- const lower = tag.toLowerCase();
769
- if (lower.includes('spam')) return '🚫';
770
- if (lower.includes('priority')) return '🚨';
771
- return '📥';
772
- }
773
-
774
- function stringMeta(item: InboundChannelItem, key: string): string | undefined {
775
- const value = item.metadata?.[key];
776
- return typeof value === 'string' && value.length > 0 ? value : undefined;
777
- }