@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
@@ -1,13 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { OperatorSqliteStore } from '../../operator/sqlite-store.ts';
3
- import { OperatorError } from '../../operator/types.ts';
2
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
3
+ import { HandlerError } from '../errors.ts';
4
4
 
5
5
  /**
6
6
  * A persisted channel-to-profile routing assignment, in the exact shape the
7
- * `channels.routing.*` operator methods return (see daemon handoff doc,
8
- * Responsibility 2). This is intentionally distinct from the foundation's
9
- * generic `ChannelRoute` type the routing control-plane has its own contract
10
- * keyed on (surfaceKind, routeId) composite channel IDs.
7
+ * `channels.routing.*` methods serve. Keyed on a composite `channelId`
8
+ * (`surfaceKind` or `surfaceKind:routeId`). The SDK output schemas require
9
+ * `surfaceKind`, `profileId`, `createdAt`, and `updatedAt`; `channelId`,
10
+ * `routeId`, and `label` are carried through as optional refinements.
11
11
  */
12
12
  export interface RoutingChannelRoute {
13
13
  assignmentId: string;
@@ -21,6 +21,47 @@ export interface RoutingChannelRoute {
21
21
  updatedAt: string; // ISO-8601
22
22
  }
23
23
 
24
+ /**
25
+ * Per-route item shape emitted by `channels.routing.list`, matching the SDK
26
+ * `CHANNEL_ROUTING_RULE_SCHEMA` exactly: properties
27
+ * {id, createdAt, updatedAt, surfaceKind, routeId, profileId, label},
28
+ * required [id, createdAt, updatedAt, surfaceKind, profileId], and
29
+ * additionalProperties:false. The store's `assignmentId` is projected to `id`,
30
+ * and the daemon-internal `channelId` is dropped (it is forbidden by the
31
+ * schema's additionalProperties:false).
32
+ */
33
+ export interface RoutingRouteListItem {
34
+ id: string;
35
+ createdAt: string; // ISO-8601
36
+ updatedAt: string; // ISO-8601
37
+ surfaceKind: string;
38
+ routeId?: string;
39
+ profileId: string;
40
+ label?: string;
41
+ }
42
+
43
+ /**
44
+ * Project a stored route to the SDK `channels.routing.list` item shape. Pure;
45
+ * only includes optional `routeId`/`label` when present so the payload never
46
+ * carries `undefined`-valued or schema-forbidden keys.
47
+ */
48
+ export function toRouteListItem(route: RoutingChannelRoute): RoutingRouteListItem {
49
+ const item: RoutingRouteListItem = {
50
+ id: route.assignmentId,
51
+ createdAt: route.createdAt,
52
+ updatedAt: route.updatedAt,
53
+ surfaceKind: route.surfaceKind,
54
+ profileId: route.profileId,
55
+ };
56
+ if (route.routeId !== undefined) {
57
+ item.routeId = route.routeId;
58
+ }
59
+ if (route.label !== undefined) {
60
+ item.label = route.label;
61
+ }
62
+ return item;
63
+ }
64
+
24
65
  /** Parsed components of a composite channelId. */
25
66
  export interface ParsedChannelId {
26
67
  surfaceKind: string;
@@ -80,7 +121,7 @@ interface RouteRow {
80
121
  export function parseChannelId(channelId: string): ParsedChannelId {
81
122
  const raw = typeof channelId === 'string' ? channelId.trim() : '';
82
123
  if (raw.length === 0) {
83
- throw new OperatorError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
124
+ throw new HandlerError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
84
125
  }
85
126
  const separator = raw.indexOf(':');
86
127
  if (separator === -1) {
@@ -89,7 +130,7 @@ export function parseChannelId(channelId: string): ParsedChannelId {
89
130
  const surfaceKind = raw.slice(0, separator).trim();
90
131
  const routeId = raw.slice(separator + 1).trim();
91
132
  if (surfaceKind.length === 0) {
92
- throw new OperatorError('channelId is missing a surfaceKind', 'ROUTING_INVALID_CHANNEL_ID', 400);
133
+ throw new HandlerError('channelId is missing a surfaceKind', 'ROUTING_INVALID_CHANNEL_ID', 400);
93
134
  }
94
135
  // A trailing colon with no routeId collapses to a surface-only assignment.
95
136
  return routeId.length === 0 ? { surfaceKind } : { surfaceKind, routeId };
@@ -117,16 +158,16 @@ function rowToRoute(row: RouteRow): RoutingChannelRoute {
117
158
  /**
118
159
  * SQLite-backed store for channel-to-profile routing assignments.
119
160
  *
120
- * Wraps {@link OperatorSqliteStore} (file `channel-routes.sqlite`) and persists
161
+ * Wraps {@link HandlerSqliteStore} (file `channel-routes.sqlite`) and persists
121
162
  * after every mutation. The store owns the composite-key parsing so callers only
122
163
  * ever deal with `channelId` strings.
123
164
  */
124
165
  export class RouteStore {
125
- private readonly store: OperatorSqliteStore;
166
+ private readonly store: HandlerSqliteStore;
126
167
  private initialized = false;
127
168
 
128
169
  constructor(options: { workingDirectory: string }) {
129
- this.store = new OperatorSqliteStore({
170
+ this.store = new HandlerSqliteStore({
130
171
  workingDirectory: options.workingDirectory,
131
172
  fileName: 'channel-routes.sqlite',
132
173
  schema: SCHEMA,
@@ -150,7 +191,7 @@ export class RouteStore {
150
191
 
151
192
  private requireInit(): void {
152
193
  if (!this.initialized) {
153
- throw new OperatorError('RouteStore not initialized', 'ROUTING_STORE_UNINITIALIZED', 500);
194
+ throw new HandlerError('RouteStore not initialized', 'ROUTING_STORE_UNINITIALIZED', 500);
154
195
  }
155
196
  }
156
197
 
@@ -219,7 +260,7 @@ export class RouteStore {
219
260
  this.requireInit();
220
261
  const profileId = typeof input.profileId === 'string' ? input.profileId.trim() : '';
221
262
  if (profileId.length === 0) {
222
- throw new OperatorError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
263
+ throw new HandlerError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
223
264
  }
224
265
  const { surfaceKind, routeId } = parseChannelId(input.channelId);
225
266
  const channelId = buildChannelId(surfaceKind, routeId);
@@ -239,7 +280,7 @@ export class RouteStore {
239
280
  await this.store.save();
240
281
  const route = this.findById(existing.assignmentId);
241
282
  if (!route) {
242
- throw new OperatorError('Failed to read back updated route', 'ROUTING_WRITE_FAILED', 500);
283
+ throw new HandlerError('Failed to read back updated route', 'ROUTING_WRITE_FAILED', 500);
243
284
  }
244
285
  return { route, created: false };
245
286
  }
@@ -254,7 +295,7 @@ export class RouteStore {
254
295
  await this.store.save();
255
296
  const route = this.findById(assignmentId);
256
297
  if (!route) {
257
- throw new OperatorError('Failed to read back inserted route', 'ROUTING_WRITE_FAILED', 500);
298
+ throw new HandlerError('Failed to read back inserted route', 'ROUTING_WRITE_FAILED', 500);
258
299
  }
259
300
  return { route, created: true };
260
301
  }
@@ -267,7 +308,7 @@ export class RouteStore {
267
308
  this.requireInit();
268
309
  const id = typeof assignmentId === 'string' ? assignmentId.trim() : '';
269
310
  if (id.length === 0) {
270
- throw new OperatorError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
311
+ throw new HandlerError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
271
312
  }
272
313
  const existing = this.findById(id);
273
314
  if (!existing) return false;
@@ -1,5 +1,5 @@
1
1
  import type { RoutingChannelRoute } from './route-store.ts';
2
- import { RouteStore } from './route-store.ts';
2
+ import type { RouteStore } from './route-store.ts';
3
3
 
4
4
  /** Wildcard surfaceKind that matches any surface as a last resort. */
5
5
  export const WILDCARD_SURFACE = 'any';
@@ -7,7 +7,7 @@ import initSqlJs from 'sql.js';
7
7
  type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>;
8
8
  type SqlDatabase = InstanceType<SqlJsStatic['Database']>;
9
9
 
10
- export interface OperatorSqliteOptions {
10
+ export interface SqliteStoreOptions {
11
11
  workingDirectory: string;
12
12
  /** e.g. 'channel-routes.sqlite', 'drafts.sqlite', 'inbox-cursors.sqlite' */
13
13
  fileName: string;
@@ -29,12 +29,12 @@ async function loadSqlJs(): Promise<SqlJsStatic> {
29
29
  * (sql.js WASM: init → run/exec → save → close). One file per concern under
30
30
  * {workingDirectory}/.goodvibes/tui/operator/{fileName}.
31
31
  */
32
- export class OperatorSqliteStore {
33
- private readonly options: OperatorSqliteOptions;
32
+ export class HandlerSqliteStore {
33
+ private readonly options: SqliteStoreOptions;
34
34
  private readonly resolvedPath: string;
35
35
  private db: SqlDatabase | null = null;
36
36
 
37
- constructor(options: OperatorSqliteOptions) {
37
+ constructor(options: SqliteStoreOptions) {
38
38
  this.options = options;
39
39
  this.resolvedPath = join(
40
40
  options.workingDirectory,
@@ -51,7 +51,7 @@ export class OperatorSqliteStore {
51
51
 
52
52
  private requireDb(): SqlDatabase {
53
53
  if (!this.db) {
54
- throw new Error(`OperatorSqliteStore not initialized: ${this.resolvedPath}`);
54
+ throw new Error(`HandlerSqliteStore not initialized: ${this.resolvedPath}`);
55
55
  }
56
56
  return this.db;
57
57
  }
@@ -0,0 +1,57 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Public surface for the daemon-internal Email Auto-Tag / Spam Triage handler.
3
+ //
4
+ // `registerTriagedInbox` (from ./integration.ts) is the single entry the
5
+ // runtime composition root calls. It DECORATES the inbox surface's
6
+ // `channels.inbox.list` handler (overlaying persisted triage metadata) and
7
+ // returns the poller-facing pipeline + tagger. inbox.triage.* are NOT published
8
+ // catalog methods — this surface registers no triage method id.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export {
12
+ scoreInboundItem,
13
+ labelToTag,
14
+ type TriageScore,
15
+ type TriageScorerOptions,
16
+ } from './scorer.ts';
17
+
18
+ export type {
19
+ InboundChannelItem,
20
+ TriageLabel,
21
+ ConversationKind,
22
+ } from './types.ts';
23
+
24
+ export {
25
+ runInboxTriage,
26
+ createTriageStore,
27
+ readTriageMetadata,
28
+ readTriageMetadataBatch,
29
+ enrichItemsWithTriage,
30
+ TRIAGE_STORE_FILE,
31
+ type TriageMetadata,
32
+ type TriagedItem,
33
+ type TriageOverlay,
34
+ type TriageEnrichedItem,
35
+ type RunInboxTriageOptions,
36
+ type RunInboxTriageResult,
37
+ } from './pipeline.ts';
38
+
39
+ export {
40
+ createTriageTagger,
41
+ TRIAGE_AUTOTAG_FLAG,
42
+ type TriageTagger,
43
+ type TriageTaggerOptions,
44
+ type TaggerProviderConfig,
45
+ type ApplyTagsRequest,
46
+ type ApplyTagsResult,
47
+ type ImapStoreArgs,
48
+ type ImapRetryOptions,
49
+ } from './tagger/index.ts';
50
+
51
+ export {
52
+ registerTriagedInbox,
53
+ INBOX_LIST_METHOD_ID,
54
+ type RegisterInbox,
55
+ type RegisterTriagedInboxOptions,
56
+ type TriagedInboxRegistration,
57
+ } from './integration.ts';
@@ -0,0 +1,212 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Daemon-internal triage INTEGRATION (the single entry the runtime calls).
3
+ //
4
+ // `registerTriagedInbox(ctx, registerInbox, options)` closes the contract loop:
5
+ //
6
+ // 1. It DECORATES the inbox surface's `channels.inbox.list` handler. The
7
+ // inbox surface (a separate handler module) attaches its handler to the
8
+ // SDK-auto-registered descriptor via `ctx.catalog.register(descriptor,
9
+ // handler, { replace: true })`. We pass the inbox `registerInbox` a
10
+ // catalog PROXY whose `register` intercepts exactly the
11
+ // `channels.inbox.list` id and wraps the handler so every returned item is
12
+ // overlaid with the persisted triageScore/triageTags via
13
+ // enrichItemsWithTriage(). Every other registration passes straight
14
+ // through to the real catalog. The inbox descriptor/schema/id is never
15
+ // re-authored — only its handler is wrapped.
16
+ // 2. It exposes the triage pipeline + tagger (`runInboxTriage`, `tagger`) for
17
+ // the daemon-internal poller, which scores items and persists them to the
18
+ // co-located inbox-triage.sqlite store the decorator reads from.
19
+ // 3. inbox.triage.* are intentionally NOT registered on the catalog (they are
20
+ // a daemon-internal pipeline, not published methods) — so this module
21
+ // makes ZERO catalog.register call for any triage id.
22
+ //
23
+ // Reads in the decorator are best-effort and degrade to the raw item when no
24
+ // triage row exists yet, so the read-only inbox feed never breaks.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ import type { HandlerContext } from '../context.ts';
28
+ import type {
29
+ GatewayMethodCatalog,
30
+ GatewayMethodDescriptor,
31
+ GatewayMethodHandler,
32
+ } from '../contracts.ts';
33
+ import type { Unregister } from '../register.ts';
34
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
35
+ import {
36
+ createTriageStore,
37
+ enrichItemsWithTriage,
38
+ runInboxTriage,
39
+ type RunInboxTriageOptions,
40
+ type RunInboxTriageResult,
41
+ } from './pipeline.ts';
42
+ import type { InboundChannelItem } from './types.ts';
43
+ import {
44
+ createTriageTagger,
45
+ type ApplyTagsRequest,
46
+ type ApplyTagsResult,
47
+ type TriageTagger,
48
+ type TriageTaggerOptions,
49
+ } from './tagger/index.ts';
50
+
51
+ /**
52
+ * Canonical id of the inbox list method whose handler we decorate. This is the
53
+ * SDK's published id — referenced as a plain string for matching during
54
+ * registration; no descriptor or schema is authored here.
55
+ */
56
+ export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
57
+
58
+ /** Minimal shape of the inbox.list result the decorator overlays triage onto. */
59
+ interface InboxListResult {
60
+ items?: unknown;
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ export interface RegisterTriagedInboxOptions {
65
+ pipeline?: RunInboxTriageOptions;
66
+ tagger?: TriageTaggerOptions;
67
+ }
68
+
69
+ /**
70
+ * The inbox surface provider. The runtime supplies the inbox module's register
71
+ * function; we hand it a decorating catalog proxy so its `channels.inbox.list`
72
+ * handler is wrapped with triage enrichment.
73
+ */
74
+ export type RegisterInbox = (ctx: HandlerContext) => Unregister;
75
+
76
+ /** Handle returned to the runtime: teardown + the poller-facing pipeline/tagger. */
77
+ export interface TriagedInboxRegistration {
78
+ readonly unregister: Unregister;
79
+ /** Score (+persist) a batch of polled items. Used by the inbox poller. */
80
+ runInboxTriage(
81
+ items: readonly InboundChannelItem[],
82
+ options?: RunInboxTriageOptions,
83
+ ): Promise<RunInboxTriageResult>;
84
+ /** Provider-side tagger (IMAP flag / Slack or Discord reaction/tag). */
85
+ readonly tagger: TriageTagger;
86
+ }
87
+
88
+ type StoredHandler = GatewayMethodHandler;
89
+
90
+ interface EnrichmentProxy {
91
+ readonly ctx: HandlerContext;
92
+ /** Close the shared triage store handle (if one was ever opened). */
93
+ dispose(): void;
94
+ }
95
+
96
+ /**
97
+ * Build a HandlerContext whose catalog decorates `channels.inbox.list`
98
+ * registration. The triage store is opened lazily ONCE on the first list
99
+ * invocation and reused for every subsequent call (hot read path); the caller
100
+ * disposes the handle on teardown.
101
+ */
102
+ function withInboxEnrichment(ctx: HandlerContext): EnrichmentProxy {
103
+ const original = ctx.catalog;
104
+
105
+ let store: HandlerSqliteStore | null = null;
106
+ let initPromise: Promise<HandlerSqliteStore> | null = null;
107
+ const getStore = async (): Promise<HandlerSqliteStore> => {
108
+ if (store) return store;
109
+ if (!initPromise) {
110
+ const pending = createTriageStore(ctx.workingDirectory);
111
+ initPromise = pending
112
+ .init()
113
+ .then(() => {
114
+ store = pending;
115
+ return pending;
116
+ })
117
+ .catch((error) => {
118
+ initPromise = null; // allow a later call to retry opening the store
119
+ throw error;
120
+ });
121
+ }
122
+ return initPromise;
123
+ };
124
+ const dispose = (): void => {
125
+ if (store) {
126
+ store.close();
127
+ store = null;
128
+ }
129
+ initPromise = null;
130
+ };
131
+
132
+ const decoratedRegister: GatewayMethodCatalog['register'] = (
133
+ descriptor: GatewayMethodDescriptor,
134
+ handler?: StoredHandler,
135
+ options?: { replace?: boolean },
136
+ ): Unregister => {
137
+ if (descriptor.id !== INBOX_LIST_METHOD_ID || !handler) {
138
+ return original.register(descriptor, handler, options);
139
+ }
140
+ const innerHandler = handler;
141
+ const wrapped: StoredHandler = async (invocation) => {
142
+ const result = (await innerHandler(invocation)) as InboxListResult;
143
+ if (!result || !Array.isArray(result.items) || result.items.length === 0) {
144
+ return result;
145
+ }
146
+ const items = result.items as Array<{ id: string }>;
147
+ try {
148
+ const handle = await getStore();
149
+ return { ...result, items: enrichItemsWithTriage(handle, items) };
150
+ } catch (error) {
151
+ // Triage is best-effort: a missing/locked store must never break the
152
+ // read-only inbox feed. Log and return the un-enriched result.
153
+ ctx.logger.warn('triage: inbox enrichment skipped', {
154
+ message: error instanceof Error ? error.message : String(error),
155
+ });
156
+ return result;
157
+ }
158
+ };
159
+ return original.register(descriptor, wrapped, options);
160
+ };
161
+
162
+ // Clone the context with only `catalog.register` swapped. Every other catalog
163
+ // method (invoke/list/get/...) keeps pointing at the original instance.
164
+ const proxiedCatalog = new Proxy(original, {
165
+ get(target, prop, receiver) {
166
+ if (prop === 'register') return decoratedRegister;
167
+ return Reflect.get(target, prop, receiver);
168
+ },
169
+ }) as GatewayMethodCatalog;
170
+
171
+ return { ctx: { ...ctx, catalog: proxiedCatalog }, dispose };
172
+ }
173
+
174
+ /**
175
+ * Compose the triage pipeline with the inbox surface so channels.inbox.list
176
+ * returns pre-scored items. Returns the teardown plus the poller-facing
177
+ * pipeline/tagger handle. The inbox surface's registration is wrapped; inbox
178
+ * teardown runs first, then the shared store handle is disposed.
179
+ */
180
+ export function registerTriagedInbox(
181
+ ctx: HandlerContext,
182
+ registerInbox: RegisterInbox,
183
+ options: RegisterTriagedInboxOptions = {},
184
+ ): TriagedInboxRegistration {
185
+ const tagger = createTriageTagger(ctx, options.tagger);
186
+ const enriched = withInboxEnrichment(ctx);
187
+
188
+ let unregisterInbox: Unregister;
189
+ try {
190
+ unregisterInbox = registerInbox(enriched.ctx);
191
+ } catch (error) {
192
+ enriched.dispose();
193
+ throw error;
194
+ }
195
+
196
+ const unregister: Unregister = () => {
197
+ try {
198
+ unregisterInbox();
199
+ } finally {
200
+ enriched.dispose();
201
+ }
202
+ };
203
+
204
+ return {
205
+ unregister,
206
+ runInboxTriage: (items, runOptions) =>
207
+ runInboxTriage(items, ctx, { ...options.pipeline, ...runOptions }),
208
+ tagger,
209
+ };
210
+ }
211
+
212
+ export type { ApplyTagsRequest, ApplyTagsResult };
@@ -6,22 +6,20 @@
6
6
  // later channels.inbox.list response can surface pre-scored items.
7
7
  //
8
8
  // The inbox surface owns the authoritative cursor-store and exposes
9
- // triageScore/triageTags columns. To stay strictly within src/daemon/triage/
10
- // (per the hard rules), this pipeline persists into a dedicated, co-located
11
- // OperatorSqliteStore ('inbox-triage.sqlite') keyed by item id. The inbox
12
- // surface reads these rows by id when assembling its list response. The schema
13
- // mirrors the agreed columns: triageScore REAL, triageTags TEXT (JSON array).
9
+ // triageScore/triageTags columns. To stay strictly within the triage handler
10
+ // surface, this pipeline persists into a dedicated, co-located
11
+ // HandlerSqliteStore ('inbox-triage.sqlite') keyed by item id. The inbox
12
+ // surface reads these rows by id when assembling its list response (the
13
+ // integration decorator does exactly that). The schema mirrors the agreed
14
+ // columns: triageScore REAL, triageTags TEXT (JSON array).
14
15
  // ---------------------------------------------------------------------------
15
16
 
16
- import {
17
- OperatorSqliteStore,
18
- type InboundChannelItem,
19
- type OperatorContext,
20
- } from '../operator/index.ts';
17
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
18
+ import type { HandlerContext } from '../context.ts';
19
+ import type { InboundChannelItem, TriageLabel } from './types.ts';
21
20
  import {
22
21
  labelToTag,
23
22
  scoreInboundItem,
24
- type TriageLabel,
25
23
  type TriageScore,
26
24
  type TriageScorerOptions,
27
25
  } from './scorer.ts';
@@ -57,7 +55,7 @@ export interface TriagedItem extends InboundChannelItem {
57
55
  export interface RunInboxTriageOptions {
58
56
  scorer?: TriageScorerOptions;
59
57
  /** Inject a store (tests). When omitted, a triage store is opened/closed. */
60
- store?: OperatorSqliteStore;
58
+ store?: HandlerSqliteStore;
61
59
  /** When true, do not persist — only compute (used by inbox.triage.list). */
62
60
  dryRun?: boolean;
63
61
  /** Clock injection for deterministic updatedAt in tests. */
@@ -83,8 +81,8 @@ function toMetadata(score: TriageScore): TriageMetadata {
83
81
  * Open the triage store for this working directory. Caller owns close()/save()
84
82
  * when they pass their own store; otherwise runInboxTriage manages lifecycle.
85
83
  */
86
- export function createTriageStore(workingDirectory: string): OperatorSqliteStore {
87
- return new OperatorSqliteStore({
84
+ export function createTriageStore(workingDirectory: string): HandlerSqliteStore {
85
+ return new HandlerSqliteStore({
88
86
  workingDirectory,
89
87
  fileName: TRIAGE_STORE_FILE,
90
88
  schema: SCHEMA,
@@ -92,7 +90,7 @@ export function createTriageStore(workingDirectory: string): OperatorSqliteStore
92
90
  }
93
91
 
94
92
  function persistRow(
95
- store: OperatorSqliteStore,
93
+ store: HandlerSqliteStore,
96
94
  item: InboundChannelItem,
97
95
  meta: TriageMetadata,
98
96
  updatedAt: string,
@@ -131,7 +129,7 @@ function persistRow(
131
129
  */
132
130
  export async function runInboxTriage(
133
131
  items: readonly InboundChannelItem[],
134
- ctx: OperatorContext,
132
+ ctx: HandlerContext,
135
133
  options: RunInboxTriageOptions = {},
136
134
  ): Promise<RunInboxTriageResult> {
137
135
  const now = options.now ?? (() => new Date());
@@ -170,11 +168,7 @@ export async function runInboxTriage(
170
168
  return { items: enriched, scored: enriched.length, persisted };
171
169
  }
172
170
 
173
- /**
174
- * Inbound item enriched with the persisted triage metadata that
175
- * `channels.inbox.list` advertises (triageScore + triageTags). When no triage
176
- * row exists yet for an item the original item is returned unchanged.
177
- */
171
+ /** Optional persisted triage metadata overlaid onto an inbound item. */
178
172
  export interface TriageOverlay {
179
173
  triageScore?: number;
180
174
  triageTags?: string[];
@@ -184,46 +178,6 @@ export interface TriageOverlay {
184
178
  /** An inbound item overlaid with optional persisted triage metadata. */
185
179
  export type TriageEnrichedItem = InboundChannelItem & TriageOverlay;
186
180
 
187
- /**
188
- * Merge persisted triage metadata onto a batch of inbound items by id. This is
189
- * the exact glue `channels.inbox.list` invokes to surface pre-scored items: the
190
- * inbox surface lists from its cursor store, then calls this to overlay the
191
- * triageScore/triageTags columns the contract promises. Items without a stored
192
- * triage row pass through untouched, so an un-scored feed degrades gracefully.
193
- */
194
- export function enrichItemsWithTriage<T extends { id: string }>(
195
- store: OperatorSqliteStore,
196
- items: readonly T[],
197
- ): Array<T & TriageOverlay> {
198
- if (items.length === 0) return [];
199
- // Single batched read (`WHERE id IN (...)`) instead of one SELECT per item —
200
- // this is a hot read path (every channels.inbox.list call), so the N+1 is
201
- // collapsed to one query keyed by id.
202
- const byId = readTriageMetadataBatch(
203
- store,
204
- items.map((item) => item.id),
205
- );
206
- return items.map((item) => {
207
- const meta = byId.get(item.id);
208
- if (!meta) return { ...item };
209
- return {
210
- ...item,
211
- triageScore: meta.triageScore,
212
- triageLabel: meta.triageLabel,
213
- triageTags: meta.triageTags,
214
- };
215
- });
216
- }
217
-
218
- interface TriageRow {
219
- id: string;
220
- triageScore: number;
221
- triageLabel: string;
222
- triageTags: string;
223
- spamSignal: number;
224
- prioritySignal: number;
225
- }
226
-
227
181
  function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
228
182
  let tags: string[] = [];
229
183
  try {
@@ -240,13 +194,22 @@ function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
240
194
  };
241
195
  }
242
196
 
197
+ interface TriageRow {
198
+ id: string;
199
+ triageScore: number;
200
+ triageLabel: string;
201
+ triageTags: string;
202
+ spamSignal: number;
203
+ prioritySignal: number;
204
+ }
205
+
243
206
  /**
244
207
  * Read persisted triage rows for many item ids in a single `WHERE id IN (...)`
245
208
  * query. Returns a Map keyed by item id; ids without a stored row are absent.
246
209
  * De-duplicates ids so the placeholder list stays minimal.
247
210
  */
248
211
  export function readTriageMetadataBatch(
249
- store: OperatorSqliteStore,
212
+ store: HandlerSqliteStore,
250
213
  itemIds: readonly string[],
251
214
  ): Map<string, TriageMetadata> {
252
215
  const out = new Map<string, TriageMetadata>();
@@ -266,16 +229,10 @@ export function readTriageMetadataBatch(
266
229
 
267
230
  /** Read a single persisted triage row by item id (used by the inbox surface). */
268
231
  export function readTriageMetadata(
269
- store: OperatorSqliteStore,
232
+ store: HandlerSqliteStore,
270
233
  itemId: string,
271
234
  ): TriageMetadata | null {
272
- const row = store.get<{
273
- triageScore: number;
274
- triageLabel: string;
275
- triageTags: string;
276
- spamSignal: number;
277
- prioritySignal: number;
278
- }>(
235
+ const row = store.get<Omit<TriageRow, 'id'>>(
279
236
  `SELECT triageScore, triageLabel, triageTags, spamSignal, prioritySignal
280
237
  FROM inbox_triage WHERE id = ?`,
281
238
  [itemId],
@@ -283,3 +240,34 @@ export function readTriageMetadata(
283
240
  if (!row) return null;
284
241
  return rowToMetadata(row);
285
242
  }
243
+
244
+ /**
245
+ * Merge persisted triage metadata onto a batch of inbound items by id. This is
246
+ * the exact glue `channels.inbox.list` invokes to surface pre-scored items: the
247
+ * inbox surface lists from its cursor store, then calls this to overlay the
248
+ * triageScore/triageTags columns the contract promises. Items without a stored
249
+ * triage row pass through untouched, so an un-scored feed degrades gracefully.
250
+ */
251
+ export function enrichItemsWithTriage<T extends { id: string }>(
252
+ store: HandlerSqliteStore,
253
+ items: readonly T[],
254
+ ): Array<T & TriageOverlay> {
255
+ if (items.length === 0) return [];
256
+ // Single batched read (`WHERE id IN (...)`) instead of one SELECT per item —
257
+ // this is a hot read path (every channels.inbox.list call), so the N+1 is
258
+ // collapsed to one query keyed by id.
259
+ const byId = readTriageMetadataBatch(
260
+ store,
261
+ items.map((item) => item.id),
262
+ );
263
+ return items.map((item) => {
264
+ const meta = byId.get(item.id);
265
+ if (!meta) return { ...item };
266
+ return {
267
+ ...item,
268
+ triageScore: meta.triageScore,
269
+ triageLabel: meta.triageLabel,
270
+ triageTags: meta.triageTags,
271
+ };
272
+ });
273
+ }