@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,140 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared email runtime: credential-backed settings resolution, the IMAP/SMTP
3
+ // connector lifecycle helpers, and the encrypt-at-rest draft store. Built once
4
+ // by index.ts and threaded into the read/write handler builders so the surface
5
+ // has a single owner for its stateful resources (lazy store, AES key).
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import type { HandlerContext, HandlerLogger } from '../context.ts';
9
+ import type { DaemonCredentialStore, AtRestCipher } from '../credentials.ts';
10
+ import { createAtRestCipher } from '../credentials.ts';
11
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
12
+ import {
13
+ resolveEmailSettings,
14
+ defaultImapFactory,
15
+ defaultSmtpFactory,
16
+ type EmailMethodsOptions,
17
+ type ImapClient,
18
+ type ImapFactory,
19
+ type SmtpClient,
20
+ type SmtpFactory,
21
+ } from './config.ts';
22
+
23
+ const DRAFT_STORE_FILE = 'email-drafts.sqlite';
24
+
25
+ const DRAFT_SCHEMA: string[] = [
26
+ `CREATE TABLE IF NOT EXISTS email_drafts (
27
+ id TEXT PRIMARY KEY,
28
+ surface TEXT NOT NULL,
29
+ recipient TEXT,
30
+ subject TEXT,
31
+ body_ciphertext TEXT NOT NULL,
32
+ status TEXT NOT NULL,
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL,
35
+ metadata TEXT
36
+ );`,
37
+ ];
38
+
39
+ /** A persisted draft. The plaintext body is NEVER stored — only its ciphertext. */
40
+ export interface DraftPersistInput {
41
+ readonly id: string;
42
+ readonly to: string;
43
+ readonly subject: string;
44
+ readonly plaintextBody: string;
45
+ readonly status: string;
46
+ readonly createdAt: string;
47
+ readonly updatedAt: string;
48
+ readonly metadata?: Record<string, unknown>;
49
+ }
50
+
51
+ /**
52
+ * Owner of the email surface's stateful resources. Read and write handler
53
+ * builders receive this instead of reaching into the catalog/context directly.
54
+ */
55
+ export interface EmailRuntime {
56
+ readonly logger: HandlerLogger;
57
+ /** Run `fn` with a connected IMAP client; always closes it afterward. */
58
+ withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T>;
59
+ /** Open (or reuse) a connected SMTP client. Caller closes it. */
60
+ openSmtp(): Promise<SmtpClient>;
61
+ /** Resolve the SMTP From / Message-ID domain context without exposing secrets. */
62
+ smtpFrom(): Promise<string>;
63
+ /** Encrypt + persist a draft record (body stored only as ciphertext). */
64
+ persistDraft(input: DraftPersistInput): Promise<void>;
65
+ /** Tear down lazily-created resources (draft store handle). */
66
+ dispose(): void;
67
+ }
68
+
69
+ export function createEmailRuntime(
70
+ ctx: HandlerContext,
71
+ options: EmailMethodsOptions = {},
72
+ ): EmailRuntime {
73
+ const credentials: DaemonCredentialStore = ctx.credentials;
74
+ const cipher: AtRestCipher = createAtRestCipher(credentials);
75
+ const imapFactory: ImapFactory = options.imapFactory ?? defaultImapFactory;
76
+ const smtpFactory: SmtpFactory = options.smtpFactory ?? defaultSmtpFactory;
77
+ const workingDirectory = options.workingDirectory ?? ctx.workingDirectory;
78
+
79
+ let draftStorePromise: Promise<HandlerSqliteStore> | null = null;
80
+ const getDraftStore = (): Promise<HandlerSqliteStore> => {
81
+ if (!draftStorePromise) {
82
+ const store = new HandlerSqliteStore({
83
+ workingDirectory,
84
+ fileName: DRAFT_STORE_FILE,
85
+ schema: DRAFT_SCHEMA,
86
+ });
87
+ draftStorePromise = store.init().then(() => store);
88
+ }
89
+ return draftStorePromise;
90
+ };
91
+
92
+ return {
93
+ logger: ctx.logger,
94
+ async withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T> {
95
+ const { imap: settings } = await resolveEmailSettings(ctx.configManager, credentials);
96
+ const imap = await imapFactory(settings);
97
+ try {
98
+ return await fn(imap);
99
+ } finally {
100
+ await imap.close();
101
+ }
102
+ },
103
+ async openSmtp(): Promise<SmtpClient> {
104
+ const { smtp: settings } = await resolveEmailSettings(ctx.configManager, credentials);
105
+ return smtpFactory(settings);
106
+ },
107
+ async smtpFrom(): Promise<string> {
108
+ const { smtp } = await resolveEmailSettings(ctx.configManager, credentials);
109
+ return smtp.from;
110
+ },
111
+ async persistDraft(input: DraftPersistInput): Promise<void> {
112
+ const bodyCiphertext = await cipher.encrypt(input.plaintextBody);
113
+ const store = await getDraftStore();
114
+ store.run(
115
+ `INSERT OR REPLACE INTO email_drafts
116
+ (id, surface, recipient, subject, body_ciphertext, status,
117
+ created_at, updated_at, metadata)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
119
+ [
120
+ input.id,
121
+ 'email',
122
+ input.to,
123
+ input.subject,
124
+ bodyCiphertext,
125
+ input.status,
126
+ input.createdAt,
127
+ input.updatedAt,
128
+ input.metadata ? JSON.stringify(input.metadata) : null,
129
+ ],
130
+ );
131
+ await store.save();
132
+ },
133
+ dispose(): void {
134
+ if (draftStorePromise) {
135
+ void draftStorePromise.then((store) => store.close()).catch(() => undefined);
136
+ draftStorePromise = null;
137
+ }
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,147 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Input validation, PII-safe digesting, and response shaping for the email
3
+ // handler surface. Pure functions only (no I/O, no secrets).
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import { HandlerError } from '../errors.ts';
8
+ import type { ImapEnvelopeSummary, ImapFullMessage } from './imap-connector.ts';
9
+
10
+ export function asRecord(body: unknown): Record<string, unknown> {
11
+ if (typeof body !== 'object' || body === null) {
12
+ throw new HandlerError('Request body must be an object', 'EMAIL_BAD_INPUT', 400);
13
+ }
14
+ return body as Record<string, unknown>;
15
+ }
16
+
17
+ export function requireString(value: unknown, field: string): string {
18
+ if (typeof value !== 'string' || value.trim().length === 0) {
19
+ throw new HandlerError(`Field '${field}' is required`, 'EMAIL_BAD_INPUT', 400);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ export function optionalString(value: unknown, field: string): string | undefined {
25
+ if (value === undefined || value === null) return undefined;
26
+ if (typeof value !== 'string') {
27
+ throw new HandlerError(`Field '${field}' must be a string`, 'EMAIL_BAD_INPUT', 400);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ /**
33
+ * Extract the addr-spec from an RFC 5322 name-addr ("Display <a@b>"). Reads the
34
+ * contents of the LAST <...> pair so a stray '<' inside a quoted display name
35
+ * does not corrupt the result.
36
+ */
37
+ export function extractAddrSpec(entry: string): string {
38
+ const open = entry.lastIndexOf('<');
39
+ if (open !== -1) {
40
+ const close = entry.indexOf('>', open + 1);
41
+ if (close !== -1) return entry.slice(open + 1, close).trim();
42
+ }
43
+ return entry.trim();
44
+ }
45
+
46
+ export function validateEmailAddress(value: string, field: string): string {
47
+ const entries = value.split(',').map((s) => s.trim()).filter(Boolean);
48
+ if (entries.length === 0 || entries.some((e) => !/.+@.+\..+/.test(extractAddrSpec(e)))) {
49
+ throw new HandlerError(`Field '${field}' must be a valid email address`, 'EMAIL_BAD_INPUT', 400);
50
+ }
51
+ return value;
52
+ }
53
+
54
+ export function clampLimit(value: unknown): number {
55
+ if (value === undefined || value === null) return 10;
56
+ const n = typeof value === 'number' ? value : Number(value);
57
+ if (!Number.isFinite(n)) {
58
+ throw new HandlerError("Field 'limit' must be a number", 'EMAIL_BAD_INPUT', 400);
59
+ }
60
+ return Math.min(100, Math.max(1, Math.floor(n)));
61
+ }
62
+
63
+ export function validateIsoDate(value: unknown): string | undefined {
64
+ const str = optionalString(value, 'since');
65
+ if (str === undefined) return undefined;
66
+ if (Number.isNaN(new Date(str).getTime())) {
67
+ throw new HandlerError("Field 'since' must be an ISO-8601 date", 'EMAIL_BAD_INPUT', 400);
68
+ }
69
+ return str;
70
+ }
71
+
72
+ export function requireUid(value: unknown): number {
73
+ const n = typeof value === 'number' ? value : Number(value);
74
+ if (!Number.isInteger(n) || n <= 0) {
75
+ throw new HandlerError("Field 'uid' must be a positive integer", 'EMAIL_BAD_INPUT', 400);
76
+ }
77
+ return n;
78
+ }
79
+
80
+ /** Reduce an address to a stable, non-reversible digest for PII-safe logging. */
81
+ export function addressDigest(address: string): string {
82
+ return createHash('sha256').update(address.toLowerCase().trim(), 'utf-8').digest('hex').slice(0, 16);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Response contracts (must match EMAIL_INBOX_MESSAGE_SCHEMA /
87
+ // EMAIL_MESSAGE_DETAIL_SCHEMA / {uid,draftId} / {messageId,sentAt}).
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface InboxListResponse {
91
+ messages: Array<{
92
+ uid: number;
93
+ from: string;
94
+ subject: string;
95
+ date: string;
96
+ unread: boolean;
97
+ bodyPreview: string;
98
+ messageId: string;
99
+ }>;
100
+ total: number;
101
+ }
102
+
103
+ export interface InboxReadResponse {
104
+ uid: number;
105
+ from: string;
106
+ subject: string;
107
+ date: string;
108
+ messageId: string;
109
+ bodyText: string;
110
+ bodyHtml?: string;
111
+ attachments?: Array<{ filename: string; contentType: string; sizeBytes: number }>;
112
+ }
113
+
114
+ export interface DraftCreateResponse {
115
+ uid: number;
116
+ draftId: string;
117
+ }
118
+
119
+ export interface SendResponse {
120
+ messageId: string;
121
+ sentAt: string;
122
+ }
123
+
124
+ export function toListMessage(m: ImapEnvelopeSummary): InboxListResponse['messages'][number] {
125
+ return {
126
+ uid: m.uid,
127
+ from: m.from,
128
+ subject: m.subject,
129
+ date: m.date,
130
+ unread: m.unread,
131
+ bodyPreview: m.bodyPreview,
132
+ messageId: m.messageId,
133
+ };
134
+ }
135
+
136
+ export function toReadMessage(m: ImapFullMessage): InboxReadResponse {
137
+ return {
138
+ uid: m.uid,
139
+ from: m.from,
140
+ subject: m.subject,
141
+ date: m.date,
142
+ messageId: m.messageId,
143
+ bodyText: m.bodyText,
144
+ ...(m.bodyHtml ? { bodyHtml: m.bodyHtml } : {}),
145
+ ...(m.attachments && m.attachments.length > 0 ? { attachments: m.attachments } : {}),
146
+ };
147
+ }
@@ -0,0 +1,133 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Write handlers: email.draft.create + email.send.
3
+ //
4
+ // email.draft.create is access:admin (no body.confirm in the SDK contract);
5
+ // email.send is dangerous:true and confirmation-gated — index.ts registers it
6
+ // with { confirm: true }, so the register wrapper enforces body.confirm===true
7
+ // AND explicitUserRequest before this handler runs.
8
+ //
9
+ // Recipients are validated, sender/recipient addresses are reduced to a digest
10
+ // before logging (PII strip), and draft bodies are encrypted at rest by the
11
+ // runtime before persistence. No secret or raw address is echoed into a
12
+ // response. No descriptor or schema is authored here.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { CatalogHandlerEntry, TypedHandler } from '../register.ts';
16
+ import type { EmailRuntime } from './runtime.ts';
17
+ import { buildRfc5322Message, generateMessageId } from './smtp-connector.ts';
18
+ import {
19
+ asRecord,
20
+ requireString,
21
+ optionalString,
22
+ validateEmailAddress,
23
+ addressDigest,
24
+ type DraftCreateResponse,
25
+ type SendResponse,
26
+ } from './validation.ts';
27
+
28
+ interface DraftCreateBody {
29
+ to?: unknown;
30
+ subject?: unknown;
31
+ body?: unknown;
32
+ inReplyTo?: unknown;
33
+ references?: unknown;
34
+ }
35
+
36
+ interface SendBody {
37
+ to?: unknown;
38
+ subject?: unknown;
39
+ body?: unknown;
40
+ inReplyTo?: unknown;
41
+ confirm?: unknown;
42
+ }
43
+
44
+ function draftCreateHandler(runtime: EmailRuntime): TypedHandler<DraftCreateBody, DraftCreateResponse> {
45
+ return async ({ body }) => {
46
+ const input = asRecord(body);
47
+ const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
48
+ const subject = requireString(input.subject, 'subject');
49
+ const draftBody = requireString(input.body, 'body');
50
+ const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
51
+ const references = optionalString(input.references, 'references');
52
+
53
+ const from = await runtime.smtpFrom();
54
+ const messageId = generateMessageId(from);
55
+ const raw = buildRfc5322Message({
56
+ from,
57
+ to,
58
+ subject,
59
+ body: draftBody,
60
+ messageId,
61
+ date: new Date(),
62
+ ...(inReplyTo ? { inReplyTo } : {}),
63
+ ...(references ? { references } : {}),
64
+ });
65
+ const appended = await runtime.withImap((imap) => imap.appendDraft(raw));
66
+
67
+ const now = new Date().toISOString();
68
+ const draftId = messageId.replace(/[<>]/g, '');
69
+ await runtime.persistDraft({
70
+ id: draftId,
71
+ to,
72
+ subject,
73
+ plaintextBody: draftBody,
74
+ status: 'draft',
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ metadata: { uid: appended.uid, mailbox: appended.mailbox, messageId },
78
+ });
79
+
80
+ runtime.logger.info('email.draft.create', {
81
+ uid: appended.uid,
82
+ draftId,
83
+ recipient: addressDigest(to),
84
+ });
85
+ return { uid: appended.uid, draftId };
86
+ };
87
+ }
88
+
89
+ function sendHandler(runtime: EmailRuntime): TypedHandler<SendBody, SendResponse> {
90
+ return async ({ body }) => {
91
+ const input = asRecord(body);
92
+ const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
93
+ const subject = requireString(input.subject, 'subject');
94
+ const sendBody = requireString(input.body, 'body');
95
+ const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
96
+
97
+ const smtp = await runtime.openSmtp();
98
+ let result: SendResponse;
99
+ try {
100
+ result = await smtp.send({
101
+ to,
102
+ subject,
103
+ body: sendBody,
104
+ ...(inReplyTo ? { inReplyTo } : {}),
105
+ });
106
+ } finally {
107
+ await smtp.close();
108
+ }
109
+ runtime.logger.info('email.send', {
110
+ messageId: result.messageId,
111
+ sentAt: result.sentAt,
112
+ recipient: addressDigest(to),
113
+ });
114
+ return result;
115
+ };
116
+ }
117
+
118
+ /** Build the write-handler catalog entries bound to the shared runtime. */
119
+ export function buildWriteHandlerEntries(runtime: EmailRuntime): CatalogHandlerEntry[] {
120
+ return [
121
+ {
122
+ id: 'email.draft.create',
123
+ handler: draftCreateHandler(runtime) as TypedHandler<unknown, unknown>,
124
+ },
125
+ {
126
+ id: 'email.send',
127
+ handler: sendHandler(runtime) as TypedHandler<unknown, unknown>,
128
+ // dangerous:true SDK method — require explicit confirmation (body.confirm
129
+ // === true AND explicitUserRequest) via the register wrapper.
130
+ options: { confirm: true },
131
+ },
132
+ ];
133
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Error type for daemon handler execution. Carries a stable machine `code`
3
+ * and an HTTP `status` so the control-plane dispatcher can map failures to
4
+ * wire responses without leaking implementation detail.
5
+ */
6
+ export class HandlerError extends Error {
7
+ constructor(
8
+ message: string,
9
+ public readonly code: string,
10
+ public readonly status: number,
11
+ ) {
12
+ super(message);
13
+ this.name = 'HandlerError';
14
+ }
15
+ }
16
+
17
+ /** Code emitted when a confirmation-gated method is invoked without explicit user confirmation. */
18
+ export const REQUIRE_CONFIRM = 'REQUIRE_CONFIRM';
@@ -1,21 +1,21 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Persistent cursor + item store for the inbound feed.
3
3
  //
4
- // Backed by OperatorSqliteStore (sql.js WASM) at
4
+ // Backed by HandlerSqliteStore (sql.js WASM) at
5
5
  // {wd}/.goodvibes/tui/operator/inbox.sqlite
6
6
  //
7
7
  // Two tables:
8
8
  // items(id PK, provider, kind, fromDigest, subjectPreview, bodyPreview,
9
- // routeId, receivedAt INT, unread INT, triageScore REAL NULL,
10
- // triageTags TEXT NULL)
9
+ // routeId, receivedAt INT, unread INT)
11
10
  // cursors(provider PK, nextSince INT)
12
11
  //
13
- // Dedup is by items.id (INSERT OR IGNORE / upsert). nextSince advances
14
- // monotonically per provider = max(receivedAt) ever seen. triageScore/
15
- // triageTags columns are written by the triage surface; we only READ them.
12
+ // Dedup is by items.id (upsert). nextSince advances monotonically per provider
13
+ // = max(receivedAt) ever seen. Triage metadata is NOT persisted here: it is
14
+ // applied downstream at the triage overlay layer (triage/integration.ts) over a
15
+ // separate store, so this feed store carries only the raw inbound fields.
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
- import { OperatorSqliteStore } from '../../operator/index.ts';
18
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
19
19
  import type { InboundChannelItem } from './provider-adapter.ts';
20
20
 
21
21
  const SCHEMA: string[] = [
@@ -28,9 +28,7 @@ const SCHEMA: string[] = [
28
28
  bodyPreview TEXT NOT NULL,
29
29
  routeId TEXT,
30
30
  receivedAt INTEGER NOT NULL,
31
- unread INTEGER NOT NULL,
32
- triageScore REAL,
33
- triageTags TEXT
31
+ unread INTEGER NOT NULL
34
32
  )`,
35
33
  `CREATE INDEX IF NOT EXISTS idx_items_provider_received
36
34
  ON items(provider, receivedAt)`,
@@ -52,8 +50,6 @@ interface ItemRow {
52
50
  routeId: string | null;
53
51
  receivedAt: number;
54
52
  unread: number;
55
- triageScore: number | null;
56
- triageTags: string | null;
57
53
  }
58
54
 
59
55
  export interface InboxQuery {
@@ -64,11 +60,11 @@ export interface InboxQuery {
64
60
  }
65
61
 
66
62
  export class InboxCursorStore {
67
- private readonly store: OperatorSqliteStore;
63
+ private readonly store: HandlerSqliteStore;
68
64
  private dirty = false;
69
65
 
70
66
  constructor(workingDirectory: string, fileName = 'inbox.sqlite') {
71
- this.store = new OperatorSqliteStore({
67
+ this.store = new HandlerSqliteStore({
72
68
  workingDirectory,
73
69
  fileName,
74
70
  schema: SCHEMA,
@@ -84,29 +80,39 @@ export class InboxCursorStore {
84
80
  }
85
81
 
86
82
  /**
87
- * Insert/refresh items, deduping by id. Existing rows keep their triage
88
- * columns (written by the triage surface) and are NOT clobbered — only the
89
- * mutable feed fields (unread, previews, routeId, receivedAt) are updated.
83
+ * Insert/refresh items, deduping by id. On conflict the mutable feed fields
84
+ * (unread, previews, routeId, receivedAt) are updated in place.
90
85
  * Returns the number of NEW (previously unseen) items.
91
86
  */
92
87
  upsertItems(items: readonly InboundChannelItem[]): number {
93
88
  if (items.length === 0) return 0;
94
89
  let inserted = 0;
90
+ // Only the ids in THIS batch can collide, so probe for exactly those rather
91
+ // than loading the whole table — bounds the lookup to the poll size instead
92
+ // of growing O(n) with the (unbounded) feed.
93
+ const batchIds = [...new Set(items.map((i) => i.id))];
94
+ const placeholders = batchIds.map(() => '?').join(', ');
95
95
  const existing = new Set(
96
96
  this.store
97
- .all<{ id: string }>('SELECT id FROM items')
97
+ .all<{ id: string }>(
98
+ `SELECT id FROM items WHERE id IN (${placeholders})`,
99
+ batchIds,
100
+ )
98
101
  .map((r) => r.id),
99
102
  );
103
+ // Track ids seen within this batch so a duplicate id in a single poll is
104
+ // counted (and inserted) once, not once per occurrence.
105
+ const seen = new Set<string>();
100
106
  this.store.transaction(() => {
101
107
  for (const item of items) {
102
- const isNew = !existing.has(item.id);
108
+ const isNew = !existing.has(item.id) && !seen.has(item.id);
103
109
  if (isNew) inserted += 1;
104
- // Upsert preserving triage columns: ON CONFLICT updates feed fields only.
110
+ seen.add(item.id);
105
111
  this.store.run(
106
112
  `INSERT INTO items
107
113
  (id, provider, kind, fromDigest, subjectPreview, bodyPreview,
108
- routeId, receivedAt, unread, triageScore, triageTags)
109
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
114
+ routeId, receivedAt, unread)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
110
116
  ON CONFLICT(id) DO UPDATE SET
111
117
  provider = excluded.provider,
112
118
  kind = excluded.kind,
@@ -130,7 +136,7 @@ export class InboxCursorStore {
130
136
  );
131
137
  }
132
138
  });
133
- if (inserted > 0 || items.length > 0) this.dirty = true;
139
+ this.dirty = true;
134
140
  return inserted;
135
141
  }
136
142
 
@@ -152,20 +158,6 @@ export class InboxCursorStore {
152
158
  this.dirty = true;
153
159
  }
154
160
 
155
- /**
156
- * Write triage metadata for an existing item. Provided so the triage surface
157
- * has a typed, scoped path to populate the triageScore/triageTags columns this
158
- * store reads back (no raw SQL needed by callers). No-op when the id is unknown.
159
- */
160
- applyTriage(id: string, triageScore: number | null, triageTags: readonly string[] | null): void {
161
- const tags = triageTags && triageTags.length > 0 ? JSON.stringify(triageTags) : null;
162
- this.store.run(
163
- 'UPDATE items SET triageScore = ?, triageTags = ? WHERE id = ?',
164
- [triageScore, tags, id],
165
- );
166
- this.dirty = true;
167
- }
168
-
169
161
  /** Current cursor for a provider (0 when unset). */
170
162
  getCursor(provider: string): number {
171
163
  const row = this.store.get<{ nextSince: number }>(
@@ -177,7 +169,7 @@ export class InboxCursorStore {
177
169
 
178
170
  /**
179
171
  * Read items for the feed, filtered by provider set + since, newest first,
180
- * capped at limit. Maps SQLite rows back to the wire shape.
172
+ * capped at limit. Maps SQLite rows back to the internal item shape.
181
173
  */
182
174
  listItems(query: InboxQuery): InboundChannelItem[] {
183
175
  const clauses: string[] = [];
@@ -196,7 +188,7 @@ export class InboxCursorStore {
196
188
  params.push(limit);
197
189
  const rows = this.store.all<ItemRow>(
198
190
  `SELECT id, provider, kind, fromDigest, subjectPreview, bodyPreview,
199
- routeId, receivedAt, unread, triageScore, triageTags
191
+ routeId, receivedAt, unread
200
192
  FROM items ${where}
201
193
  ORDER BY receivedAt DESC, id ASC
202
194
  LIMIT ?`,
@@ -221,8 +213,8 @@ export class InboxCursorStore {
221
213
  return row && row.maxReceived != null ? Number(row.maxReceived) : 0;
222
214
  }
223
215
 
224
- /** Count of items per provider for the (optional) provider set. */
225
- countByProvider(providers?: readonly string[]): Map<string, number> {
216
+ /** Total count of items across the (optional) provider set. */
217
+ countItems(providers?: readonly string[]): number {
226
218
  let where = '';
227
219
  const params: (string | number)[] = [];
228
220
  if (providers && providers.length > 0) {
@@ -230,13 +222,33 @@ export class InboxCursorStore {
230
222
  where = `WHERE provider IN (${placeholders})`;
231
223
  params.push(...providers);
232
224
  }
233
- const rows = this.store.all<{ provider: string; n: number }>(
234
- `SELECT provider, COUNT(*) AS n FROM items ${where} GROUP BY provider`,
225
+ const row = this.store.get<{ n: number }>(
226
+ `SELECT COUNT(*) AS n FROM items ${where}`,
235
227
  params,
236
228
  );
237
- const out = new Map<string, number>();
238
- for (const row of rows) out.set(row.provider, Number(row.n));
239
- return out;
229
+ return row ? Number(row.n) : 0;
230
+ }
231
+
232
+ /**
233
+ * Retention: delete items strictly older than `cutoff` (Unix ms), optionally
234
+ * scoped to a provider set. Returns the number of rows removed. Cursors are
235
+ * left untouched so already-consumed watermarks never regress. A non-finite
236
+ * cutoff is a no-op (prevents an accidental whole-table wipe).
237
+ */
238
+ pruneOlderThan(cutoff: number, providers?: readonly string[]): number {
239
+ if (!Number.isFinite(cutoff)) return 0;
240
+ const clauses = ['receivedAt < ?'];
241
+ const params: (string | number)[] = [Math.floor(cutoff)];
242
+ if (providers && providers.length > 0) {
243
+ const placeholders = providers.map(() => '?').join(', ');
244
+ clauses.push(`provider IN (${placeholders})`);
245
+ params.push(...providers);
246
+ }
247
+ const before = this.countItems(providers);
248
+ this.store.run(`DELETE FROM items WHERE ${clauses.join(' AND ')}`, params);
249
+ const removed = before - this.countItems(providers);
250
+ if (removed > 0) this.dirty = true;
251
+ return removed;
240
252
  }
241
253
 
242
254
  /** Persist to disk if anything changed since the last flush. */
@@ -268,11 +280,6 @@ function rowToItem(row: ItemRow): InboundChannelItem {
268
280
  unread: Number(row.unread) !== 0,
269
281
  };
270
282
  if (row.routeId != null) item.routeId = row.routeId;
271
- if (row.triageScore != null) item.triageScore = Number(row.triageScore);
272
- if (row.triageTags != null) {
273
- const tags = parseTags(row.triageTags);
274
- if (tags.length > 0) item.triageTags = tags;
275
- }
276
283
  return item;
277
284
  }
278
285
 
@@ -281,18 +288,3 @@ function normalizeKind(value: string): InboundChannelItem['kind'] {
281
288
  ? value
282
289
  : 'dm';
283
290
  }
284
-
285
- function parseTags(raw: string): string[] {
286
- try {
287
- const parsed = JSON.parse(raw) as unknown;
288
- if (Array.isArray(parsed)) {
289
- return parsed.filter((t): t is string => typeof t === 'string');
290
- }
291
- } catch {
292
- // Fall through to comma-split fallback for non-JSON legacy values.
293
- }
294
- return raw
295
- .split(',')
296
- .map((t) => t.trim())
297
- .filter((t) => t.length > 0);
298
- }