@pellux/goodvibes-tui 0.24.1 → 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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  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/handlers/calendar/caldav-client.ts +661 -0
  6. package/src/daemon/handlers/calendar/ics.ts +556 -0
  7. package/src/daemon/handlers/calendar/index.ts +316 -0
  8. package/src/daemon/handlers/context.ts +29 -0
  9. package/src/daemon/handlers/contracts.ts +77 -0
  10. package/src/daemon/handlers/credentials.ts +129 -0
  11. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  12. package/src/daemon/handlers/drafts/index.ts +17 -0
  13. package/src/daemon/handlers/drafts/register.ts +331 -0
  14. package/src/daemon/handlers/email/config.ts +164 -0
  15. package/src/daemon/handlers/email/imap-connector.ts +441 -0
  16. package/src/daemon/handlers/email/imap-parsing.ts +499 -0
  17. package/src/daemon/handlers/email/index.ts +43 -0
  18. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  19. package/src/daemon/handlers/email/runtime.ts +140 -0
  20. package/src/daemon/handlers/email/smtp-connector.ts +557 -0
  21. package/src/daemon/handlers/email/validation.ts +147 -0
  22. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  23. package/src/daemon/handlers/errors.ts +18 -0
  24. package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
  25. package/src/daemon/handlers/inbox/index.ts +210 -0
  26. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  27. package/src/daemon/handlers/inbox/poller.ts +155 -0
  28. package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
  29. package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
  30. package/src/daemon/handlers/inbox/providers/email.ts +151 -0
  31. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  32. package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
  33. package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
  34. package/src/daemon/handlers/index.ts +107 -0
  35. package/src/daemon/handlers/register.ts +161 -0
  36. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
  37. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  38. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  39. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  40. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  41. package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
  42. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  43. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  44. package/src/daemon/handlers/remote/index.ts +119 -0
  45. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  46. package/src/daemon/handlers/remote/service.ts +191 -0
  47. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  48. package/src/daemon/handlers/routing/index.ts +261 -0
  49. package/src/daemon/handlers/routing/route-store.ts +319 -0
  50. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  51. package/src/daemon/handlers/sqlite-store.ts +124 -0
  52. package/src/daemon/handlers/triage/index.ts +57 -0
  53. package/src/daemon/handlers/triage/integration.ts +212 -0
  54. package/src/daemon/handlers/triage/pipeline.ts +273 -0
  55. package/src/daemon/handlers/triage/scorer.ts +287 -0
  56. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  57. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  58. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  59. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  60. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  61. package/src/daemon/handlers/triage/types.ts +50 -0
  62. package/src/runtime/services.ts +48 -0
  63. package/src/version.ts +1 -1
