@pellux/goodvibes-tui 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -0,0 +1,264 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Slack inbound adapter.
3
+ //
4
+ // Transport note (contract fidelity): the daemon-handoff checklist names the
5
+ // Slack Events API / RTM for DM polling. Those are push transports (HTTP event
6
+ // callbacks / a persistent websocket) and cannot be driven by the inbound
7
+ // poller, whose contract is a stateless, cadence-driven adapter.poll() that
8
+ // MUST resolve each call (see provider-adapter.ts). A websocket/event-callback
9
+ // would require an inbound HTTP endpoint or a long-lived socket the poller
10
+ // neither owns nor supervises. We therefore satisfy the DM-polling goal over
11
+ // the supported request/response surface (the Slack Web API), which the Events
12
+ // API itself documents as the canonical pull-based equivalent for reading DM
13
+ // history on an interval:
14
+ // conversations.list (types=im) -> open DM channels (cursor-paginated)
15
+ // conversations.history -> recent messages per DM
16
+ // auth.test -> our own user id (for @-mention class.);
17
+ // id is digested, never emitted raw.
18
+ //
19
+ // Credential: surfaces.slack.botToken (xoxb-...). Missing => 'unavailable'.
20
+ // Cadence: 30s (realtime tier).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import type {
24
+ AdapterContext,
25
+ InboundChannelItem,
26
+ InboundProviderAdapter,
27
+ ProviderPollOptions,
28
+ ProviderPollResult,
29
+ } from '../provider-adapter.ts';
30
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
31
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
32
+ import { resolveRouteId } from './route-util.ts';
33
+
34
+ const SLACK_API = 'https://slack.com/api';
35
+ export const SLACK_PROVIDER_ID = 'slack';
36
+ export const SLACK_CREDENTIAL_KEY = 'surfaces.slack.botToken';
37
+
38
+ interface SlackConversationsListResponse {
39
+ ok: boolean;
40
+ error?: string;
41
+ channels?: Array<{ id: string; user?: string }>;
42
+ response_metadata?: { next_cursor?: string };
43
+ }
44
+
45
+ interface SlackAuthTestResponse {
46
+ ok: boolean;
47
+ error?: string;
48
+ user_id?: string;
49
+ }
50
+
51
+ interface SlackReaction {
52
+ name?: string;
53
+ count?: number;
54
+ users?: string[];
55
+ }
56
+
57
+ interface SlackMessage {
58
+ type?: string;
59
+ subtype?: string;
60
+ user?: string;
61
+ bot_id?: string;
62
+ text?: string;
63
+ ts?: string; // "1700000000.000200"
64
+ thread_ts?: string;
65
+ reactions?: SlackReaction[];
66
+ }
67
+
68
+ interface SlackHistoryResponse {
69
+ ok: boolean;
70
+ error?: string;
71
+ messages?: SlackMessage[];
72
+ has_more?: boolean;
73
+ response_metadata?: { next_cursor?: string };
74
+ }
75
+
76
+ /**
77
+ * Classify a Slack message into the InboundChannelItem `kind`.
78
+ * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
79
+ * event. This requires the message to be authored by us (its
80
+ * user id is selfUserId) AND to carry a non-empty reactions[].
81
+ * A message authored by someone else that merely carries
82
+ * reactions[] is a normal DM, not a reaction event.
83
+ * - mention: the message @-mentions us (text contains `<@SELF>`)
84
+ * - thread: a threaded reply (thread_ts present and not the root ts)
85
+ * - dm: a plain direct message
86
+ * Reaction takes precedence over mention/thread because a reaction is the most
87
+ * specific inbound signal; mention outranks thread/dm.
88
+ */
89
+ function classifySlackKind(
90
+ msg: SlackMessage,
91
+ selfUserId: string | undefined,
92
+ ): InboundChannelItem['kind'] {
93
+ if (
94
+ selfUserId &&
95
+ msg.user === selfUserId &&
96
+ Array.isArray(msg.reactions) &&
97
+ msg.reactions.length > 0
98
+ ) {
99
+ return 'reaction';
100
+ }
101
+ if (selfUserId && typeof msg.text === 'string' && msg.text.includes(`<@${selfUserId}>`)) {
102
+ return 'mention';
103
+ }
104
+ if (msg.thread_ts && msg.thread_ts !== msg.ts) return 'thread';
105
+ return 'dm';
106
+ }
107
+
108
+ /** Slack ts ("1700000000.000200") -> Unix ms. */
109
+ function tsToMs(ts: string | undefined): number {
110
+ if (!ts) return 0;
111
+ const seconds = Number.parseFloat(ts);
112
+ return Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0;
113
+ }
114
+
115
+ async function slackGet<T>(
116
+ token: string,
117
+ method: string,
118
+ params: Record<string, string>,
119
+ ): Promise<T> {
120
+ const url = new URL(`${SLACK_API}/${method}`);
121
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
122
+ const res = await fetch(url, {
123
+ method: 'GET',
124
+ headers: {
125
+ Authorization: `Bearer ${token}`,
126
+ Accept: 'application/json',
127
+ },
128
+ });
129
+ if (!res.ok) {
130
+ throw new Error(`Slack ${method} HTTP ${res.status}`);
131
+ }
132
+ return (await res.json()) as T;
133
+ }
134
+
135
+ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter {
136
+ return {
137
+ id: SLACK_PROVIDER_ID,
138
+ pollIntervalMs: POLL_CADENCE_MS.realtime,
139
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
140
+ let token: string | null;
141
+ try {
142
+ token = await ctx.credentials.resolveConfigSecret(SLACK_CREDENTIAL_KEY);
143
+ } catch (error) {
144
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
145
+ }
146
+ if (!token || token.trim().length === 0) {
147
+ return unavailable('missing surfaces.slack.botToken');
148
+ }
149
+ if (!token.startsWith('xoxb-') && !token.startsWith('xoxp-')) {
150
+ return unavailable('surfaces.slack.botToken is not a valid Slack bot/user token');
151
+ }
152
+
153
+ // Resolve our own user id so we can distinguish @-mentions of us from
154
+ // plain DMs. Best-effort: a failure here only means mentions are not
155
+ // separately classified, it never fails the provider.
156
+ let selfUserId: string | undefined;
157
+ try {
158
+ const auth = await slackGet<SlackAuthTestResponse>(token, 'auth.test', {});
159
+ if (auth.ok && auth.user_id) selfUserId = auth.user_id;
160
+ } catch (error) {
161
+ ctx.logger.warn('slack auth.test failed; mentions will not be classified', {
162
+ error: errMsg(error),
163
+ });
164
+ }
165
+
166
+ try {
167
+ // Page through ALL open DM channels. conversations.list returns at most
168
+ // `limit` channels per page and a response_metadata.next_cursor when more
169
+ // remain; without following the cursor, accounts with >100 open DMs would
170
+ // silently lose every channel past the first page. MAX_LIST_PAGES bounds
171
+ // the loop so a misbehaving cursor can never spin forever.
172
+ const MAX_LIST_PAGES = 50;
173
+ const channels: Array<{ id: string; user?: string }> = [];
174
+ let cursor: string | undefined;
175
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
176
+ const list = await slackGet<SlackConversationsListResponse>(token, 'conversations.list', {
177
+ types: 'im',
178
+ limit: '100',
179
+ ...(cursor ? { cursor } : {}),
180
+ });
181
+ if (!list.ok) {
182
+ return unavailable(`conversations.list: ${list.error ?? 'unknown_error'}`);
183
+ }
184
+ if (list.channels) channels.push(...list.channels);
185
+ const next = list.response_metadata?.next_cursor;
186
+ if (!next || next.length === 0) break;
187
+ cursor = next;
188
+ }
189
+ const oldest = opts.since ? (opts.since / 1000).toFixed(6) : undefined;
190
+ // Page through conversations.history per channel: a single call returns at
191
+ // most `limit` (<=50) messages and sets has_more + a next_cursor when a DM
192
+ // accrued more new messages than fit in one page. Without following the
193
+ // cursor a busy DM would silently drop everything past the first page.
194
+ // MAX_HISTORY_PAGES bounds the loop so a misbehaving cursor cannot spin.
195
+ const MAX_HISTORY_PAGES = 20;
196
+ const items: InboundChannelItem[] = [];
197
+ channelLoop: for (const channel of channels) {
198
+ if (items.length >= opts.limit) break;
199
+ let historyCursor: string | undefined;
200
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
201
+ const history = await slackGet<SlackHistoryResponse>(token, 'conversations.history', {
202
+ channel: channel.id,
203
+ limit: String(Math.min(opts.limit, 50)),
204
+ ...(oldest ? { oldest } : {}),
205
+ ...(historyCursor ? { cursor: historyCursor } : {}),
206
+ });
207
+ if (!history.ok) {
208
+ // Skip this DM but keep going; do not fail the whole provider.
209
+ ctx.logger.warn('slack conversations.history failed', {
210
+ channel: channel.id,
211
+ error: history.error,
212
+ });
213
+ continue channelLoop;
214
+ }
215
+ for (const msg of history.messages ?? []) {
216
+ if (items.length >= opts.limit) break;
217
+ if (msg.subtype === 'bot_message' || msg.bot_id) continue;
218
+ const senderId = msg.user ?? channel.user ?? channel.id;
219
+ const receivedAt = tsToMs(msg.ts);
220
+ if (opts.since && receivedAt <= opts.since) continue;
221
+ // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
222
+ // provider user id (see provider-adapter.ts InboundChannelItem and
223
+ // handoff acceptance checklist 'first 16 hex chars'). Slack user ids
224
+ // (U...) are unique within a workspace, and the item.id / provider
225
+ // fields already namespace by provider.
226
+ const fromDigest = digestSender(senderId);
227
+ const kind = classifySlackKind(msg, selfUserId);
228
+ const item: InboundChannelItem = {
229
+ id: `slack:${channel.id}:${msg.ts ?? String(receivedAt)}`,
230
+ provider: SLACK_PROVIDER_ID,
231
+ kind,
232
+ fromDigest,
233
+ subjectPreview: toSubjectPreview(`Direct message`),
234
+ bodyPreview: toBodyPreview(msg.text),
235
+ receivedAt,
236
+ unread: true,
237
+ };
238
+ const routeId = await resolveRouteId(ctx, SLACK_PROVIDER_ID, fromDigest, kind);
239
+ if (routeId) item.routeId = routeId;
240
+ items.push(item);
241
+ }
242
+ // Advance to the next history page only while the channel reported
243
+ // more messages AND we still have item budget left.
244
+ const nextHistory = history.response_metadata?.next_cursor;
245
+ if (!history.has_more || !nextHistory || nextHistory.length === 0) break;
246
+ if (items.length >= opts.limit) break;
247
+ historyCursor = nextHistory;
248
+ }
249
+ }
250
+ return { items, state: items.length > 0 ? 'ready' : 'empty' };
251
+ } catch (error) {
252
+ return unavailable(errMsg(error));
253
+ }
254
+ },
255
+ };
256
+ }
257
+
258
+ function unavailable(error: string): ProviderPollResult {
259
+ return { items: [], state: 'unavailable', error };
260
+ }
261
+
262
+ function errMsg(error: unknown): string {
263
+ return error instanceof Error ? error.message : String(error);
264
+ }
@@ -0,0 +1,247 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Surface register: `channels.inbox.list`.
3
+ //
4
+ // register(ctx) =>
5
+ // 1. registers built-in provider adapter factories (slack/discord/email)
6
+ // 2. constructs adapters with a daemon credential store
7
+ // 3. opens the inbox cursor store and seeds an initial poll
8
+ // 4. starts the per-provider polling loops
9
+ // 5. publishes the read-only `channels.inbox.list` operator method
10
+ // 6. returns an Unregister that stops the poller, closes the store, and
11
+ // removes the method.
12
+ //
13
+ // `channels.inbox.list` is READ-ONLY (no confirm). It reads the persisted feed
14
+ // (filtered by providers/limit/since) and reports per-provider state from the
15
+ // live poller status. nextSince advances monotonically (max receivedAt in the
16
+ // returned window, never below the requested `since`).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import {
20
+ createDaemonCredentialStore,
21
+ declareOperatorMethod,
22
+ } from '../../operator/index.ts';
23
+ import type {
24
+ OperatorContext,
25
+ OperatorInvocation,
26
+ SurfaceRegister,
27
+ Unregister,
28
+ } from '../../operator/index.ts';
29
+ import {
30
+ buildAdapters,
31
+ registerAdapterFactory,
32
+ type AdapterContext,
33
+ type InboundChannelItem,
34
+ type RouteResolver,
35
+ } from './provider-adapter.ts';
36
+ import { InboxCursorStore } from './cursor-store.ts';
37
+ import { InboundPoller, type ProviderStatus } from './poller.ts';
38
+ import { createSlackAdapter, SLACK_PROVIDER_ID } from './providers/slack.ts';
39
+ import { createDiscordAdapter, DISCORD_PROVIDER_ID } from './providers/discord.ts';
40
+ import { createEmailAdapter, EMAIL_PROVIDER_ID } from './providers/email.ts';
41
+
42
+ export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
43
+ export const INBOX_LIST_SCOPES = ['channels:inbox:read'];
44
+ const DEFAULT_LIMIT = 50;
45
+ const MAX_LIMIT = 500;
46
+
47
+ export interface InboxListInput {
48
+ providers?: string[];
49
+ limit?: number;
50
+ since?: number;
51
+ }
52
+
53
+ export interface InboxProviderReport {
54
+ id: string;
55
+ state: ProviderStatus['state'];
56
+ itemCount: number;
57
+ error?: string;
58
+ }
59
+
60
+ export interface InboxListOutput {
61
+ items: InboundChannelItem[];
62
+ nextSince: number;
63
+ providers: InboxProviderReport[];
64
+ }
65
+
66
+ export interface RegisterInboxOptions {
67
+ /** Route resolver injected by the routing surface (optional, best-effort). */
68
+ resolveRouteId?: RouteResolver;
69
+ /** Override the cursor-store filename (tests). */
70
+ storeFileName?: string;
71
+ /** Skip the initial seed poll (tests that drive polling manually). */
72
+ skipInitialPoll?: boolean;
73
+ /** Replace the default built-in adapter set (tests). */
74
+ registerBuiltins?: boolean;
75
+ }
76
+
77
+ function registerBuiltinAdapters(): void {
78
+ registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
79
+ registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
80
+ registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
81
+ }
82
+
83
+ const INBOX_LIST_OUTPUT_SCHEMA: Record<string, unknown> = {
84
+ type: 'object',
85
+ required: ['items', 'nextSince', 'providers'],
86
+ properties: {
87
+ items: {
88
+ type: 'array',
89
+ items: {
90
+ type: 'object',
91
+ required: [
92
+ 'id', 'provider', 'kind', 'fromDigest',
93
+ 'subjectPreview', 'bodyPreview', 'receivedAt', 'unread',
94
+ ],
95
+ properties: {
96
+ id: { type: 'string' },
97
+ provider: { type: 'string' },
98
+ kind: { type: 'string', enum: ['dm', 'thread', 'mention', 'reaction'] },
99
+ fromDigest: { type: 'string' },
100
+ subjectPreview: { type: 'string', maxLength: 200 },
101
+ bodyPreview: { type: 'string', maxLength: 500 },
102
+ routeId: { type: 'string' },
103
+ receivedAt: { type: 'number' },
104
+ unread: { type: 'boolean' },
105
+ triageScore: { type: 'number' },
106
+ triageTags: { type: 'array', items: { type: 'string' } },
107
+ },
108
+ },
109
+ },
110
+ nextSince: { type: 'number' },
111
+ providers: {
112
+ type: 'array',
113
+ items: {
114
+ type: 'object',
115
+ required: ['id', 'state', 'itemCount'],
116
+ properties: {
117
+ id: { type: 'string' },
118
+ state: { type: 'string', enum: ['ready', 'unavailable', 'empty'] },
119
+ itemCount: { type: 'number' },
120
+ error: { type: 'string' },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ };
126
+
127
+ const INBOX_LIST_INPUT_SCHEMA: Record<string, unknown> = {
128
+ type: 'object',
129
+ properties: {
130
+ providers: { type: 'array', items: { type: 'string' } },
131
+ limit: { type: 'number', minimum: 1, maximum: MAX_LIMIT },
132
+ since: { type: 'number', minimum: 0 },
133
+ },
134
+ };
135
+
136
+ function normalizeInput(body: unknown): { providers?: string[]; limit: number; since?: number } {
137
+ const input = (body ?? {}) as InboxListInput;
138
+ const providers = Array.isArray(input.providers)
139
+ ? input.providers.filter((p): p is string => typeof p === 'string' && p.length > 0)
140
+ : undefined;
141
+ let limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
142
+ ? Math.floor(input.limit)
143
+ : DEFAULT_LIMIT;
144
+ limit = Math.min(Math.max(1, limit), MAX_LIMIT);
145
+ const since = typeof input.since === 'number' && Number.isFinite(input.since) && input.since >= 0
146
+ ? Math.floor(input.since)
147
+ : undefined;
148
+ return {
149
+ ...(providers && providers.length > 0 ? { providers } : {}),
150
+ limit,
151
+ ...(since !== undefined ? { since } : {}),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Register the inbox surface. Returns an Unregister that tears down the poller,
157
+ * closes the store, and removes the operator method.
158
+ */
159
+ export function registerInboxMethods(
160
+ ctx: OperatorContext,
161
+ options: RegisterInboxOptions = {},
162
+ ): Unregister {
163
+ if (options.registerBuiltins !== false) {
164
+ registerBuiltinAdapters();
165
+ }
166
+
167
+ const credentials = createDaemonCredentialStore(ctx.secrets);
168
+ const adapterContext: AdapterContext = {
169
+ credentials,
170
+ logger: ctx.logger,
171
+ ...(options.resolveRouteId ? { resolveRouteId: options.resolveRouteId } : {}),
172
+ };
173
+ const adapters = buildAdapters(adapterContext);
174
+ const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
175
+ const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
176
+
177
+ // Async bootstrap: init store, seed one poll, start loops. Failures are
178
+ // logged but never thrown out of register() — the method still serves the
179
+ // (possibly empty) persisted feed and reports provider states.
180
+ const ready: Promise<void> = (async () => {
181
+ await store.init();
182
+ if (!options.skipInitialPoll) {
183
+ await poller.pollOnce();
184
+ }
185
+ poller.start();
186
+ })().catch((error: unknown) => {
187
+ ctx.logger.error('inbox surface bootstrap failed', {
188
+ error: error instanceof Error ? error.message : String(error),
189
+ });
190
+ });
191
+
192
+ const handler = async (
193
+ invocation: OperatorInvocation<InboxListInput>,
194
+ ): Promise<InboxListOutput> => {
195
+ await ready;
196
+ const { providers, limit, since } = normalizeInput(invocation.body);
197
+ const items = store.listItems({ ...(providers ? { providers } : {}), ...(since !== undefined ? { since } : {}), limit });
198
+ const maxReceived = store.maxReceivedAt(providers);
199
+ const nextSince = Math.max(since ?? 0, maxReceived);
200
+ const statuses = poller.snapshotStatuses(providers);
201
+ const reports: InboxProviderReport[] = statuses.map((status) => ({
202
+ id: status.id,
203
+ state: status.state,
204
+ itemCount: status.itemCount,
205
+ ...(status.error ? { error: status.error } : {}),
206
+ }));
207
+ return { items, nextSince, providers: reports };
208
+ };
209
+
210
+ const unregisterMethod = declareOperatorMethod<InboxListInput, InboxListOutput>(
211
+ ctx,
212
+ {
213
+ id: INBOX_LIST_METHOD_ID,
214
+ title: 'List inbound channel items',
215
+ description:
216
+ 'Aggregated, deduplicated inbound feed (DMs/threads/mentions) across '
217
+ + 'configured providers. Read-only; advances a monotonic nextSince cursor.',
218
+ category: 'channels',
219
+ source: 'daemon',
220
+ access: 'operator',
221
+ transport: ['ws', 'internal'],
222
+ scopes: INBOX_LIST_SCOPES,
223
+ effect: 'read-only',
224
+ confirm: false,
225
+ inputSchema: INBOX_LIST_INPUT_SCHEMA,
226
+ outputSchema: INBOX_LIST_OUTPUT_SCHEMA,
227
+ },
228
+ handler,
229
+ );
230
+
231
+ return () => {
232
+ try {
233
+ unregisterMethod();
234
+ } finally {
235
+ poller.stop();
236
+ void store.close();
237
+ }
238
+ };
239
+ }
240
+
241
+ /**
242
+ * SurfaceRegister contract entry point. Integration wires the inbox surface by
243
+ * calling this from its surface bootstrap (the same mechanism every other
244
+ * daemon surface uses, e.g. routing/triage), retaining the returned Unregister
245
+ * for teardown. This is the production caller of `registerInboxMethods`.
246
+ */
247
+ export const register: SurfaceRegister = (ctx) => registerInboxMethods(ctx);
@@ -0,0 +1,58 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbox <-> Routing resolver bridge.
3
+ //
4
+ // The inbox provider adapters expose a best-effort `RouteResolver` seam
5
+ // (provider-adapter.ts): given an inbound item's `{ provider, fromDigest, kind }`
6
+ // they ask "which daemon route binding handles this item?" and stamp the answer
7
+ // onto `item.routeId` when one resolves. The routing control plane owns the
8
+ // channel<->profile bindings and exposes a `RoutingResolver` whose
9
+ // `getProfileForChannel(surfaceKind, routeId?)` applies the canonical resolution
10
+ // order:
11
+ //
12
+ // 1. exact — surfaceKind AND routeId both match
13
+ // 2. surface — surfaceKind matches, route has no routeId
14
+ // 3. wildcard — a route with surfaceKind === 'any' (no routeId)
15
+ //
16
+ // This module bridges the two shapes. The bridge maps an inbound item's
17
+ // `provider` to the routing `surfaceKind` and forwards an optional `routeId`
18
+ // refinement; `fromDigest`/`kind` are NOT used as routing keys (the routing
19
+ // store is keyed by surfaceKind + optional routeId only, never by sender digest
20
+ // or message kind — see RoutingChannelRoute). When no binding matches the
21
+ // resolver returns null and the bridge yields `undefined`, so the adapter falls
22
+ // back to leaving the item unrouted (offline/default behaviour intact). The
23
+ // bridge NEVER throws: route-util.ts already swallows resolver errors, and the
24
+ // underlying resolver is a pure in-memory lookup.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ import type { RouteResolver } from '../inbox/provider-adapter.ts';
28
+ import type { RoutingResolver } from './routing-resolver.ts';
29
+
30
+ /**
31
+ * Optional input fields the bridge consults for routeId refinement. The
32
+ * published {@link RouteResolver} input does not currently carry a routeId, so
33
+ * surface-only / wildcard resolution is the effective path today; should the
34
+ * inbox seam grow a routeId (e.g. a Slack channel id or email mailbox tag) the
35
+ * bridge will pass it through to enable exact (surfaceKind+routeId) matches
36
+ * without any further change here.
37
+ */
38
+ type RouteResolverInput = Parameters<RouteResolver>[0] & { routeId?: unknown };
39
+
40
+ /**
41
+ * Build a {@link RouteResolver} backed by the routing surface's
42
+ * {@link RoutingResolver}. Maps `provider -> surfaceKind` and forwards an
43
+ * optional `routeId` refinement, then applies the canonical
44
+ * exact > surface-only > wildcard resolution order via
45
+ * `getProfileForChannel`. Returns the resolved profileId (the daemon route
46
+ * binding) or `undefined` when nothing matches.
47
+ */
48
+ export function createInboxRouteResolver(resolver: RoutingResolver): RouteResolver {
49
+ return (input: RouteResolverInput): string | undefined => {
50
+ const surfaceKind = typeof input.provider === 'string' ? input.provider : '';
51
+ if (surfaceKind.length === 0) return undefined;
52
+ const routeId = typeof input.routeId === 'string' && input.routeId.length > 0 ? input.routeId : undefined;
53
+ // getProfileForChannel applies exact (surfaceKind+routeId) > surface-only >
54
+ // wildcard ('any') and returns null when no assignment matches.
55
+ const profileId = resolver.getProfileForChannel(surfaceKind, routeId);
56
+ return profileId ?? undefined;
57
+ };
58
+ }
@@ -0,0 +1,39 @@
1
+ // Channel-to-Profile Routing Control Plane — surface barrel.
2
+ //
3
+ // Integration wires this surface by calling `register(ctx)` (or
4
+ // `registerRoutingMethods(ctx)` when it needs the returned store/resolver).
5
+ // The inbox surface reuses `resolveProfile` / `createRoutingResolver` for
6
+ // identical online/offline routing resolution.
7
+
8
+ export { register, registerRoutingMethods } from './register.ts';
9
+ export type {
10
+ RoutingRegistration,
11
+ RoutingListInput,
12
+ RoutingListOutput,
13
+ RoutingAssignInput,
14
+ RoutingAssignOutput,
15
+ RoutingDeleteInput,
16
+ RoutingDeleteOutput,
17
+ } from './register.ts';
18
+
19
+ export {
20
+ RouteStore,
21
+ parseChannelId,
22
+ buildChannelId,
23
+ } from './route-store.ts';
24
+ export type {
25
+ RoutingChannelRoute,
26
+ ParsedChannelId,
27
+ RouteUpsertInput,
28
+ RouteUpsertResult,
29
+ RouteListFilter,
30
+ } from './route-store.ts';
31
+
32
+ export {
33
+ resolveProfile,
34
+ createRoutingResolver,
35
+ WILDCARD_SURFACE,
36
+ } from './routing-resolver.ts';
37
+ export type { RoutingResolver } from './routing-resolver.ts';
38
+
39
+ export { createInboxRouteResolver } from './inbox-bridge.ts';