@pellux/goodvibes-tui 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +5 -3
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
  6. package/src/daemon/handlers/calendar/index.ts +316 -0
  7. package/src/daemon/handlers/context.ts +29 -0
  8. package/src/daemon/handlers/contracts.ts +77 -0
  9. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  10. package/src/daemon/handlers/drafts/index.ts +17 -0
  11. package/src/daemon/handlers/drafts/register.ts +331 -0
  12. package/src/daemon/handlers/email/config.ts +164 -0
  13. package/src/daemon/handlers/email/index.ts +43 -0
  14. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  15. package/src/daemon/handlers/email/runtime.ts +140 -0
  16. package/src/daemon/handlers/email/validation.ts +147 -0
  17. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  18. package/src/daemon/handlers/errors.ts +18 -0
  19. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  20. package/src/daemon/handlers/inbox/index.ts +210 -0
  21. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  22. package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
  23. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  24. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
  25. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  26. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
  27. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
  28. package/src/daemon/handlers/index.ts +107 -0
  29. package/src/daemon/handlers/register.ts +161 -0
  30. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  31. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  32. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  33. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  34. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  35. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  36. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  37. package/src/daemon/handlers/remote/index.ts +119 -0
  38. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  39. package/src/daemon/handlers/remote/service.ts +191 -0
  40. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  41. package/src/daemon/handlers/routing/index.ts +261 -0
  42. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  43. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  44. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  45. package/src/daemon/handlers/triage/index.ts +57 -0
  46. package/src/daemon/handlers/triage/integration.ts +212 -0
  47. package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
  48. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  49. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  50. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  51. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  52. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  53. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  54. package/src/daemon/handlers/triage/types.ts +50 -0
  55. package/src/runtime/services.ts +48 -35
  56. package/src/version.ts +1 -1
  57. package/src/daemon/calendar/index.ts +0 -52
  58. package/src/daemon/calendar/register.ts +0 -527
  59. package/src/daemon/channels/drafts/index.ts +0 -22
  60. package/src/daemon/channels/drafts/register.ts +0 -449
  61. package/src/daemon/channels/inbox/index.ts +0 -58
  62. package/src/daemon/channels/inbox/register.ts +0 -247
  63. package/src/daemon/channels/routing/index.ts +0 -39
  64. package/src/daemon/channels/routing/register.ts +0 -296
  65. package/src/daemon/email/index.ts +0 -68
  66. package/src/daemon/email/register.ts +0 -715
  67. package/src/daemon/operator/index.ts +0 -43
  68. package/src/daemon/operator/register-helper.ts +0 -150
  69. package/src/daemon/operator/surfaces.ts +0 -137
  70. package/src/daemon/operator/types.ts +0 -207
  71. package/src/daemon/remote/backends/index.ts +0 -34
  72. package/src/daemon/remote/index.ts +0 -74
  73. package/src/daemon/remote/register.ts +0 -411
  74. package/src/daemon/triage/index.ts +0 -59
  75. package/src/daemon/triage/integration.ts +0 -179
  76. package/src/daemon/triage/register.ts +0 -231
  77. package/src/daemon/triage/tagger.ts +0 -777
  78. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  79. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  80. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  81. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  82. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  83. /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
  84. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -0,0 +1,210 @@
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 reads the persisted feed (filtered by provider/limit/since) and
21
+ // maps the daemon-internal item shape onto the SDK CHANNEL_INBOX_ITEM_SCHEMA
22
+ // wire shape. The redacted `fromDigest` is the only sender value emitted (as
23
+ // `from`); raw sender ids and unredacted bodies never leave the daemon. The
24
+ // monotonic cursor advances to max(receivedAt) in the returned window.
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 { createSlackAdapter, SLACK_PROVIDER_ID } from './providers/slack.ts';
41
+ import { createDiscordAdapter, DISCORD_PROVIDER_ID } from './providers/discord.ts';
42
+ import { createEmailAdapter, EMAIL_PROVIDER_ID } from './providers/email.ts';
43
+
44
+ export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
45
+ const DEFAULT_LIMIT = 50;
46
+ const MAX_LIMIT = 500;
47
+
48
+ /** SDK `channels.inbox.list` input (single optional provider per the schema). */
49
+ export interface InboxListInput {
50
+ provider?: string;
51
+ limit?: number;
52
+ since?: number;
53
+ }
54
+
55
+ /** One item in the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
56
+ export interface ChannelInboxItem {
57
+ id: string;
58
+ provider: string;
59
+ kind: string;
60
+ /** Redacted sender token (sha256First, 16 hex). Never the raw id. */
61
+ from: string;
62
+ subject?: string;
63
+ bodyPreview: string;
64
+ receivedAt: number;
65
+ unread: boolean;
66
+ routeId?: string;
67
+ }
68
+
69
+ /** SDK `channels.inbox.list` output (objectSchema: items/total/truncated/cursor?). */
70
+ export interface InboxListOutput {
71
+ items: ChannelInboxItem[];
72
+ total: number;
73
+ truncated: boolean;
74
+ cursor?: string;
75
+ }
76
+
77
+ export interface RegisterInboxOptions {
78
+ /** Override the cursor-store filename (tests). */
79
+ storeFileName?: string;
80
+ /** Skip the initial seed poll (tests that drive polling manually). */
81
+ skipInitialPoll?: boolean;
82
+ /** Register the built-in slack/discord/email adapters (default true). */
83
+ registerBuiltins?: boolean;
84
+ }
85
+
86
+ function registerBuiltinAdapters(): void {
87
+ registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
88
+ registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
89
+ registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
90
+ }
91
+
92
+ function normalizeInput(body: unknown): { providers?: string[]; limit: number; since?: number } {
93
+ const input = (body ?? {}) as InboxListInput;
94
+ const provider = typeof input.provider === 'string' && input.provider.length > 0
95
+ ? input.provider
96
+ : undefined;
97
+ let limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
98
+ ? Math.floor(input.limit)
99
+ : DEFAULT_LIMIT;
100
+ limit = Math.min(Math.max(1, limit), MAX_LIMIT);
101
+ const since = typeof input.since === 'number' && Number.isFinite(input.since) && input.since >= 0
102
+ ? Math.floor(input.since)
103
+ : undefined;
104
+ return {
105
+ ...(provider ? { providers: [provider] } : {}),
106
+ limit,
107
+ ...(since !== undefined ? { since } : {}),
108
+ };
109
+ }
110
+
111
+ /** Map a daemon-internal item onto the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
112
+ function toWireItem(item: InboundChannelItem): ChannelInboxItem {
113
+ const wire: ChannelInboxItem = {
114
+ id: item.id,
115
+ provider: item.provider,
116
+ kind: item.kind,
117
+ from: item.fromDigest,
118
+ bodyPreview: item.bodyPreview,
119
+ receivedAt: item.receivedAt,
120
+ unread: item.unread,
121
+ };
122
+ if (item.subjectPreview.length > 0) wire.subject = item.subjectPreview;
123
+ if (item.routeId != null) wire.routeId = item.routeId;
124
+ return wire;
125
+ }
126
+
127
+ /**
128
+ * Bridge the routing surface's profile resolver into the adapter `RouteResolver`
129
+ * seam. Resolution is by provider surface (best-effort wildcard); a resolved
130
+ * profile id is surfaced as the item's routeId binding. Never throws.
131
+ */
132
+ function routeResolverFromRouting(routing: RoutingRegistration): RouteResolver {
133
+ return ({ provider }) => {
134
+ try {
135
+ return routing.resolveProfileId(provider) ?? undefined;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Register the inbox surface. Attaches the `channels.inbox.list` handler to the
144
+ * SDK catalog and returns an Unregister that detaches it, stops the poller, and
145
+ * closes the store.
146
+ */
147
+ export function registerInboxMethods(
148
+ ctx: HandlerContext,
149
+ routing?: RoutingRegistration,
150
+ options: RegisterInboxOptions = {},
151
+ ): Unregister {
152
+ if (options.registerBuiltins !== false) {
153
+ registerBuiltinAdapters();
154
+ }
155
+
156
+ const adapterContext: AdapterContext = {
157
+ credentials: ctx.credentials,
158
+ logger: ctx.logger,
159
+ ...(routing ? { resolveRouteId: routeResolverFromRouting(routing) } : {}),
160
+ };
161
+ const adapters = buildAdapters(adapterContext);
162
+ const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
163
+ const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
164
+
165
+ // Async bootstrap: init store, seed one poll, start loops. Failures are
166
+ // logged but never thrown out of register() — the handler still serves the
167
+ // (possibly empty) persisted feed.
168
+ const ready: Promise<void> = (async () => {
169
+ await store.init();
170
+ if (!options.skipInitialPoll) {
171
+ await poller.pollOnce();
172
+ }
173
+ poller.start();
174
+ })().catch((error: unknown) => {
175
+ ctx.logger.error('inbox surface bootstrap failed', {
176
+ error: error instanceof Error ? error.message : String(error),
177
+ });
178
+ });
179
+
180
+ const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
181
+ ctx.catalog,
182
+ INBOX_LIST_METHOD_ID,
183
+ async (invocation) => {
184
+ await ready;
185
+ const { providers, limit, since } = normalizeInput(invocation.body);
186
+ const internalItems = store.listItems({
187
+ ...(providers ? { providers } : {}),
188
+ ...(since !== undefined ? { since } : {}),
189
+ limit,
190
+ });
191
+ const items = internalItems.map(toWireItem);
192
+ const total = store.countItems(providers);
193
+ const truncated = internalItems.length >= limit && total > internalItems.length;
194
+ const maxReceived = store.maxReceivedAt(providers);
195
+ const nextSince = Math.max(since ?? 0, maxReceived);
196
+ const output: InboxListOutput = { items, total, truncated };
197
+ if (nextSince > 0) output.cursor = String(nextSince);
198
+ return output;
199
+ },
200
+ );
201
+
202
+ return () => {
203
+ try {
204
+ unregisterMethod();
205
+ } finally {
206
+ poller.stop();
207
+ void store.close();
208
+ }
209
+ };
210
+ }
@@ -3,23 +3,25 @@
3
3
  //
4
4
  // Rules (from the handoff contract):
5
5
  // - fromDigest = sha256First(senderExternalId, 16) (never the raw id)
6
- // (16 hex chars == the first 8 bytes of the SHA-256 digest;
7
- // handoff acceptance checklist: 'first 16 hex chars')
8
6
  // - bodyPreview = plain-text, PII-stripped, truncated to 500 chars
9
7
  // - subjectPreview <= 200 chars
10
8
  // These are pure and deterministic so they are unit-testable in isolation.
11
9
  // ---------------------------------------------------------------------------
12
10
 
13
- import { sha256First } from '../../operator/index.ts';
11
+ import { createHash } from 'node:crypto';
14
12
 
15
13
  export const SUBJECT_PREVIEW_MAX = 200;
16
14
  export const BODY_PREVIEW_MAX = 500;
17
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
+
18
22
  /**
19
23
  * Digest a sender's external id to a stable 16-hex-char token (the first 8
20
- * bytes of the SHA-256 digest). The handoff wire-shape note's 'SHA-256 first-8'
21
- * refers to those 8 bytes; the acceptance checklist states it as 'first 16 hex
22
- * chars'. Both describe the same value emitted here.
24
+ * bytes of the SHA-256 digest). The raw id is never emitted.
23
25
  */
24
26
  export function digestSender(senderExternalId: string): string {
25
27
  return sha256First(senderExternalId, 16);
@@ -5,7 +5,7 @@
5
5
  // Discord 30s, email 60s, others 120s). Each tick:
6
6
  // 1. resolves the provider's persisted cursor (nextSince)
7
7
  // 2. calls adapter.poll({ since, limit })
8
- // 3. dedups + persists items into the cursor store (INSERT OR IGNORE / upsert)
8
+ // 3. dedups + persists items into the cursor store (upsert)
9
9
  // 4. advances the cursor monotonically to max(receivedAt)
10
10
  // 5. records the last per-provider state for channels.inbox.list to report
11
11
  //
@@ -16,7 +16,7 @@
16
16
 
17
17
  import type { InboundProviderAdapter, ProviderState } from './provider-adapter.ts';
18
18
  import type { InboxCursorStore } from './cursor-store.ts';
19
- import type { OperatorLogger } from '../../operator/index.ts';
19
+ import type { HandlerLogger } from '../context.ts';
20
20
 
21
21
  export interface ProviderStatus {
22
22
  id: string;
@@ -29,7 +29,7 @@ export interface ProviderStatus {
29
29
  export interface PollerOptions {
30
30
  adapters: Map<string, InboundProviderAdapter>;
31
31
  store: InboxCursorStore;
32
- logger: OperatorLogger;
32
+ logger: HandlerLogger;
33
33
  /** Max items fetched per provider per tick. */
34
34
  perProviderLimit?: number;
35
35
  /** Inject a timer factory for tests (defaults to global setInterval). */
@@ -42,7 +42,7 @@ const DEFAULT_PER_PROVIDER_LIMIT = 50;
42
42
  export class InboundPoller {
43
43
  private readonly adapters: Map<string, InboundProviderAdapter>;
44
44
  private readonly store: InboxCursorStore;
45
- private readonly logger: OperatorLogger;
45
+ private readonly logger: HandlerLogger;
46
46
  private readonly perProviderLimit: number;
47
47
  private readonly setIntervalImpl: typeof setInterval;
48
48
  private readonly clearIntervalImpl: typeof clearInterval;
@@ -12,10 +12,15 @@
12
12
  // caller must be able to distinguish "configured-but-empty" from "not wired".
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- import type { DaemonCredentialStore } from '../../operator/index.ts';
16
- import type { OperatorLogger } from '../../operator/index.ts';
15
+ import type { DaemonCredentialStore } from '../credentials.ts';
16
+ import type { HandlerLogger } from '../context.ts';
17
17
 
18
- /** Wire-shape inbound item as published by `channels.inbox.list`. */
18
+ /**
19
+ * Daemon-internal inbound item. This is NOT the SDK wire shape — the inbox
20
+ * surface maps it onto the SDK `CHANNEL_INBOX_ITEM_SCHEMA` shape
21
+ * (`from`/`subject`/`bodyPreview`/...) before returning. `fromDigest` is the
22
+ * redacted sender (the only sender value that ever leaves the daemon).
23
+ */
19
24
  export interface InboundChannelItem {
20
25
  /** Stable, provider-scoped dedup key. Idempotent across polls. */
21
26
  id: string;
@@ -23,8 +28,7 @@ export interface InboundChannelItem {
23
28
  kind: 'dm' | 'thread' | 'mention' | 'reaction';
24
29
  /**
25
30
  * sha256First(senderExternalId, 16) — NEVER the raw id. 16 hex chars == the
26
- * first 8 bytes of the SHA-256 digest (handoff: 'first-8' bytes / acceptance
27
- * checklist: 'first 16 hex chars' — the same value).
31
+ * first 8 bytes of the SHA-256 digest.
28
32
  */
29
33
  fromDigest: string;
30
34
  /** <= 200 chars, safe for display. */
@@ -60,7 +64,7 @@ export interface ProviderPollOptions {
60
64
  /** Context handed to every adapter at construction time. */
61
65
  export interface AdapterContext {
62
66
  readonly credentials: DaemonCredentialStore;
63
- readonly logger: OperatorLogger;
67
+ readonly logger: HandlerLogger;
64
68
  /**
65
69
  * Optional route resolver supplied by the routing surface. Returns the route
66
70
  * binding id for a given inbound item, or undefined when no route matches /
@@ -70,8 +74,8 @@ export interface AdapterContext {
70
74
  }
71
75
 
72
76
  /**
73
- * Best-effort route resolution seam. The routing surface (concurrent work)
74
- * injects its `resolveProfile`-backed implementation. Until then it is
77
+ * Best-effort route resolution seam. The routing surface injects its
78
+ * profile-backed implementation via the inbox bridge. Until then it is
75
79
  * undefined and items carry no routeId.
76
80
  */
77
81
  export type RouteResolver = (input: {
@@ -127,8 +131,8 @@ export function registeredProviderIds(): string[] {
127
131
 
128
132
  /**
129
133
  * Construct adapters for the requested provider ids (or all registered ids when
130
- * `requested` is undefined/empty). Unknown ids are ignored by the caller via the
131
- * returned map only containing known providers.
134
+ * `requested` is undefined/empty). Unknown ids are dropped: the returned map
135
+ * only contains known providers.
132
136
  */
133
137
  export function buildAdapters(
134
138
  ctx: AdapterContext,
@@ -7,8 +7,7 @@
7
7
  // stateless, cadence-driven adapter.poll() that MUST resolve each call (see
8
8
  // provider-adapter.ts) — a long-lived socket the poller neither owns nor
9
9
  // supervises is out of scope for that contract. We therefore satisfy the
10
- // DM-polling goal (handoff acceptance checklist: "Daemon polls Discord DMs via
11
- // Discord Gateway") over Discord's supported request/response surface, the REST
10
+ // DM-polling goal over Discord's supported request/response surface, the REST
12
11
  // API — the same DM data the Gateway streams, fetched on the poll cadence and
13
12
  // paged so a busy DM is never truncated:
14
13
  // GET /users/@me/channels -> list DM channels the bot participates in
@@ -67,10 +66,10 @@ interface DiscordMessage {
67
66
  /**
68
67
  * Classify a Discord message into the InboundChannelItem `kind`.
69
68
  * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
70
- * event. This requires the message to be authored by us (its
71
- * author id is selfId) AND to carry a non-empty reactions[].
72
- * A message authored by someone else that merely carries
73
- * reactions[] is a normal DM, not a reaction event.
69
+ * event. Requires the message to be authored by us (its author id
70
+ * is selfId) AND to carry a non-empty reactions[]. A message
71
+ * authored by someone else that merely carries reactions[] is a
72
+ * normal DM, not a reaction event.
74
73
  * - mention: the message mentions us (our id is in mentions[])
75
74
  * - thread: the message is a reply (referenced_message present)
76
75
  * - dm: a plain direct message
@@ -201,10 +200,9 @@ export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapte
201
200
  if (opts.since && receivedAt <= opts.since) continue;
202
201
  const senderId = msg.author?.id ?? channel.id;
203
202
  // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
204
- // provider user id (see provider-adapter.ts InboundChannelItem and
205
- // handoff acceptance checklist 'first 16 hex chars'). Discord user
206
- // ids (snowflakes) are globally unique, and the item.id / provider
207
- // fields already namespace by provider.
203
+ // provider user id. Discord user ids (snowflakes) are globally
204
+ // unique, and the item.id / provider fields already namespace by
205
+ // provider.
208
206
  const fromDigest = digestSender(senderId);
209
207
  const kind = classifyDiscordKind(msg, selfId);
210
208
  const item: InboundChannelItem = {
@@ -72,7 +72,7 @@ export class ImapClient {
72
72
  await this.command(`LOGIN ${user} ${pass}`);
73
73
  }
74
74
 
75
- /** SELECT a mailbox (default INBOX). Returns the reported message count. */
75
+ /** SELECT a mailbox (default INBOX). */
76
76
  async select(mailbox = 'INBOX'): Promise<void> {
77
77
  await this.command(`SELECT ${quote(mailbox)}`);
78
78
  }
@@ -1,6 +1,6 @@
1
1
  // Shared best-effort route resolution wrapper used by all adapters.
2
- // Swallows resolver failures (routing is an optional, concurrent surface) so a
3
- // route lookup can never crash a provider poll.
2
+ // Swallows resolver failures (routing is an optional, concurrently-wired
3
+ // surface) so a route lookup can never crash a provider poll.
4
4
 
5
5
  import type { AdapterContext, InboundChannelItem } from '../provider-adapter.ts';
6
6
 
@@ -16,7 +16,7 @@
16
16
  // auth.test -> our own user id (for @-mention class.);
17
17
  // id is digested, never emitted raw.
18
18
  //
19
- // Credential: surfaces.slack.botToken (xoxb-...). Missing => 'unavailable'.
19
+ // Credential: surfaces.slack.botToken. Missing => 'unavailable'.
20
20
  // Cadence: 30s (realtime tier).
21
21
  // ---------------------------------------------------------------------------
22
22
 
@@ -76,15 +76,14 @@ interface SlackHistoryResponse {
76
76
  /**
77
77
  * Classify a Slack message into the InboundChannelItem `kind`.
78
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.
79
+ * event. Requires the message to be authored by us (its user id
80
+ * is selfUserId) AND to carry a non-empty reactions[]. A message
81
+ * authored by someone else that merely carries reactions[] is a
82
+ * normal DM, not a reaction event.
83
83
  * - mention: the message @-mentions us (text contains `<@SELF>`)
84
84
  * - thread: a threaded reply (thread_ts present and not the root ts)
85
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.
86
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
88
87
  */
89
88
  function classifySlackKind(
90
89
  msg: SlackMessage,
@@ -219,10 +218,9 @@ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter
219
218
  const receivedAt = tsToMs(msg.ts);
220
219
  if (opts.since && receivedAt <= opts.since) continue;
221
220
  // 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.
221
+ // provider user id. Slack user ids (U...) are unique within a
222
+ // workspace, and the item.id / provider fields already namespace
223
+ // by provider.
226
224
  const fromDigest = digestSender(senderId);
227
225
  const kind = classifySlackKind(msg, selfUserId);
228
226
  const item: InboundChannelItem = {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Composition root for the daemon handler layer — the only module that
3
+ * `src/runtime/services.ts` imports. It assembles every surface (routing,
4
+ * inbox+triage, drafts, calendar, email, remote) onto the SDK gateway catalog
5
+ * and returns a single teardown plus the cross-surface handles the runtime
6
+ * needs (routing resolver, the remote `DistributedRuntimeRouteService` the SDK
7
+ * facade injects, and the `remote.peers.invoke` dispatch adapter).
8
+ *
9
+ * Surfaces are provided by the caller as `SurfaceRegister` functions so this
10
+ * foundation module stays free of import cycles and does not author any SDK
11
+ * descriptor or schema. Registration order is fixed; teardown runs in reverse.
12
+ */
13
+ import type { DistributedRuntimeRouteService } from './contracts.ts';
14
+ import type { HandlerContext, SurfaceRegister } from './context.ts';
15
+ import type { Unregister } from './register.ts';
16
+
17
+ /** Routing surface handle: teardown plus the resolver other surfaces consume. */
18
+ export interface RoutingRegistration {
19
+ readonly unregister: Unregister;
20
+ /** Resolve an inbound channel target to a profile id (exact → wildcard → null). */
21
+ readonly resolveProfileId: (surfaceKind: string, routeId?: string) => string | null;
22
+ }
23
+
24
+ /** Adapter the runtime wires into the published `remote.peers.invoke` route. */
25
+ export interface RemoteInvokeAdapter {
26
+ invoke(input: Record<string, unknown>): Promise<unknown>;
27
+ }
28
+
29
+ /** Remote surface handle: the host service the SDK facade injects + its teardown. */
30
+ export interface RemoteSurfaceRegistration {
31
+ readonly unregister: Unregister;
32
+ readonly service: DistributedRuntimeRouteService;
33
+ readonly dispatch: RemoteInvokeAdapter;
34
+ }
35
+
36
+ /** Aggregate result returned to the runtime for teardown and cross-surface wiring. */
37
+ export interface DaemonHandlerSurfaces {
38
+ readonly unregister: Unregister;
39
+ readonly routing: RoutingRegistration;
40
+ readonly remoteSurface: { readonly service: DistributedRuntimeRouteService };
41
+ readonly remoteDispatch: RemoteInvokeAdapter;
42
+ }
43
+
44
+ /**
45
+ * The surface implementations the runtime supplies. Each builds its stores and
46
+ * attaches handlers to the SDK catalog via `registerCatalogHandler(s)`.
47
+ *
48
+ * Order of composition (and reverse teardown) is enforced by
49
+ * `registerDaemonHandlers`: routing → inbox(+triage) → drafts → calendar →
50
+ * email → remote.
51
+ */
52
+ export interface DaemonHandlerSurfaceProviders {
53
+ /** channels.routing.* — returns the resolver consumed by the inbox surface. */
54
+ readonly registerRouting: (ctx: HandlerContext) => RoutingRegistration;
55
+ /** channels.inbox.list (triage-decorated) + inbox.triage.* (daemon-internal). */
56
+ readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
57
+ /** channels.drafts.* */
58
+ readonly registerDrafts: SurfaceRegister;
59
+ /** calendar.* */
60
+ readonly registerCalendar: SurfaceRegister;
61
+ /** email.* */
62
+ readonly registerEmail: SurfaceRegister;
63
+ /** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
64
+ readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
65
+ }
66
+
67
+ /**
68
+ * Compose all daemon handler surfaces onto the gateway catalog held by `ctx`.
69
+ * Returns a single teardown (reverse order, best-effort) and the handles the
70
+ * runtime must expose: the routing registration, the remote service the SDK
71
+ * facade injects into `DaemonRemoteRouteContext.distributedRuntime`, and the
72
+ * remote invoke dispatch adapter.
73
+ */
74
+ export function registerDaemonHandlers(
75
+ ctx: HandlerContext,
76
+ providers: DaemonHandlerSurfaceProviders,
77
+ ): DaemonHandlerSurfaces {
78
+ const teardowns: Unregister[] = [];
79
+
80
+ const routing = providers.registerRouting(ctx);
81
+ teardowns.push(routing.unregister);
82
+
83
+ teardowns.push(providers.registerInbox(ctx, routing));
84
+ teardowns.push(providers.registerDrafts(ctx));
85
+ teardowns.push(providers.registerCalendar(ctx));
86
+ teardowns.push(providers.registerEmail(ctx));
87
+
88
+ const remote = providers.registerRemote(ctx);
89
+ teardowns.push(remote.unregister);
90
+
91
+ const unregister: Unregister = () => {
92
+ for (let i = teardowns.length - 1; i >= 0; i -= 1) {
93
+ try {
94
+ teardowns[i]!();
95
+ } catch (error) {
96
+ ctx.logger.warn('daemon handler surface teardown failed', { error });
97
+ }
98
+ }
99
+ };
100
+
101
+ return {
102
+ unregister,
103
+ routing,
104
+ remoteSurface: { service: remote.service },
105
+ remoteDispatch: remote.dispatch,
106
+ };
107
+ }