@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
@@ -0,0 +1,211 @@
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
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
44
+
45
+ export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
46
+ const DEFAULT_LIMIT = 50;
47
+ const MAX_LIMIT = 500;
48
+
49
+ /** SDK `channels.inbox.list` input (single optional provider per the schema). */
50
+ export interface InboxListInput {
51
+ provider?: string;
52
+ limit?: number;
53
+ since?: number;
54
+ }
55
+
56
+ /** One item in the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
57
+ export interface ChannelInboxItem {
58
+ id: string;
59
+ provider: string;
60
+ kind: string;
61
+ /** Redacted sender token (sha256First, 16 hex). Never the raw id. */
62
+ from: string;
63
+ subject?: string;
64
+ bodyPreview: string;
65
+ receivedAt: number;
66
+ unread: boolean;
67
+ routeId?: string;
68
+ }
69
+
70
+ /** SDK `channels.inbox.list` output (objectSchema: items/total/truncated/cursor?). */
71
+ export interface InboxListOutput {
72
+ items: ChannelInboxItem[];
73
+ total: number;
74
+ truncated: boolean;
75
+ cursor?: string;
76
+ }
77
+
78
+ export interface RegisterInboxOptions {
79
+ /** Override the cursor-store filename (tests). */
80
+ storeFileName?: string;
81
+ /** Skip the initial seed poll (tests that drive polling manually). */
82
+ skipInitialPoll?: boolean;
83
+ /** Register the built-in slack/discord/email adapters (default true). */
84
+ registerBuiltins?: boolean;
85
+ }
86
+
87
+ function registerBuiltinAdapters(): void {
88
+ registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
89
+ registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
90
+ registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
91
+ }
92
+
93
+ function normalizeInput(body: unknown): { providers?: string[]; limit: number; since?: number } {
94
+ const input = (body ?? {}) as InboxListInput;
95
+ const provider = typeof input.provider === 'string' && input.provider.length > 0
96
+ ? input.provider
97
+ : undefined;
98
+ let limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
99
+ ? Math.floor(input.limit)
100
+ : DEFAULT_LIMIT;
101
+ limit = Math.min(Math.max(1, limit), MAX_LIMIT);
102
+ const since = typeof input.since === 'number' && Number.isFinite(input.since) && input.since >= 0
103
+ ? Math.floor(input.since)
104
+ : undefined;
105
+ return {
106
+ ...(provider ? { providers: [provider] } : {}),
107
+ limit,
108
+ ...(since !== undefined ? { since } : {}),
109
+ };
110
+ }
111
+
112
+ /** Map a daemon-internal item onto the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
113
+ function toWireItem(item: InboundChannelItem): ChannelInboxItem {
114
+ const wire: ChannelInboxItem = {
115
+ id: item.id,
116
+ provider: item.provider,
117
+ kind: item.kind,
118
+ from: item.fromDigest,
119
+ bodyPreview: item.bodyPreview,
120
+ receivedAt: item.receivedAt,
121
+ unread: item.unread,
122
+ };
123
+ if (item.subjectPreview.length > 0) wire.subject = item.subjectPreview;
124
+ if (item.routeId != null) wire.routeId = item.routeId;
125
+ return wire;
126
+ }
127
+
128
+ /**
129
+ * Bridge the routing surface's profile resolver into the adapter `RouteResolver`
130
+ * seam. Resolution is by provider surface (best-effort wildcard); a resolved
131
+ * profile id is surfaced as the item's routeId binding. Never throws.
132
+ */
133
+ function routeResolverFromRouting(routing: RoutingRegistration): RouteResolver {
134
+ return ({ provider }) => {
135
+ try {
136
+ return routing.resolveProfileId(provider) ?? undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Register the inbox surface. Attaches the `channels.inbox.list` handler to the
145
+ * SDK catalog and returns an Unregister that detaches it, stops the poller, and
146
+ * closes the store.
147
+ */
148
+ export function registerInboxMethods(
149
+ ctx: HandlerContext,
150
+ routing?: RoutingRegistration,
151
+ options: RegisterInboxOptions = {},
152
+ ): Unregister {
153
+ if (options.registerBuiltins !== false) {
154
+ registerBuiltinAdapters();
155
+ }
156
+
157
+ const adapterContext: AdapterContext = {
158
+ credentials: ctx.credentials,
159
+ logger: ctx.logger,
160
+ ...(routing ? { resolveRouteId: routeResolverFromRouting(routing) } : {}),
161
+ };
162
+ const adapters = buildAdapters(adapterContext);
163
+ const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
164
+ const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
165
+
166
+ // Async bootstrap: init store, seed one poll, start loops. Failures are
167
+ // logged but never thrown out of register() — the handler still serves the
168
+ // (possibly empty) persisted feed.
169
+ const ready: Promise<void> = (async () => {
170
+ await store.init();
171
+ if (!options.skipInitialPoll) {
172
+ await poller.pollOnce();
173
+ }
174
+ poller.start();
175
+ })().catch((error: unknown) => {
176
+ ctx.logger.error('inbox surface bootstrap failed', {
177
+ error: summarizeError(error),
178
+ });
179
+ });
180
+
181
+ const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
182
+ ctx.catalog,
183
+ INBOX_LIST_METHOD_ID,
184
+ async (invocation) => {
185
+ await ready;
186
+ const { providers, limit, since } = normalizeInput(invocation.body);
187
+ const internalItems = store.listItems({
188
+ ...(providers ? { providers } : {}),
189
+ ...(since !== undefined ? { since } : {}),
190
+ limit,
191
+ });
192
+ const items = internalItems.map(toWireItem);
193
+ const total = store.countItems(providers);
194
+ const truncated = internalItems.length >= limit && total > internalItems.length;
195
+ const maxReceived = store.maxReceivedAt(providers);
196
+ const nextSince = Math.max(since ?? 0, maxReceived);
197
+ const output: InboxListOutput = { items, total, truncated };
198
+ if (nextSince > 0) output.cursor = String(nextSince);
199
+ return output;
200
+ },
201
+ );
202
+
203
+ return () => {
204
+ try {
205
+ unregisterMethod();
206
+ } finally {
207
+ poller.stop();
208
+ void store.close();
209
+ }
210
+ };
211
+ }
@@ -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,8 @@
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
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
20
21
 
21
22
  export interface ProviderStatus {
22
23
  id: string;
@@ -29,7 +30,7 @@ export interface ProviderStatus {
29
30
  export interface PollerOptions {
30
31
  adapters: Map<string, InboundProviderAdapter>;
31
32
  store: InboxCursorStore;
32
- logger: OperatorLogger;
33
+ logger: HandlerLogger;
33
34
  /** Max items fetched per provider per tick. */
34
35
  perProviderLimit?: number;
35
36
  /** Inject a timer factory for tests (defaults to global setInterval). */
@@ -42,7 +43,7 @@ const DEFAULT_PER_PROVIDER_LIMIT = 50;
42
43
  export class InboundPoller {
43
44
  private readonly adapters: Map<string, InboundProviderAdapter>;
44
45
  private readonly store: InboxCursorStore;
45
- private readonly logger: OperatorLogger;
46
+ private readonly logger: HandlerLogger;
46
47
  private readonly perProviderLimit: number;
47
48
  private readonly setIntervalImpl: typeof setInterval;
48
49
  private readonly clearIntervalImpl: typeof clearInterval;
@@ -115,7 +116,7 @@ export class InboundPoller {
115
116
  lastPolledAt: Date.now(),
116
117
  });
117
118
  } catch (error) {
118
- const message = error instanceof Error ? error.message : String(error);
119
+ const message = summarizeError(error);
119
120
  this.logger.warn('inbound poll failed', { provider: id, error: message });
120
121
  this.setStatus(id, {
121
122
  id,
@@ -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
@@ -32,6 +31,7 @@ import type {
32
31
  import { POLL_CADENCE_MS } from '../provider-adapter.ts';
33
32
  import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
34
33
  import { resolveRouteId } from './route-util.ts';
34
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
35
 
36
36
  const DISCORD_API = 'https://discord.com/api/v10';
37
37
  // Discord epoch (2015-01-01T00:00:00Z) in ms, used to decode snowflakes.
@@ -67,10 +67,10 @@ interface DiscordMessage {
67
67
  /**
68
68
  * Classify a Discord message into the InboundChannelItem `kind`.
69
69
  * - 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.
70
+ * event. Requires the message to be authored by us (its author id
71
+ * is selfId) AND to carry a non-empty reactions[]. A message
72
+ * authored by someone else that merely carries reactions[] is a
73
+ * normal DM, not a reaction event.
74
74
  * - mention: the message mentions us (our id is in mentions[])
75
75
  * - thread: the message is a reply (referenced_message present)
76
76
  * - dm: a plain direct message
@@ -201,10 +201,9 @@ export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapte
201
201
  if (opts.since && receivedAt <= opts.since) continue;
202
202
  const senderId = msg.author?.id ?? channel.id;
203
203
  // 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.
204
+ // provider user id. Discord user ids (snowflakes) are globally
205
+ // unique, and the item.id / provider fields already namespace by
206
+ // provider.
208
207
  const fromDigest = digestSender(senderId);
209
208
  const kind = classifyDiscordKind(msg, selfId);
210
209
  const item: InboundChannelItem = {
@@ -249,5 +248,5 @@ function unavailable(error: string): ProviderPollResult {
249
248
  }
250
249
 
251
250
  function errMsg(error: unknown): string {
252
- return error instanceof Error ? error.message : String(error);
251
+ return summarizeError(error);
253
252
  }
@@ -25,6 +25,7 @@ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
25
25
  import { resolveRouteId } from './route-util.ts';
26
26
  import { ImapClient } from './imap-client.ts';
27
27
  import type { ImapConfig, ImapEnvelope } from './imap-client.ts';
28
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
28
29
 
29
30
  export const EMAIL_PROVIDER_ID = 'email';
30
31
  export const EMAIL_HOST_KEY = 'surfaces.email.imapHost';
@@ -147,5 +148,5 @@ function unavailable(error: string): ProviderPollResult {
147
148
  }
148
149
 
149
150
  function errMsg(error: unknown): string {
150
- return error instanceof Error ? error.message : String(error);
151
+ return summarizeError(error);
151
152
  }
@@ -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,8 +1,9 @@
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
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
7
 
7
8
  export async function resolveRouteId(
8
9
  ctx: AdapterContext,
@@ -16,7 +17,7 @@ export async function resolveRouteId(
16
17
  } catch (error) {
17
18
  ctx.logger.warn('route resolution failed', {
18
19
  provider,
19
- error: error instanceof Error ? error.message : String(error),
20
+ error: summarizeError(error),
20
21
  });
21
22
  return undefined;
22
23
  }
@@ -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
 
@@ -30,6 +30,7 @@ import type {
30
30
  import { POLL_CADENCE_MS } from '../provider-adapter.ts';
31
31
  import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
32
32
  import { resolveRouteId } from './route-util.ts';
33
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
33
34
 
34
35
  const SLACK_API = 'https://slack.com/api';
35
36
  export const SLACK_PROVIDER_ID = 'slack';
@@ -76,15 +77,14 @@ interface SlackHistoryResponse {
76
77
  /**
77
78
  * Classify a Slack message into the InboundChannelItem `kind`.
78
79
  * - 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.
80
+ * event. Requires the message to be authored by us (its user id
81
+ * is selfUserId) AND to carry a non-empty reactions[]. A message
82
+ * authored by someone else that merely carries reactions[] is a
83
+ * normal DM, not a reaction event.
83
84
  * - mention: the message @-mentions us (text contains `<@SELF>`)
84
85
  * - thread: a threaded reply (thread_ts present and not the root ts)
85
86
  * - 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.
87
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
88
88
  */
89
89
  function classifySlackKind(
90
90
  msg: SlackMessage,
@@ -219,10 +219,9 @@ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter
219
219
  const receivedAt = tsToMs(msg.ts);
220
220
  if (opts.since && receivedAt <= opts.since) continue;
221
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.
222
+ // provider user id. Slack user ids (U...) are unique within a
223
+ // workspace, and the item.id / provider fields already namespace
224
+ // by provider.
226
225
  const fromDigest = digestSender(senderId);
227
226
  const kind = classifySlackKind(msg, selfUserId);
228
227
  const item: InboundChannelItem = {
@@ -260,5 +259,5 @@ function unavailable(error: string): ProviderPollResult {
260
259
  }
261
260
 
262
261
  function errMsg(error: unknown): string {
263
- return error instanceof Error ? error.message : String(error);
262
+ return summarizeError(error);
264
263
  }
@@ -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
+ }