@pellux/goodvibes-tui 0.24.0 → 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 (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -5
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/daemon/calendar/caldav-client.ts +657 -0
  6. package/src/daemon/calendar/ics.ts +556 -0
  7. package/src/daemon/calendar/index.ts +52 -0
  8. package/src/daemon/calendar/register.ts +527 -0
  9. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  10. package/src/daemon/channels/drafts/index.ts +22 -0
  11. package/src/daemon/channels/drafts/register.ts +449 -0
  12. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  13. package/src/daemon/channels/inbox/index.ts +58 -0
  14. package/src/daemon/channels/inbox/mapping.ts +190 -0
  15. package/src/daemon/channels/inbox/poller.ts +155 -0
  16. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  17. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  18. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  19. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  20. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  21. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  22. package/src/daemon/channels/inbox/register.ts +247 -0
  23. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  24. package/src/daemon/channels/routing/index.ts +39 -0
  25. package/src/daemon/channels/routing/register.ts +296 -0
  26. package/src/daemon/channels/routing/route-store.ts +278 -0
  27. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  28. package/src/daemon/email/imap-connector.ts +441 -0
  29. package/src/daemon/email/imap-parsing.ts +499 -0
  30. package/src/daemon/email/index.ts +68 -0
  31. package/src/daemon/email/register.ts +715 -0
  32. package/src/daemon/email/smtp-connector.ts +557 -0
  33. package/src/daemon/operator/credential-store.ts +129 -0
  34. package/src/daemon/operator/index.ts +43 -0
  35. package/src/daemon/operator/register-helper.ts +150 -0
  36. package/src/daemon/operator/sqlite-store.ts +124 -0
  37. package/src/daemon/operator/surfaces.ts +137 -0
  38. package/src/daemon/operator/types.ts +207 -0
  39. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  40. package/src/daemon/remote/backends/docker.ts +80 -0
  41. package/src/daemon/remote/backends/index.ts +34 -0
  42. package/src/daemon/remote/backends/local-process.ts +113 -0
  43. package/src/daemon/remote/backends/process-runner.ts +151 -0
  44. package/src/daemon/remote/backends/ssh.ts +120 -0
  45. package/src/daemon/remote/backends/types.ts +71 -0
  46. package/src/daemon/remote/dispatcher.ts +160 -0
  47. package/src/daemon/remote/index.ts +74 -0
  48. package/src/daemon/remote/peer-registry.ts +321 -0
  49. package/src/daemon/remote/register.ts +411 -0
  50. package/src/daemon/triage/index.ts +59 -0
  51. package/src/daemon/triage/integration.ts +179 -0
  52. package/src/daemon/triage/pipeline.ts +285 -0
  53. package/src/daemon/triage/register.ts +231 -0
  54. package/src/daemon/triage/scorer.ts +287 -0
  55. package/src/daemon/triage/tagger.ts +777 -0
  56. package/src/runtime/services.ts +35 -0
  57. package/src/version.ts +1 -1
@@ -0,0 +1,411 @@
1
+ import {
2
+ declareOperatorMethod,
3
+ createDaemonCredentialStore,
4
+ assertConfirmed,
5
+ OperatorError,
6
+ type OperatorContext,
7
+ type OperatorInvocation,
8
+ type Unregister,
9
+ } from '../operator/index.ts';
10
+ import type {
11
+ GatewayMethodCatalog,
12
+ GatewayMethodDescriptor,
13
+ GatewayMethodInvocation,
14
+ } from '@pellux/goodvibes-sdk/platform/control-plane';
15
+ import {
16
+ PeerRegistry,
17
+ type BackendKind,
18
+ type PeerRecord,
19
+ } from './peer-registry.ts';
20
+ import {
21
+ RemoteDispatcher,
22
+ type RemoteDispatcherOptions,
23
+ type RemoteInvokeResult,
24
+ type RemoteWorkEnqueuer,
25
+ } from './dispatcher.ts';
26
+ import { BackendDispatchError, type DispatchPayload } from './backends/index.ts';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Method ids
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const REMOTE_PEERS_REGISTER = 'remote.peers.register';
33
+ export const REMOTE_PEERS_INVOKE = 'remote.peers.invoke';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // remote.peers.register input/output
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface RegisterPeerBody {
40
+ peerId?: unknown;
41
+ displayName?: unknown;
42
+ backendKind?: unknown;
43
+ backendConfig?: unknown;
44
+ confirm?: unknown;
45
+ }
46
+
47
+ export interface RegisterPeerResult {
48
+ peerId: string;
49
+ registered: true;
50
+ backendKind: BackendKind;
51
+ }
52
+
53
+ const REGISTER_INPUT_SCHEMA: Record<string, unknown> = {
54
+ type: 'object',
55
+ required: ['peerId', 'displayName', 'backendKind', 'backendConfig', 'confirm'],
56
+ additionalProperties: false,
57
+ properties: {
58
+ peerId: { type: 'string', minLength: 1 },
59
+ displayName: { type: 'string', minLength: 1 },
60
+ backendKind: { type: 'string', enum: ['docker', 'ssh', 'cloud-terminal', 'local-process'] },
61
+ backendConfig: { type: 'object' },
62
+ confirm: { type: 'boolean', const: true },
63
+ },
64
+ };
65
+
66
+ const REGISTER_OUTPUT_SCHEMA: Record<string, unknown> = {
67
+ type: 'object',
68
+ required: ['peerId', 'registered', 'backendKind'],
69
+ properties: {
70
+ peerId: { type: 'string' },
71
+ registered: { type: 'boolean' },
72
+ backendKind: { type: 'string' },
73
+ },
74
+ };
75
+
76
+ const VALID_KINDS: ReadonlySet<string> = new Set([
77
+ 'docker',
78
+ 'ssh',
79
+ 'cloud-terminal',
80
+ 'local-process',
81
+ ]);
82
+
83
+ function parseBackendKind(value: unknown): BackendKind {
84
+ if (typeof value !== 'string' || !VALID_KINDS.has(value)) {
85
+ throw new OperatorError(
86
+ "Field 'backendKind' must be one of 'docker' | 'ssh' | 'cloud-terminal' | 'local-process'.",
87
+ 'REMOTE_INVALID_BACKEND_KIND',
88
+ 400,
89
+ );
90
+ }
91
+ return value as BackendKind;
92
+ }
93
+
94
+ function parseConfig(value: unknown): Record<string, unknown> {
95
+ if (value === undefined || value === null) return {};
96
+ if (typeof value !== 'object' || Array.isArray(value)) {
97
+ throw new OperatorError("Field 'backendConfig' must be an object.", 'REMOTE_INVALID_CONFIG', 400);
98
+ }
99
+ return value as Record<string, unknown>;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // remote.peers.invoke dispatch adapter
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface InvokeBody {
107
+ peerId?: unknown;
108
+ command?: unknown;
109
+ payload?: unknown;
110
+ async?: unknown;
111
+ confirm?: unknown;
112
+ }
113
+
114
+ /**
115
+ * The dispatch adapter the integrator attaches to the already-registered
116
+ * remote.peers.invoke route. It enforces confirm:true + explicitUserRequest via
117
+ * assertConfirmed before routing to the execution backend layer.
118
+ */
119
+ export type RemoteInvokeAdapter = (
120
+ input: OperatorInvocation<unknown>,
121
+ ) => Promise<RemoteInvokeResult>;
122
+
123
+ function normalizePayload(value: unknown): DispatchPayload | undefined {
124
+ if (value === undefined || value === null) return undefined;
125
+ if (typeof value !== 'object' || Array.isArray(value)) {
126
+ throw new OperatorError("Field 'payload' must be an object.", 'REMOTE_INVALID_PAYLOAD', 400);
127
+ }
128
+ const raw = value as Record<string, unknown>;
129
+ const payload: DispatchPayload = {};
130
+ if (Array.isArray(raw.args)) {
131
+ payload.args = raw.args.filter((a): a is string => typeof a === 'string');
132
+ }
133
+ if (typeof raw.stdin === 'string') payload.stdin = raw.stdin;
134
+ if (typeof raw.cwd === 'string') payload.cwd = raw.cwd;
135
+ if (typeof raw.timeoutMs === 'number') payload.timeoutMs = raw.timeoutMs;
136
+ if (raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env)) {
137
+ const env: Record<string, string> = {};
138
+ for (const [k, v] of Object.entries(raw.env as Record<string, unknown>)) {
139
+ if (typeof v === 'string') env[k] = v;
140
+ }
141
+ payload.env = env;
142
+ }
143
+ return Object.keys(payload).length > 0 ? payload : undefined;
144
+ }
145
+
146
+ /**
147
+ * Normalize the invocation context for the UNWRAPPED invoke dispatch adapter.
148
+ *
149
+ * The wrapped register method goes through declareOperatorMethod, which lifts
150
+ * `context.metadata.explicitUserRequest` (the raw SDK / gateway shape) up to a
151
+ * top-level `context.explicitUserRequest` before any confirm guard runs. The
152
+ * invoke dispatch adapter is attached directly to the existing
153
+ * remote.peers.invoke route and is therefore NOT wrapped, so it must perform
154
+ * the same normalization itself. We accept BOTH shapes:
155
+ * - already-normalized: context.explicitUserRequest === true
156
+ * - raw SDK/catalog: context.metadata.explicitUserRequest === true
157
+ * so the adapter behaves identically whether wired to the raw gateway
158
+ * invocation or handed a pre-normalized context (e.g. by tests).
159
+ */
160
+ function normalizeInvokeContext(
161
+ context: OperatorInvocation<unknown>['context'],
162
+ ): { principalId: string; explicitUserRequest: boolean } {
163
+ const raw = context as {
164
+ principalId?: unknown;
165
+ explicitUserRequest?: unknown;
166
+ metadata?: { explicitUserRequest?: unknown } | undefined;
167
+ };
168
+ const explicitUserRequest =
169
+ raw.explicitUserRequest === true || raw.metadata?.explicitUserRequest === true;
170
+ const principalId = typeof raw.principalId === 'string' ? raw.principalId : '';
171
+ return { principalId, explicitUserRequest };
172
+ }
173
+
174
+ function mapDispatchError(error: unknown): never {
175
+ if (error instanceof OperatorError) throw error;
176
+ if (error instanceof BackendDispatchError) {
177
+ const status = error.code === 'REMOTE_PEER_NOT_FOUND' ? 404 : 400;
178
+ throw new OperatorError(error.message, error.code, status);
179
+ }
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ throw new OperatorError(message, 'REMOTE_DISPATCH_FAILED', 500);
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Surface factory
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export interface RemoteSurfaceOptions {
189
+ /** Hook the integrator wires to the existing distributed runtime work queue. */
190
+ workEnqueuer?: RemoteWorkEnqueuer;
191
+ /** Inject a pre-built dispatcher (tests). When set, registry/credentials are ignored. */
192
+ dispatcher?: RemoteDispatcher;
193
+ /** Inject a pre-built registry (tests / shared instance). */
194
+ peerRegistry?: PeerRegistry;
195
+ }
196
+
197
+ export interface RemoteSurface {
198
+ readonly peerRegistry: PeerRegistry;
199
+ readonly dispatcher: RemoteDispatcher;
200
+ /** Async one-time init for the peer registry store. */
201
+ init(): Promise<void>;
202
+ /** Registers remote.peers.register only. Returns its Unregister. */
203
+ register(): Unregister;
204
+ /**
205
+ * Builds the dispatch adapter for the EXISTING remote.peers.invoke route.
206
+ * The integrator attaches this to the upstream invoke router rather than
207
+ * re-registering the method.
208
+ */
209
+ registerDispatch(): RemoteInvokeAdapter;
210
+ close(): void;
211
+ }
212
+
213
+ /**
214
+ * Create the remote execution surface: peer registry + dispatcher + the two
215
+ * registration entry points. Touches only src/daemon/remote.
216
+ */
217
+ export function createRemoteSurface(
218
+ ctx: OperatorContext,
219
+ options: RemoteSurfaceOptions = {},
220
+ ): RemoteSurface {
221
+ const peerRegistry = options.peerRegistry ?? new PeerRegistry(ctx.workingDirectory);
222
+
223
+ let dispatcher: RemoteDispatcher;
224
+ if (options.dispatcher) {
225
+ dispatcher = options.dispatcher;
226
+ } else {
227
+ const credentials = createDaemonCredentialStore(ctx.secrets);
228
+ const dispatcherOptions: RemoteDispatcherOptions = {
229
+ registry: peerRegistry,
230
+ credentials,
231
+ logger: ctx.logger,
232
+ homeDirectory: ctx.homeDirectory,
233
+ ...(options.workEnqueuer ? { workEnqueuer: options.workEnqueuer } : {}),
234
+ };
235
+ dispatcher = new RemoteDispatcher(dispatcherOptions);
236
+ }
237
+
238
+ return {
239
+ peerRegistry,
240
+ dispatcher,
241
+
242
+ async init(): Promise<void> {
243
+ await peerRegistry.init();
244
+ },
245
+
246
+ register(): Unregister {
247
+ return declareOperatorMethod<RegisterPeerBody, RegisterPeerResult>(
248
+ ctx,
249
+ {
250
+ id: REMOTE_PEERS_REGISTER,
251
+ title: 'Register Remote Peer',
252
+ description:
253
+ 'Register or update a remote execution peer with a backend kind and ref-only backend config.',
254
+ category: 'remote',
255
+ source: 'daemon',
256
+ access: 'operator',
257
+ transport: ['ws', 'internal'],
258
+ scopes: ['remote:peers:write'],
259
+ effect: 'confirmed-connected-host-state',
260
+ confirm: true,
261
+ inputSchema: REGISTER_INPUT_SCHEMA,
262
+ outputSchema: REGISTER_OUTPUT_SCHEMA,
263
+ },
264
+ async ({ body }) => {
265
+ const peerId = typeof body.peerId === 'string' ? body.peerId : '';
266
+ const displayName = typeof body.displayName === 'string' ? body.displayName : '';
267
+ const backendKind = parseBackendKind(body.backendKind);
268
+ const backendConfig = parseConfig(body.backendConfig);
269
+ let record: PeerRecord;
270
+ try {
271
+ record = await peerRegistry.register({
272
+ peerId,
273
+ displayName,
274
+ backendKind,
275
+ backendConfig,
276
+ });
277
+ } catch (error) {
278
+ const message = error instanceof Error ? error.message : String(error);
279
+ throw new OperatorError(message, 'REMOTE_PEER_REGISTER_FAILED', 400);
280
+ }
281
+ return { peerId: record.peerId, registered: true, backendKind: record.backendKind };
282
+ },
283
+ );
284
+ },
285
+
286
+ registerDispatch(): RemoteInvokeAdapter {
287
+ return async (input: OperatorInvocation<unknown>): Promise<RemoteInvokeResult> => {
288
+ // Effect: confirmed-connected-host-state. Reject anything that lacks an
289
+ // explicit, confirmed user request BEFORE touching a backend. The
290
+ // context is normalized so the unwrapped adapter accepts both the raw
291
+ // SDK metadata shape and an already-normalized top-level context.
292
+ const normalizedContext = normalizeInvokeContext(input.context);
293
+ assertConfirmed({ body: input.body, context: normalizedContext });
294
+ const body = (input.body ?? {}) as InvokeBody;
295
+ const peerId = typeof body.peerId === 'string' ? body.peerId : '';
296
+ const command = typeof body.command === 'string' ? body.command : '';
297
+ const payload = normalizePayload(body.payload);
298
+ const asyncFlag = body.async === true;
299
+ try {
300
+ return await dispatcher.dispatch({
301
+ peerId,
302
+ command,
303
+ principalId: normalizedContext.principalId,
304
+ async: asyncFlag,
305
+ ...(payload !== undefined ? { payload } : {}),
306
+ });
307
+ } catch (error) {
308
+ mapDispatchError(error);
309
+ }
310
+ };
311
+ },
312
+
313
+ close(): void {
314
+ peerRegistry.close();
315
+ },
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Convenience entry point for the integrator. Builds the remote surface,
321
+ * initializes the peer registry, registers remote.peers.register, and returns
322
+ * the surface handle plus the dispatch adapter for the existing
323
+ * remote.peers.invoke route and a combined Unregister.
324
+ *
325
+ * Integration: attach `dispatch` to the upstream remote.peers.invoke handler.
326
+ */
327
+ export interface RegisteredRemoteMethods {
328
+ surface: RemoteSurface;
329
+ /** Dispatch adapter to wire to the existing remote.peers.invoke route. */
330
+ dispatch: RemoteInvokeAdapter;
331
+ /** Tears down remote.peers.register and closes the peer registry store. */
332
+ unregister: Unregister;
333
+ }
334
+
335
+ export async function registerRemoteMethods(
336
+ ctx: OperatorContext,
337
+ options: RemoteSurfaceOptions = {},
338
+ ): Promise<RegisteredRemoteMethods> {
339
+ const surface = createRemoteSurface(ctx, options);
340
+ await surface.init();
341
+ const unregisterMethod = surface.register();
342
+ const dispatch = surface.registerDispatch();
343
+ return {
344
+ surface,
345
+ dispatch,
346
+ unregister: () => {
347
+ try {
348
+ unregisterMethod();
349
+ } finally {
350
+ surface.close();
351
+ }
352
+ },
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Standalone helper for integrators who only need the invoke dispatch adapter
358
+ * bound to an already-built surface (e.g. when remote.peers.register is wired
359
+ * elsewhere). Enforces confirm:true + explicitUserRequest.
360
+ */
361
+ export function registerRemoteDispatch(surface: RemoteSurface): RemoteInvokeAdapter {
362
+ return surface.registerDispatch();
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // remote.peers.invoke catalog route attachment
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Full gateway descriptor for the remote.peers.invoke route. The SDK control
371
+ * plane already declares this method in its builtin catalog (HTTP binding
372
+ * POST /api/remote/peers/{peerId}/invoke, admin access). We re-declare the same
373
+ * descriptor here so the route can be (re)registered against any catalog with a
374
+ * concrete handler — `access: 'admin'` mirrors the operator-tier mapping used by
375
+ * remote.peers.register, and the http binding matches the upstream route so the
376
+ * DaemonHttpRouter dispatches POSTs to this handler.
377
+ */
378
+ export const REMOTE_PEERS_INVOKE_DESCRIPTOR: GatewayMethodDescriptor = {
379
+ id: REMOTE_PEERS_INVOKE,
380
+ title: 'Invoke Remote Peer',
381
+ description:
382
+ 'Execute a command on a registered remote execution peer (Docker / SSH / cloud-terminal / local-process). Requires explicit user confirmation.',
383
+ category: 'remote',
384
+ source: 'builtin',
385
+ access: 'admin',
386
+ transport: ['http', 'ws', 'internal'],
387
+ scopes: ['remote:peers:invoke'],
388
+ http: { method: 'POST', path: '/api/remote/peers/{peerId}/invoke' },
389
+ };
390
+
391
+ /**
392
+ * Attach the remote dispatch adapter to the `remote.peers.invoke` route on the
393
+ * gateway-method catalog. This is the single faithful integration point: the
394
+ * SDK's DaemonHttpRouter resolves POST /api/remote/peers/{peerId}/invoke to
395
+ * `catalog.invoke('remote.peers.invoke', invocation)`, which calls the handler
396
+ * registered here. The handler hands the raw gateway invocation straight to the
397
+ * dispatch adapter, which normalizes the context (lifting
398
+ * `context.metadata.explicitUserRequest`), enforces confirm:true +
399
+ * explicitUserRequest via assertConfirmed, and routes to the backend layer.
400
+ *
401
+ * `replace: true` overrides the builtin stub the SDK ships for this method.
402
+ * Returns the catalog Unregister for the route.
403
+ */
404
+ export function attachRemoteInvokeRoute(
405
+ catalog: GatewayMethodCatalog,
406
+ dispatch: RemoteInvokeAdapter,
407
+ ): Unregister {
408
+ const handler = async (invocation: GatewayMethodInvocation): Promise<RemoteInvokeResult> =>
409
+ dispatch(invocation as OperatorInvocation<unknown>);
410
+ return catalog.register(REMOTE_PEERS_INVOKE_DESCRIPTOR, handler, { replace: true });
411
+ }
@@ -0,0 +1,59 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Barrel for the daemon-internal Email Auto-Tag / Spam Triage surface.
3
+ //
4
+ // Daemon-internal ONLY: inbox.triage.list and inbox.triage.tag register with
5
+ // transport ['internal'] and are absent from the agent-facing WS method list.
6
+ //
7
+ // Wiring contract: the daemon integration layer (src/runtime/services.ts, the
8
+ // single allowed edit site there) wires this surface in one of two ways:
9
+ // - `registerTriageMethods(ctx)` (alias of `register`) to expose just the
10
+ // internal triage methods; or
11
+ // - `registerTriagedInbox(ctx)` to compose triage WITH the inbox surface so
12
+ // channels.inbox.list returns pre-scored items (the full contract loop).
13
+ // Either returns an Unregister the integration retains for teardown.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export {
17
+ scoreInboundItem,
18
+ labelToTag,
19
+ type TriageLabel,
20
+ type TriageScore,
21
+ type TriageScorerOptions,
22
+ } from './scorer.ts';
23
+
24
+ export {
25
+ runInboxTriage,
26
+ createTriageStore,
27
+ readTriageMetadata,
28
+ enrichItemsWithTriage,
29
+ TRIAGE_STORE_FILE,
30
+ type TriageMetadata,
31
+ type TriagedItem,
32
+ type TriageEnrichedItem,
33
+ type RunInboxTriageOptions,
34
+ type RunInboxTriageResult,
35
+ } from './pipeline.ts';
36
+
37
+ export {
38
+ createTriageTagger,
39
+ TRIAGE_AUTOTAG_FLAG,
40
+ type TriageTagger,
41
+ type TriageTaggerOptions,
42
+ type TaggerProviderConfig,
43
+ type ApplyTagsRequest,
44
+ type ApplyTagsResult,
45
+ type ImapStoreArgs,
46
+ } from './tagger.ts';
47
+
48
+ export {
49
+ register,
50
+ registerTriageMethods,
51
+ createTriageRegister,
52
+ TRIAGE_METHOD_IDS,
53
+ type RegisterTriageOptions,
54
+ } from './register.ts';
55
+
56
+ export {
57
+ registerTriagedInbox,
58
+ type RegisterTriagedInboxOptions,
59
+ } from './integration.ts';
@@ -0,0 +1,179 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Daemon-internal triage INTEGRATION (composition root).
3
+ //
4
+ // `registerTriagedInbox(ctx)` is the single call the daemon integration layer
5
+ // (services.ts) makes to obtain the contract behaviour end-to-end:
6
+ //
7
+ // 1. registers the internal triage surface (inbox.triage.list / .tag) so the
8
+ // poller can score + tag items;
9
+ // 2. registers the inbox surface (channels.inbox.list) THROUGH a catalog
10
+ // proxy that decorates its list handler so every returned item is overlaid
11
+ // with the persisted triageScore/triageTags via enrichItemsWithTriage();
12
+ // 3. returns one Unregister that tears both surfaces down (inbox first, then
13
+ // triage) in reverse order.
14
+ //
15
+ // This closes the loop the contract specifies: "the agent gets pre-scored items
16
+ // via channels.inbox.list metadata" — without editing the inbox surface itself
17
+ // (the list handler is decorated, not modified). The triage scoring store is
18
+ // the co-located inbox-triage.sqlite written by runInboxTriage(); reads here are
19
+ // best-effort and degrade to the raw item when no triage row exists yet.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import type {
23
+ OperatorContext,
24
+ OperatorSqliteStore,
25
+ Unregister,
26
+ } from '../operator/index.ts';
27
+ import {
28
+ registerInboxMethods,
29
+ INBOX_LIST_METHOD_ID,
30
+ type RegisterInboxOptions,
31
+ type InboxListOutput,
32
+ } from '../channels/inbox/index.ts';
33
+ import {
34
+ createTriageRegister,
35
+ type RegisterTriageOptions,
36
+ } from './register.ts';
37
+ import {
38
+ createTriageStore,
39
+ enrichItemsWithTriage,
40
+ } from './pipeline.ts';
41
+
42
+ export interface RegisterTriagedInboxOptions {
43
+ triage?: RegisterTriageOptions;
44
+ inbox?: RegisterInboxOptions;
45
+ }
46
+
47
+ // The narrow slice of the SDK catalog this module proxies. Matches
48
+ // GatewayMethodCatalog.register(descriptor, handler) => Unregister.
49
+ type CatalogLike = OperatorContext['catalog'];
50
+ type CatalogRegister = CatalogLike['register'];
51
+ // The handler the catalog stores: invoked with an opaque invocation, returns a
52
+ // promise of the method result. We model it concretely (rather than via the
53
+ // SDK's possibly-optional Parameters type) so the wrapped handler is callable.
54
+ type StoredHandler = (invocation: unknown) => Promise<unknown>;
55
+
56
+ interface EnrichedContext {
57
+ ctx: OperatorContext;
58
+ /** Close the shared triage store handle (if one was ever opened). */
59
+ dispose: () => void;
60
+ }
61
+
62
+ /**
63
+ * Wrap a catalog so that registration of `channels.inbox.list` decorates the
64
+ * handler: the original handler runs, then each returned item is enriched with
65
+ * persisted triage metadata. All other registrations pass straight through.
66
+ *
67
+ * The triage store is opened lazily ONCE on the first list invocation and the
68
+ * handle is reused for every subsequent call (this is a hot read path). The
69
+ * caller disposes the handle on teardown via the returned `dispose`.
70
+ */
71
+ function withInboxEnrichment(ctx: OperatorContext): EnrichedContext {
72
+ const original = ctx.catalog;
73
+
74
+ // Shared, lazily-opened store handle reused across list invocations.
75
+ let store: OperatorSqliteStore | null = null;
76
+ let initPromise: Promise<OperatorSqliteStore> | null = null;
77
+ const getStore = async (): Promise<OperatorSqliteStore> => {
78
+ if (store) return store;
79
+ if (!initPromise) {
80
+ const pending = createTriageStore(ctx.workingDirectory);
81
+ initPromise = pending
82
+ .init()
83
+ .then(() => {
84
+ store = pending;
85
+ return pending;
86
+ })
87
+ .catch((error) => {
88
+ // Reset so a later call can retry opening the store.
89
+ initPromise = null;
90
+ throw error;
91
+ });
92
+ }
93
+ return initPromise;
94
+ };
95
+ const dispose = (): void => {
96
+ if (store) {
97
+ store.close();
98
+ store = null;
99
+ }
100
+ initPromise = null;
101
+ };
102
+
103
+ const decoratedRegister: CatalogRegister = ((descriptor: unknown, handler: unknown) => {
104
+ const id = (descriptor as { id?: unknown }).id;
105
+ const innerHandler = handler as StoredHandler;
106
+ if (id !== INBOX_LIST_METHOD_ID) {
107
+ return original.register(
108
+ descriptor as Parameters<CatalogRegister>[0],
109
+ handler as Parameters<CatalogRegister>[1],
110
+ );
111
+ }
112
+ const wrapped: StoredHandler = async (invocation) => {
113
+ const result = (await innerHandler(invocation)) as InboxListOutput;
114
+ if (!result || !Array.isArray(result.items) || result.items.length === 0) {
115
+ return result;
116
+ }
117
+ try {
118
+ const handle = await getStore();
119
+ return { ...result, items: enrichItemsWithTriage(handle, result.items) };
120
+ } catch (error) {
121
+ // Triage is best-effort: a missing/locked store must never break the
122
+ // read-only inbox feed. Log and return the un-enriched result.
123
+ ctx.logger.warn('triage: inbox enrichment skipped', {
124
+ message: error instanceof Error ? error.message : String(error),
125
+ });
126
+ return result;
127
+ }
128
+ };
129
+ return original.register(
130
+ descriptor as Parameters<CatalogRegister>[0],
131
+ wrapped as Parameters<CatalogRegister>[1],
132
+ );
133
+ }) as CatalogRegister;
134
+
135
+ // Structurally clone the context with only `catalog` swapped. The proxy keeps
136
+ // every other catalog method (invoke/list/get/...) pointing at the original.
137
+ const proxiedCatalog = new Proxy(original, {
138
+ get(target, prop, receiver) {
139
+ if (prop === 'register') return decoratedRegister;
140
+ return Reflect.get(target, prop, receiver);
141
+ },
142
+ });
143
+
144
+ return { ctx: { ...ctx, catalog: proxiedCatalog as CatalogLike }, dispose };
145
+ }
146
+
147
+ /**
148
+ * Compose the triage surface with the inbox surface so channels.inbox.list
149
+ * returns pre-scored items. Returns a single Unregister tearing down both.
150
+ */
151
+ export function registerTriagedInbox(
152
+ ctx: OperatorContext,
153
+ options: RegisterTriagedInboxOptions = {},
154
+ ): Unregister {
155
+ const unregisterTriage = createTriageRegister(options.triage)(ctx);
156
+ const enriched = withInboxEnrichment(ctx);
157
+ let unregisterInbox: Unregister | null = null;
158
+ try {
159
+ unregisterInbox = registerInboxMethods(enriched.ctx, options.inbox ?? {});
160
+ } catch (error) {
161
+ // If inbox registration fails, do not leak the triage surface or the store.
162
+ enriched.dispose();
163
+ unregisterTriage();
164
+ throw error;
165
+ }
166
+
167
+ return () => {
168
+ try {
169
+ unregisterInbox?.();
170
+ } finally {
171
+ try {
172
+ // Close the shared triage store handle opened by the enrichment proxy.
173
+ enriched.dispose();
174
+ } finally {
175
+ unregisterTriage();
176
+ }
177
+ }
178
+ };
179
+ }