@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,43 @@
1
+ // Foundation barrel for the daemon operator-method scaffolding.
2
+ // Surfaces import only from here, from src/config/*, and from the SDK.
3
+
4
+ export type {
5
+ OperatorAccess,
6
+ OperatorTransport,
7
+ OperatorEffect,
8
+ OperatorMethodDescriptor,
9
+ CatalogMethodDescriptor,
10
+ OperatorInvocation,
11
+ OperatorHandler,
12
+ OperatorLogger,
13
+ OperatorContext,
14
+ Unregister,
15
+ SurfaceRegister,
16
+ InboundChannelItem,
17
+ ChannelRoute,
18
+ DraftRecord,
19
+ CalendarEventSummary,
20
+ } from './types.ts';
21
+ export { OperatorError, REQUIRE_CONFIRM, sha256First, redactWebhook } from './types.ts';
22
+
23
+ export {
24
+ declareOperatorMethod,
25
+ declareOperatorMethods,
26
+ assertConfirmed,
27
+ } from './register-helper.ts';
28
+
29
+ export { OperatorSqliteStore } from './sqlite-store.ts';
30
+ export type { OperatorSqliteOptions } from './sqlite-store.ts';
31
+
32
+ export {
33
+ createDaemonCredentialStore,
34
+ createAtRestCipher,
35
+ } from './credential-store.ts';
36
+ export type { DaemonCredentialStore, AtRestCipher } from './credential-store.ts';
37
+
38
+ // NOTE: `registerDaemonOperatorSurfaces` / `DaemonOperatorSurfaces` are NOT
39
+ // re-exported here. `surfaces.ts` is the composition root that imports every
40
+ // surface register module; re-exporting it from this foundation barrel would
41
+ // make any leaf module that imports the barrel transitively pull in the whole
42
+ // surface graph, forming an import cycle. The composition root (services.ts)
43
+ // imports them directly from './surfaces.ts'.
@@ -0,0 +1,150 @@
1
+ import type {
2
+ GatewayMethodAccess,
3
+ GatewayMethodDescriptor,
4
+ GatewayMethodInvocation,
5
+ GatewayMethodSource,
6
+ GatewayMethodTransport,
7
+ } from '@pellux/goodvibes-sdk/platform/control-plane';
8
+ import type {
9
+ CatalogMethodDescriptor,
10
+ OperatorAccess,
11
+ OperatorContext,
12
+ OperatorHandler,
13
+ OperatorInvocation,
14
+ OperatorMethodDescriptor,
15
+ OperatorTransport,
16
+ Unregister,
17
+ } from './types.ts';
18
+ import { OperatorError, REQUIRE_CONFIRM } from './types.ts';
19
+
20
+ const CATALOG_TRANSPORTS = new Set<GatewayMethodTransport>(['http', 'ws', 'internal']);
21
+
22
+ function mapAccess(access: OperatorAccess): GatewayMethodAccess {
23
+ // The SDK has no 'operator' access tier; map it to 'admin'.
24
+ return access === 'operator' ? 'admin' : access;
25
+ }
26
+
27
+ function mapSource(source: string | undefined, pluginId: string | undefined): GatewayMethodSource {
28
+ // The SDK accepts only 'builtin' | 'plugin'. Daemon-owned methods register as
29
+ // 'builtin' unless they carry a pluginId.
30
+ if (source === 'plugin' || pluginId !== undefined) return 'plugin';
31
+ return 'builtin';
32
+ }
33
+
34
+ function mapTransport(transport: OperatorTransport[] | undefined): GatewayMethodTransport[] {
35
+ const candidates = transport ?? ['ws', 'internal'];
36
+ return candidates.filter((t): t is GatewayMethodTransport => CATALOG_TRANSPORTS.has(t as GatewayMethodTransport));
37
+ }
38
+
39
+ function toCatalogDescriptor(descriptor: OperatorMethodDescriptor): CatalogMethodDescriptor {
40
+ // Strip non-catalog fields (effect, confirm). Only the SDK-recognized fields
41
+ // are forwarded to catalog.register().
42
+ return {
43
+ id: descriptor.id,
44
+ title: descriptor.title,
45
+ description: descriptor.description,
46
+ category: descriptor.category,
47
+ source: mapSource(descriptor.source, descriptor.pluginId),
48
+ access: mapAccess(descriptor.access),
49
+ transport: mapTransport(descriptor.transport),
50
+ scopes: descriptor.scopes,
51
+ ...(descriptor.pluginId !== undefined ? { pluginId: descriptor.pluginId } : {}),
52
+ ...(descriptor.inputSchema !== undefined ? { inputSchema: descriptor.inputSchema } : {}),
53
+ ...(descriptor.outputSchema !== undefined ? { outputSchema: descriptor.outputSchema } : {}),
54
+ };
55
+ }
56
+
57
+ function hasConfirmFlag(body: unknown): boolean {
58
+ return (
59
+ typeof body === 'object'
60
+ && body !== null
61
+ && (body as { confirm?: unknown }).confirm === true
62
+ );
63
+ }
64
+
65
+ function explicitUserRequestFlag(metadata: Record<string, unknown> | undefined): boolean {
66
+ return metadata?.explicitUserRequest === true;
67
+ }
68
+
69
+ /**
70
+ * Assert that an invocation carries an explicit, confirmed user request.
71
+ * Throws OperatorError(REQUIRE_CONFIRM, 403) when not confirmed.
72
+ */
73
+ export function assertConfirmed(input: {
74
+ body: unknown;
75
+ context: { explicitUserRequest?: boolean };
76
+ }): void {
77
+ if (!hasConfirmFlag(input.body) || input.context.explicitUserRequest !== true) {
78
+ throw new OperatorError(
79
+ 'This operation requires explicit user confirmation.',
80
+ REQUIRE_CONFIRM,
81
+ 403,
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Declare one operator method: applies defaults, wraps the handler with a
88
+ * confirm-guard + error mapping, and registers it against the real catalog.
89
+ * Returns the catalog's unregister function.
90
+ */
91
+ export function declareOperatorMethod<TBody, TResult>(
92
+ ctx: OperatorContext,
93
+ descriptor: OperatorMethodDescriptor,
94
+ handler: OperatorHandler<TBody, TResult>,
95
+ ): Unregister {
96
+ const catalogDescriptor = toCatalogDescriptor(descriptor);
97
+ const requiresConfirm = descriptor.confirm === true;
98
+
99
+ const wrappedHandler = async (input: GatewayMethodInvocation): Promise<unknown> => {
100
+ // Normalize the SDK invocation context into the operator-facing shape.
101
+ const explicitUserRequest = explicitUserRequestFlag(input.context.metadata);
102
+ const operatorContext = {
103
+ principalId: input.context.principalId ?? '',
104
+ explicitUserRequest,
105
+ };
106
+ try {
107
+ if (requiresConfirm) {
108
+ assertConfirmed({ body: input.body, context: operatorContext });
109
+ }
110
+ const invocation: OperatorInvocation<TBody> = {
111
+ body: input.body as TBody,
112
+ context: operatorContext,
113
+ };
114
+ return await handler(invocation);
115
+ } catch (error) {
116
+ if (error instanceof OperatorError) throw error;
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new OperatorError(message, 'OPERATOR_HANDLER_FAILED', 500);
119
+ }
120
+ };
121
+
122
+ // CatalogMethodDescriptor is structurally a GatewayMethodDescriptor.
123
+ return ctx.catalog.register(catalogDescriptor as GatewayMethodDescriptor, wrappedHandler);
124
+ }
125
+
126
+ /**
127
+ * Register many methods at once. Returns a single Unregister that tears them all
128
+ * down (invoking each individual unregister fn in reverse registration order).
129
+ */
130
+ export function declareOperatorMethods(
131
+ ctx: OperatorContext,
132
+ entries: Array<{
133
+ descriptor: OperatorMethodDescriptor;
134
+ handler: OperatorHandler<unknown, unknown>;
135
+ }>,
136
+ ): Unregister {
137
+ const unregisters: Unregister[] = [];
138
+ for (const entry of entries) {
139
+ unregisters.push(declareOperatorMethod(ctx, entry.descriptor, entry.handler));
140
+ }
141
+ return () => {
142
+ for (let i = unregisters.length - 1; i >= 0; i -= 1) {
143
+ try {
144
+ unregisters[i]?.();
145
+ } catch {
146
+ // Best-effort teardown: continue unregistering the rest.
147
+ }
148
+ }
149
+ };
150
+ }
@@ -0,0 +1,124 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
2
+ import { readFileSync } from 'node:fs';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import initSqlJs from 'sql.js';
6
+
7
+ type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>;
8
+ type SqlDatabase = InstanceType<SqlJsStatic['Database']>;
9
+
10
+ export interface OperatorSqliteOptions {
11
+ workingDirectory: string;
12
+ /** e.g. 'channel-routes.sqlite', 'drafts.sqlite', 'inbox-cursors.sqlite' */
13
+ fileName: string;
14
+ /** CREATE TABLE / INDEX statements run once on init (idempotent: IF NOT EXISTS). */
15
+ schema: string[];
16
+ }
17
+
18
+ let sqlJsStaticPromise: Promise<SqlJsStatic> | null = null;
19
+
20
+ async function loadSqlJs(): Promise<SqlJsStatic> {
21
+ if (!sqlJsStaticPromise) {
22
+ sqlJsStaticPromise = initSqlJs();
23
+ }
24
+ return sqlJsStaticPromise;
25
+ }
26
+
27
+ /**
28
+ * Daemon sqlite helper following the project MemoryStore lifecycle
29
+ * (sql.js WASM: init → run/exec → save → close). One file per concern under
30
+ * {workingDirectory}/.goodvibes/tui/operator/{fileName}.
31
+ */
32
+ export class OperatorSqliteStore {
33
+ private readonly options: OperatorSqliteOptions;
34
+ private readonly resolvedPath: string;
35
+ private db: SqlDatabase | null = null;
36
+
37
+ constructor(options: OperatorSqliteOptions) {
38
+ this.options = options;
39
+ this.resolvedPath = join(
40
+ options.workingDirectory,
41
+ '.goodvibes',
42
+ 'tui',
43
+ 'operator',
44
+ options.fileName,
45
+ );
46
+ }
47
+
48
+ get dbPath(): string {
49
+ return this.resolvedPath;
50
+ }
51
+
52
+ private requireDb(): SqlDatabase {
53
+ if (!this.db) {
54
+ throw new Error(`OperatorSqliteStore not initialized: ${this.resolvedPath}`);
55
+ }
56
+ return this.db;
57
+ }
58
+
59
+ async init(): Promise<void> {
60
+ if (this.db) return;
61
+ await mkdir(dirname(this.resolvedPath), { recursive: true });
62
+ const SQL = await loadSqlJs();
63
+ const existing = existsSync(this.resolvedPath) ? readFileSync(this.resolvedPath) : undefined;
64
+ this.db = existing ? new SQL.Database(existing) : new SQL.Database();
65
+ for (const statement of this.options.schema) {
66
+ this.db.run(statement);
67
+ }
68
+ }
69
+
70
+ /** Execute a write (INSERT/UPDATE/DELETE/CREATE). */
71
+ run(sql: string, params?: (string | number | Uint8Array | null)[]): void {
72
+ this.requireDb().run(sql, params);
73
+ }
74
+
75
+ /** SELECT → array of row objects (columns mapped to values). */
76
+ all<T = Record<string, unknown>>(sql: string, params?: (string | number)[]): T[] {
77
+ const result = this.requireDb().exec(sql, params);
78
+ if (result.length === 0) return [];
79
+ const { columns, values } = result[0]!;
80
+ return values.map((row) => {
81
+ const obj: Record<string, unknown> = {};
82
+ for (let i = 0; i < columns.length; i += 1) {
83
+ obj[columns[i]!] = row[i];
84
+ }
85
+ return obj as T;
86
+ });
87
+ }
88
+
89
+ /** First row or null. */
90
+ get<T = Record<string, unknown>>(sql: string, params?: (string | number)[]): T | null {
91
+ const rows = this.all<T>(sql, params);
92
+ return rows.length > 0 ? rows[0]! : null;
93
+ }
94
+
95
+ /** Serialize and atomically persist to dbPath (tmp + rename). */
96
+ async save(): Promise<void> {
97
+ const db = this.requireDb();
98
+ await mkdir(dirname(this.resolvedPath), { recursive: true });
99
+ const data = db.export();
100
+ const tmpPath = `${this.resolvedPath}.${process.pid}.${Date.now()}.tmp`;
101
+ await writeFile(tmpPath, data);
102
+ await rename(tmpPath, this.resolvedPath);
103
+ }
104
+
105
+ close(): void {
106
+ if (this.db) {
107
+ this.db.close();
108
+ this.db = null;
109
+ }
110
+ }
111
+
112
+ /** BEGIN/COMMIT around fn; ROLLBACK on throw (synchronous sql.js). */
113
+ transaction(fn: () => void): void {
114
+ const db = this.requireDb();
115
+ db.run('BEGIN');
116
+ try {
117
+ fn();
118
+ db.run('COMMIT');
119
+ } catch (error) {
120
+ db.run('ROLLBACK');
121
+ throw error;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,137 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Daemon operator-surface integration root.
3
+ //
4
+ // `registerDaemonOperatorSurfaces(ctx)` is the single call the runtime services
5
+ // layer (src/runtime/services.ts) makes to publish every daemon operator
6
+ // surface against the shared GatewayMethodCatalog. Each surface module owns its
7
+ // own method declarations; this module only composes their register entry
8
+ // points and aggregates teardown.
9
+ //
10
+ // Surfaces wired here:
11
+ // - channels.routing.* (routing/register.ts)
12
+ // - channels.inbox.list + (triage/integration.ts -> registerTriagedInbox,
13
+ // inbox.triage.* which registers the inbox surface through a
14
+ // catalog proxy that overlays triage scores, plus
15
+ // the inbox.triage.list / .tag methods)
16
+ // - channels.drafts.* (channels/drafts/register.ts)
17
+ // - calendar.* (CalDAV) (calendar/register.ts)
18
+ // - email.* (email/register.ts)
19
+ // - remote.peers.register + (remote/register.ts) — the register method is
20
+ // remote dispatch adapter published on the catalog and the dispatch
21
+ // adapter for the existing remote.peers.invoke
22
+ // route is exposed on the returned handle.
23
+ //
24
+ // Every surface initializes its stores lazily (on first method invocation), so
25
+ // this composition is synchronous and never blocks daemon bootstrap on disk I/O.
26
+ // The remote peer-registry store is the one async initializer; it is kicked off
27
+ // in the background and the dispatch adapter awaits it implicitly on first use.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import type { OperatorContext, Unregister } from './types.ts';
31
+ import { registerRoutingMethods, type RoutingRegistration } from '../channels/routing/register.ts';
32
+ import { createInboxRouteResolver } from '../channels/routing/inbox-bridge.ts';
33
+ import { registerTriagedInbox } from '../triage/integration.ts';
34
+ import { registerDraftsMethods } from '../channels/drafts/register.ts';
35
+ import { registerCalendarMethods } from '../calendar/register.ts';
36
+ import { registerEmailMethods } from '../email/register.ts';
37
+ import {
38
+ createRemoteSurface,
39
+ type RemoteSurface,
40
+ type RemoteInvokeAdapter,
41
+ } from '../remote/register.ts';
42
+
43
+ /**
44
+ * Handles returned to the integrator. `unregister()` tears every surface down in
45
+ * reverse registration order. `remoteDispatch` is the invoke adapter the daemon
46
+ * server attaches to the existing `remote.peers.invoke` route; `remoteSurface`
47
+ * exposes the underlying peer registry / dispatcher for advanced wiring.
48
+ */
49
+ export interface DaemonOperatorSurfaces {
50
+ /** Tear down every registered operator surface (reverse order). */
51
+ readonly unregister: Unregister;
52
+ /** Routing registration handle (store + resolver reuse). */
53
+ readonly routing: RoutingRegistration;
54
+ /** Remote execution surface (peer registry + dispatcher). */
55
+ readonly remoteSurface: RemoteSurface;
56
+ /** Dispatch adapter for the existing remote.peers.invoke route. */
57
+ readonly remoteDispatch: RemoteInvokeAdapter;
58
+ }
59
+
60
+ /**
61
+ * Register every daemon operator surface against the catalog in `ctx`.
62
+ *
63
+ * Synchronous: surfaces defer their disk I/O until first invocation. The only
64
+ * async initializer (the remote peer registry) is started in the background and
65
+ * its failure is logged rather than thrown so daemon bootstrap is never blocked.
66
+ */
67
+ export function registerDaemonOperatorSurfaces(ctx: OperatorContext): DaemonOperatorSurfaces {
68
+ const teardowns: Unregister[] = [];
69
+ const pushTeardown = (fn: Unregister): void => {
70
+ teardowns.push(fn);
71
+ };
72
+
73
+ // Aggregate teardown invoked in reverse registration order; best-effort so one
74
+ // failing teardown never strands the rest.
75
+ const unregister: Unregister = () => {
76
+ for (let i = teardowns.length - 1; i >= 0; i -= 1) {
77
+ try {
78
+ teardowns[i]?.();
79
+ } catch (error) {
80
+ ctx.logger.warn('operator surface teardown failed', {
81
+ message: error instanceof Error ? error.message : String(error),
82
+ });
83
+ }
84
+ }
85
+ };
86
+
87
+ try {
88
+ // 1. Routing control plane (channels.routing.list/assign/delete).
89
+ const routing = registerRoutingMethods(ctx);
90
+ pushTeardown(routing);
91
+
92
+ // 2. Triaged inbox: channels.inbox.list (triage-enriched) + inbox.triage.*.
93
+ // Bridge routing's resolver into the inbox so inbound items resolve their
94
+ // routeId via the channel<->profile bindings (exact > surface-only >
95
+ // wildcard). The resolver is best-effort: a null lookup leaves the item
96
+ // unrouted (offline/default fallback) and never throws.
97
+ pushTeardown(
98
+ registerTriagedInbox(ctx, {
99
+ inbox: { resolveRouteId: createInboxRouteResolver(routing.resolver) },
100
+ }),
101
+ );
102
+
103
+ // 3. Channel drafts mirror (channels.drafts.list/get/save/delete).
104
+ pushTeardown(registerDraftsMethods(ctx));
105
+
106
+ // 4. Calendar (CalDAV) operator methods.
107
+ pushTeardown(registerCalendarMethods(ctx));
108
+
109
+ // 5. Email operator surface (read/list/draft/send).
110
+ pushTeardown(registerEmailMethods(ctx));
111
+
112
+ // 6. Remote execution surface: publish remote.peers.register and build the
113
+ // dispatch adapter for the existing remote.peers.invoke route.
114
+ const remoteSurface = createRemoteSurface(ctx);
115
+ const unregisterRemoteMethod = remoteSurface.register();
116
+ const remoteDispatch = remoteSurface.registerDispatch();
117
+ pushTeardown(() => {
118
+ try {
119
+ unregisterRemoteMethod();
120
+ } finally {
121
+ remoteSurface.close();
122
+ }
123
+ });
124
+ // Background-init the peer registry store; never block bootstrap on it.
125
+ void remoteSurface.init().catch((error: unknown) => {
126
+ ctx.logger.error('remote surface peer-registry init failed', {
127
+ message: error instanceof Error ? error.message : String(error),
128
+ });
129
+ });
130
+
131
+ return { unregister, routing, remoteSurface, remoteDispatch };
132
+ } catch (error) {
133
+ // Roll back any surfaces registered before the failure.
134
+ unregister();
135
+ throw error;
136
+ }
137
+ }
@@ -0,0 +1,207 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type {
3
+ GatewayMethodCatalog,
4
+ GatewayMethodAccess,
5
+ GatewayMethodSource,
6
+ GatewayMethodTransport,
7
+ } from '@pellux/goodvibes-sdk/platform/control-plane';
8
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
+ import type { SecretsManager } from '../../config/secrets.ts';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Access / transport / effect vocabularies
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type OperatorAccess = 'public' | 'authenticated' | 'operator';
16
+ export type OperatorTransport = 'ws' | 'internal' | 'http';
17
+ export type OperatorEffect =
18
+ | 'read-only'
19
+ | 'read-only-network'
20
+ | 'confirmed-effect'
21
+ | 'confirmed-connected-host-state'
22
+ | 'local-state-mutation';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Method descriptor
26
+ //
27
+ // Matches catalog.register() descriptor (services.ts survey). The SDK catalog
28
+ // has NO confirmationRequired / effect fields — confirmation is application-level
29
+ // via request body `confirm:true`. The `effect` and `confirm` fields below are
30
+ // metadata-only here; enforcement happens in the handler against body.confirm.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface OperatorMethodDescriptor {
34
+ id: string;
35
+ title: string;
36
+ description: string;
37
+ category: string;
38
+ source?: string; // default 'daemon'
39
+ access: OperatorAccess;
40
+ transport?: OperatorTransport[]; // default ['ws','internal']
41
+ scopes: string[];
42
+ pluginId?: string;
43
+ inputSchema?: Record<string, unknown>; // JSON Schema
44
+ outputSchema?: Record<string, unknown>; // JSON Schema
45
+ effect?: OperatorEffect; // app-level metadata, not sent to catalog
46
+ confirm?: boolean; // when true, handler MUST require body.confirm === true
47
+ }
48
+
49
+ // The exact descriptor shape the SDK catalog.register() accepts
50
+ // (GatewayMethodDescriptor). `source`/`access`/`transport` use the SDK's narrow
51
+ // unions, so the app-level OperatorAccess 'operator' is mapped to 'admin' and the
52
+ // app-level source 'daemon' is mapped to 'builtin' before registration.
53
+ export interface CatalogMethodDescriptor {
54
+ readonly id: string;
55
+ readonly title: string;
56
+ readonly description: string;
57
+ readonly category: string;
58
+ readonly source: GatewayMethodSource;
59
+ readonly access: GatewayMethodAccess;
60
+ readonly transport: readonly GatewayMethodTransport[];
61
+ readonly scopes: readonly string[];
62
+ readonly pluginId?: string;
63
+ readonly inputSchema?: Record<string, unknown>;
64
+ readonly outputSchema?: Record<string, unknown>;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Handler / invocation
69
+ //
70
+ // Matches catalog handler input: async ({ body, context }) => unknown
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface OperatorInvocation<TBody = unknown> {
74
+ body: TBody;
75
+ context: { principalId: string; explicitUserRequest?: boolean };
76
+ }
77
+
78
+ export type OperatorHandler<TBody = unknown, TResult = unknown> = (
79
+ input: OperatorInvocation<TBody>,
80
+ ) => Promise<TResult>;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Logger
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface OperatorLogger {
87
+ info(msg: string, meta?: unknown): void;
88
+ warn(msg: string, meta?: unknown): void;
89
+ error(msg: string, meta?: unknown): void;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // OperatorContext
94
+ //
95
+ // Context injected into every surface register() by services.ts (the ONE
96
+ // allowed edit there).
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export interface OperatorContext {
100
+ readonly catalog: GatewayMethodCatalog;
101
+ readonly secrets: SecretsManager;
102
+ // Narrow to the read surface actually used by surfaces (get / getCategory).
103
+ readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
104
+ readonly workingDirectory: string;
105
+ readonly homeDirectory: string;
106
+ readonly logger: OperatorLogger;
107
+ }
108
+
109
+ export type Unregister = () => void;
110
+
111
+ // Every surface module's default/contract: register(ctx) => Unregister
112
+ // (call to remove all its methods).
113
+ export type SurfaceRegister = (ctx: OperatorContext) => Unregister;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Errors
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export class OperatorError extends Error {
120
+ readonly code: string;
121
+ readonly status: number;
122
+
123
+ constructor(message: string, code: string, status = 400) {
124
+ super(message);
125
+ this.name = 'OperatorError';
126
+ this.code = code;
127
+ this.status = status;
128
+ }
129
+ }
130
+
131
+ export const REQUIRE_CONFIRM = 'OPERATOR_CONFIRMATION_REQUIRED';
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Handoff domain types (shared across surfaces)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export interface InboundChannelItem {
138
+ id: string;
139
+ surface: string;
140
+ accountId?: string;
141
+ conversationId?: string;
142
+ conversationKind?: 'direct' | 'group' | 'channel' | 'thread' | string;
143
+ fromDigest: string; // sha256First(fromAddress, 16)
144
+ messageDigest: string; // sha256First(messageBody, 12)
145
+ subject?: string;
146
+ snippet?: string;
147
+ receivedAt: string; // ISO-8601
148
+ unread: boolean;
149
+ metadata?: Record<string, unknown>;
150
+ }
151
+
152
+ export interface ChannelRoute {
153
+ id: string;
154
+ surface: string;
155
+ accountId?: string;
156
+ matchKind: 'sender' | 'conversation' | 'keyword' | 'default' | string;
157
+ matchValue: string;
158
+ targetSurface: string;
159
+ targetConversationId?: string;
160
+ priority: number;
161
+ enabled: boolean;
162
+ webhook?: string; // redacted on read via redactWebhook()
163
+ createdAt: string; // ISO-8601
164
+ updatedAt: string; // ISO-8601
165
+ }
166
+
167
+ export interface DraftRecord {
168
+ id: string;
169
+ surface: string;
170
+ accountId?: string;
171
+ conversationId?: string;
172
+ to?: string;
173
+ subject?: string;
174
+ bodyCiphertext: string; // AES-256-GCM, base64(iv|tag|ct) — never plaintext at rest
175
+ status: 'draft' | 'queued' | 'sent' | 'failed' | string;
176
+ createdAt: string; // ISO-8601
177
+ updatedAt: string; // ISO-8601
178
+ metadata?: Record<string, unknown>;
179
+ }
180
+
181
+ export interface CalendarEventSummary {
182
+ id: string;
183
+ calendarId?: string;
184
+ title: string;
185
+ start: string; // ISO-8601
186
+ end: string; // ISO-8601
187
+ allDay?: boolean;
188
+ location?: string;
189
+ organizerDigest?: string; // sha256First(organizer, 16)
190
+ status?: 'confirmed' | 'tentative' | 'cancelled' | string;
191
+ metadata?: Record<string, unknown>;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Digest / redaction conventions (shared pure helpers)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** SHA-256 of input, truncated to the first `hexChars` hex characters. */
199
+ export function sha256First(input: string, hexChars: number): string {
200
+ const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
201
+ return digest.slice(0, Math.max(0, hexChars));
202
+ }
203
+
204
+ /** Returns '[redacted]' when a value is present, otherwise undefined. */
205
+ export function redactWebhook(value?: string): string | undefined {
206
+ return value === undefined || value === null || value === '' ? undefined : '[redacted]';
207
+ }