@@ -0,0 +1,427 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AtRestCipher } from '../credentials.ts';
3
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Draft Sync Backend store (rebased onto the daemon handler foundation).
7
+ //
8
+ // Mirrors the agent's local draft store (channels/drafts.json) server-side so
9
+ // drafts are visible across surfaces. The agent's local file remains the source
10
+ // of truth; this store is a sync mirror. Conflict resolution (most-recent
11
+ // updatedAt wins) is the integrator's concern — this store performs a plain
12
+ // upsert and records the supplied/derived updatedAt.
13
+ //
14
+ // SECURITY POSTURE:
15
+ // - The plaintext message body is NEVER persisted. It is encrypted at rest
16
+ // via the daemon at-rest cipher (AES-256-GCM) as `bodyEnc`.
17
+ // - The webhook URL is encrypted at rest (`webhookEnc`) and is ALWAYS
18
+ // redacted ('[redacted]') in every list/get response — the raw URL never
19
+ // leaves the store.
20
+ // - `messageDigest` (sha256First(body, 12)) is computed at save time and
21
+ // stored alongside, so reads never need to decrypt the body. The required
22
+ // `message` field of every emitted record carries the DIGEST, never the
23
+ // plaintext body.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** SHA-256 of `input`, truncated to the first `hexChars` hex characters. */
27
+ export function sha256First(input: string, hexChars: number): string {
28
+ const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
29
+ return digest.slice(0, Math.max(0, hexChars));
30
+ }
31
+
32
+ /** Redact a stored webhook for responses: present → '[redacted]', else undefined. */
33
+ export function redactWebhook(value?: string | null): string | undefined {
34
+ return value === undefined || value === null || value === '' ? undefined : '[redacted]';
35
+ }
36
+
37
+ export type DraftStatus = 'draft' | 'queued' | 'sent' | 'failed';
38
+
39
+ /** Statuses callers are permitted to write via save(). */
40
+ export const WRITABLE_DRAFT_STATUSES: readonly DraftStatus[] = ['draft', 'queued'];
41
+
42
+ /** All known statuses (writable + terminal states set by the send pipeline). */
43
+ export const ALL_DRAFT_STATUSES: readonly DraftStatus[] = [
44
+ 'draft',
45
+ 'queued',
46
+ 'sent',
47
+ 'failed',
48
+ ];
49
+
50
+ export const DRAFT_MESSAGE_DIGEST_HEX = 12;
51
+ export const DEFAULT_DRAFT_VERSION = 1;
52
+ export const DEFAULT_DRAFT_LIST_LIMIT = 50;
53
+ export const MAX_DRAFT_LIST_LIMIT = 200;
54
+
55
+ /**
56
+ * Public draft record returned by list/get/save. Matches the SDK
57
+ * CHANNEL_DRAFT_SCHEMA: the required `message` field carries the message
58
+ * DIGEST (never the plaintext body). `webhook` is always '[redacted]' when set.
59
+ * `messageDigest` is emitted ONLY by `get` (whose output schema permits
60
+ * additional properties); strict list/save records omit it.
61
+ */
62
+ export interface DraftRecord {
63
+ version: number;
64
+ id: string;
65
+ createdAt: string; // ISO-8601
66
+ updatedAt: string; // ISO-8601
67
+ status: DraftStatus;
68
+ message: string; // sha256First(body, 12) digest — plaintext body is NEVER transmitted
69
+ title?: string;
70
+ channel?: string;
71
+ route?: string;
72
+ webhook?: string; // '[redacted]' when a webhook is stored, else omitted
73
+ link?: string;
74
+ tags?: string[];
75
+ sentResponseId?: string;
76
+ sendError?: string;
77
+ }
78
+
79
+ /** Normalized input accepted by upsert(). `message` is the FULL plaintext body. */
80
+ export interface DraftSaveInput {
81
+ id?: string;
82
+ version?: number;
83
+ title?: string;
84
+ message: string;
85
+ channel?: string;
86
+ route?: string;
87
+ webhook?: string;
88
+ link?: string;
89
+ tags?: string[];
90
+ status?: DraftStatus;
91
+ createdAt?: string;
92
+ /**
93
+ * Caller-supplied last-modified timestamp (ISO-8601). When provided it is
94
+ * persisted verbatim, enabling the sync contract's 'most recent updatedAt
95
+ * wins' conflict model; when omitted the store stamps updatedAt = now().
96
+ */
97
+ updatedAt?: string;
98
+ }
99
+
100
+ export interface DraftListQuery {
101
+ status?: DraftStatus;
102
+ limit?: number;
103
+ }
104
+
105
+ export interface DraftSaveResult {
106
+ draft: DraftRecord;
107
+ created: boolean;
108
+ }
109
+
110
+ export interface DraftSyncStoreOptions {
111
+ workingDirectory: string;
112
+ cipher: AtRestCipher;
113
+ /** Override the sqlite filename (tests). Defaults to 'drafts.sqlite'. */
114
+ fileName?: string;
115
+ /** UUID generator injection point (tests). Defaults to crypto.randomUUID. */
116
+ generateId?: () => string;
117
+ /** Clock injection point (tests). Defaults to () => new Date().toISOString(). */
118
+ now?: () => string;
119
+ }
120
+
121
+ const SCHEMA: string[] = [
122
+ `CREATE TABLE IF NOT EXISTS drafts (
123
+ id TEXT PRIMARY KEY,
124
+ version INTEGER NOT NULL,
125
+ createdAt TEXT NOT NULL,
126
+ updatedAt TEXT NOT NULL,
127
+ status TEXT NOT NULL,
128
+ title TEXT,
129
+ bodyEnc TEXT NOT NULL,
130
+ messageDigest TEXT NOT NULL,
131
+ channel TEXT,
132
+ route TEXT,
133
+ webhookEnc TEXT,
134
+ link TEXT,
135
+ tags TEXT,
136
+ sentResponseId TEXT,
137
+ sendError TEXT
138
+ )`,
139
+ 'CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts (status)',
140
+ 'CREATE INDEX IF NOT EXISTS idx_drafts_updatedAt ON drafts (updatedAt)',
141
+ ];
142
+
143
+ interface DraftRow {
144
+ id: string;
145
+ version: number;
146
+ createdAt: string;
147
+ updatedAt: string;
148
+ status: string;
149
+ title: string | null;
150
+ messageDigest: string;
151
+ channel: string | null;
152
+ route: string | null;
153
+ webhookEnc: string | null;
154
+ link: string | null;
155
+ tags: string | null;
156
+ sentResponseId: string | null;
157
+ sendError: string | null;
158
+ }
159
+
160
+ function nullable(value: string | undefined): string | null {
161
+ return value === undefined ? null : value;
162
+ }
163
+
164
+ function parseTags(raw: string | null): string[] | undefined {
165
+ if (raw === null || raw === '') return undefined;
166
+ try {
167
+ const parsed = JSON.parse(raw) as unknown;
168
+ if (Array.isArray(parsed)) {
169
+ const tags = parsed.filter((t): t is string => typeof t === 'string');
170
+ return tags.length > 0 ? tags : undefined;
171
+ }
172
+ } catch {
173
+ // Corrupt tags column — treat as absent rather than throwing on read.
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ function normalizeStatus(status: DraftStatus | undefined): DraftStatus {
179
+ return status ?? 'draft';
180
+ }
181
+
182
+ function normalizeVersion(version: number | undefined): number {
183
+ if (version === undefined || !Number.isFinite(version)) return DEFAULT_DRAFT_VERSION;
184
+ const floored = Math.floor(version);
185
+ return floored < 1 ? DEFAULT_DRAFT_VERSION : floored;
186
+ }
187
+
188
+ /**
189
+ * Server-side mirror of agent drafts. Wraps HandlerSqliteStore; all body and
190
+ * webhook material is encrypted at rest via the injected AtRestCipher.
191
+ */
192
+ export class DraftSyncStore {
193
+ private readonly store: HandlerSqliteStore;
194
+ private readonly cipher: AtRestCipher;
195
+ private readonly generateId: () => string;
196
+ private readonly now: () => string;
197
+ private initialized = false;
198
+
199
+ constructor(options: DraftSyncStoreOptions) {
200
+ this.store = new HandlerSqliteStore({
201
+ workingDirectory: options.workingDirectory,
202
+ fileName: options.fileName ?? 'drafts.sqlite',
203
+ schema: SCHEMA,
204
+ });
205
+ this.cipher = options.cipher;
206
+ this.generateId = options.generateId ?? (() => crypto.randomUUID());
207
+ this.now = options.now ?? (() => new Date().toISOString());
208
+ }
209
+
210
+ get dbPath(): string {
211
+ return this.store.dbPath;
212
+ }
213
+
214
+ async init(): Promise<void> {
215
+ if (this.initialized) return;
216
+ await this.store.init();
217
+ this.initialized = true;
218
+ }
219
+
220
+ /** Persist the in-memory db to disk (atomic tmp+rename via the base store). */
221
+ async save(): Promise<void> {
222
+ await this.store.save();
223
+ }
224
+
225
+ close(): void {
226
+ this.store.close();
227
+ this.initialized = false;
228
+ }
229
+
230
+ /**
231
+ * Create (no id) or update (id present) a draft. Encrypts the body and
232
+ * webhook at rest, computes messageDigest, preserves createdAt on update, and
233
+ * sets updatedAt to input.updatedAt when present (enabling 'most recent
234
+ * updatedAt wins') or now() otherwise.
235
+ *
236
+ * FULL-REPLACE SEMANTICS (intentional, per the full-snapshot sync contract):
237
+ * every caller-supplied field is a complete snapshot, NOT a partial patch.
238
+ * The ON CONFLICT UPDATE SET clause assigns each column from the incoming row,
239
+ * so an OMITTED optional field is CLEARED on update (webhookEnc/tags/title/
240
+ * channel/route/link → NULL). The ONLY exceptions are sentResponseId/
241
+ * sendError, which are owned by the send pipeline and survive a save().
242
+ */
243
+ async upsert(input: DraftSaveInput): Promise<DraftSaveResult> {
244
+ const existing = input.id !== undefined ? this.getRow(input.id) : null;
245
+ const created = existing === null;
246
+ const id = existing?.id ?? input.id ?? this.generateId();
247
+ const timestamp = this.now();
248
+ const createdAt = existing?.createdAt ?? input.createdAt ?? timestamp;
249
+ const updatedAt = input.updatedAt ?? timestamp;
250
+ const status = normalizeStatus(input.status);
251
+ const version = normalizeVersion(input.version);
252
+
253
+ const bodyEnc = await this.cipher.encrypt(input.message);
254
+ const messageDigest = sha256First(input.message, DRAFT_MESSAGE_DIGEST_HEX);
255
+ const webhookEnc =
256
+ input.webhook !== undefined && input.webhook !== ''
257
+ ? await this.cipher.encrypt(input.webhook)
258
+ : null;
259
+ const tagsJson =
260
+ input.tags !== undefined && input.tags.length > 0
261
+ ? JSON.stringify(input.tags)
262
+ : null;
263
+
264
+ this.store.run(
265
+ `INSERT INTO drafts (
266
+ id, version, createdAt, updatedAt, status, title, bodyEnc, messageDigest,
267
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
268
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
269
+ ON CONFLICT(id) DO UPDATE SET
270
+ version = excluded.version,
271
+ updatedAt = excluded.updatedAt,
272
+ status = excluded.status,
273
+ title = excluded.title,
274
+ bodyEnc = excluded.bodyEnc,
275
+ messageDigest = excluded.messageDigest,
276
+ channel = excluded.channel,
277
+ route = excluded.route,
278
+ webhookEnc = excluded.webhookEnc,
279
+ link = excluded.link,
280
+ tags = excluded.tags`,
281
+ [
282
+ id,
283
+ version,
284
+ createdAt,
285
+ updatedAt,
286
+ status,
287
+ nullable(input.title),
288
+ bodyEnc,
289
+ messageDigest,
290
+ nullable(input.channel),
291
+ nullable(input.route),
292
+ webhookEnc,
293
+ nullable(input.link),
294
+ tagsJson,
295
+ // sentResponseId / sendError are owned by the send pipeline, not save().
296
+ // On update they are preserved (omitted from UPDATE SET); on insert NULL.
297
+ null,
298
+ null,
299
+ ],
300
+ );
301
+
302
+ const row = this.getRow(id);
303
+ return { draft: toRecord(row ?? this.fallbackRow(id, version, createdAt, updatedAt, status, messageDigest, webhookEnc, input)), created };
304
+ }
305
+
306
+ /** List drafts, newest-updated first, optionally filtered by status. */
307
+ list(query: DraftListQuery = {}): DraftRecord[] {
308
+ const limit = clampLimit(query.limit);
309
+ const rows =
310
+ query.status !== undefined
311
+ ? this.store.all<DraftRow>(
312
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
313
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
314
+ FROM drafts WHERE status = ? ORDER BY updatedAt DESC, id ASC LIMIT ?`,
315
+ [query.status, limit],
316
+ )
317
+ : this.store.all<DraftRow>(
318
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
319
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
320
+ FROM drafts ORDER BY updatedAt DESC, id ASC LIMIT ?`,
321
+ [limit],
322
+ );
323
+ return rows.map((row) => toRecord(row));
324
+ }
325
+
326
+ /** Total number of stored drafts (optionally status-filtered). */
327
+ count(status?: DraftStatus): number {
328
+ const row =
329
+ status !== undefined
330
+ ? this.store.get<{ n: number }>('SELECT COUNT(*) AS n FROM drafts WHERE status = ?', [status])
331
+ : this.store.get<{ n: number }>('SELECT COUNT(*) AS n FROM drafts');
332
+ return row?.n ?? 0;
333
+ }
334
+
335
+ /** Fetch a single draft as a redacted record, or null when absent. */
336
+ get(id: string): DraftRecord | null {
337
+ const row = this.getRow(id);
338
+ return row === null ? null : toRecord(row);
339
+ }
340
+
341
+ /** Delete a draft. Returns true when a row was removed. */
342
+ delete(id: string): boolean {
343
+ const existed = this.getRow(id) !== null;
344
+ if (existed) {
345
+ this.store.run('DELETE FROM drafts WHERE id = ?', [id]);
346
+ }
347
+ return existed;
348
+ }
349
+
350
+ private getRow(id: string): DraftRow | null {
351
+ return this.store.get<DraftRow>(
352
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
353
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
354
+ FROM drafts WHERE id = ?`,
355
+ [id],
356
+ );
357
+ }
358
+
359
+ // Defensive synthesis if a read-back ever returns null (should not happen for
360
+ // the row we just wrote); keeps upsert total even under a degraded backend.
361
+ private fallbackRow(
362
+ id: string,
363
+ version: number,
364
+ createdAt: string,
365
+ updatedAt: string,
366
+ status: DraftStatus,
367
+ messageDigest: string,
368
+ webhookEnc: string | null,
369
+ input: DraftSaveInput,
370
+ ): DraftRow {
371
+ return {
372
+ id,
373
+ version,
374
+ createdAt,
375
+ updatedAt,
376
+ status,
377
+ title: nullable(input.title),
378
+ messageDigest,
379
+ channel: nullable(input.channel),
380
+ route: nullable(input.route),
381
+ webhookEnc,
382
+ link: nullable(input.link),
383
+ tags:
384
+ input.tags !== undefined && input.tags.length > 0
385
+ ? JSON.stringify(input.tags)
386
+ : null,
387
+ sentResponseId: null,
388
+ sendError: null,
389
+ };
390
+ }
391
+ }
392
+
393
+ function clampLimit(limit: number | undefined): number {
394
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_DRAFT_LIST_LIMIT;
395
+ const floored = Math.floor(limit);
396
+ if (floored < 1) return 1;
397
+ if (floored > MAX_DRAFT_LIST_LIMIT) return MAX_DRAFT_LIST_LIMIT;
398
+ return floored;
399
+ }
400
+
401
+ /**
402
+ * Map a stored row to a wire record. The required `message` field carries the
403
+ * DIGEST (never plaintext); the webhook is redacted; tags are parsed.
404
+ */
405
+ export function toRecord(row: DraftRow): DraftRecord {
406
+ const record: DraftRecord = {
407
+ version: row.version,
408
+ id: row.id,
409
+ createdAt: row.createdAt,
410
+ updatedAt: row.updatedAt,
411
+ status: row.status as DraftStatus,
412
+ message: row.messageDigest,
413
+ };
414
+ if (row.title !== null) record.title = row.title;
415
+ if (row.channel !== null) record.channel = row.channel;
416
+ if (row.route !== null) record.route = row.route;
417
+ // Webhook is encrypted at rest; reads ALWAYS redact. Presence of webhookEnc
418
+ // means a webhook exists → emit '[redacted]'; the raw URL never leaves here.
419
+ const redacted = redactWebhook(row.webhookEnc);
420
+ if (redacted !== undefined) record.webhook = redacted;
421
+ if (row.link !== null) record.link = row.link;
422
+ const tags = parseTags(row.tags);
423
+ if (tags !== undefined) record.tags = tags;
424
+ if (row.sentResponseId !== null) record.sentResponseId = row.sentResponseId;
425
+ if (row.sendError !== null) record.sendError = row.sendError;
426
+ return record;
427
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * channels.drafts.* handler surface barrel.
3
+ *
4
+ * Re-exports only the concrete register entrypoint and the store/types other
5
+ * code may need. It imports concrete submodules (no project index barrels), so
6
+ * it introduces no import cycle.
7
+ */
8
+ export { registerDraftMethods } from './register.ts';
9
+ export type { RegisterDraftsOptions } from './register.ts';
10
+ export { DraftSyncStore, sha256First, redactWebhook } from './draft-store.ts';
11
+ export type {
12
+ DraftListQuery,
13
+ DraftRecord,
14
+ DraftSaveInput,
15
+ DraftSaveResult,
16
+ DraftStatus,
17
+ } from './draft-store.ts';