@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,363 @@
1
+ import { OperatorSqliteStore } from '../../operator/index.ts';
2
+ import type { AtRestCipher } from '../../operator/index.ts';
3
+ import { redactWebhook, sha256First } from '../../operator/index.ts';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Draft Sync Backend store.
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) and stored 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 raw body
22
+ // is never included in any response — only the digest.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type DraftStatus = 'draft' | 'queued' | 'sent' | 'failed';
26
+
27
+ /** Statuses callers are permitted to write via save(). */
28
+ export const WRITABLE_DRAFT_STATUSES: readonly DraftStatus[] = ['draft', 'queued'];
29
+
30
+ /** All known statuses (writable + terminal states set by the send pipeline). */
31
+ export const ALL_DRAFT_STATUSES: readonly DraftStatus[] = [
32
+ 'draft',
33
+ 'queued',
34
+ 'sent',
35
+ 'failed',
36
+ ];
37
+
38
+ export const DRAFT_MESSAGE_DIGEST_HEX = 12;
39
+
40
+ export const DEFAULT_DRAFT_LIST_LIMIT = 50;
41
+ export const MAX_DRAFT_LIST_LIMIT = 200;
42
+
43
+ /**
44
+ * Public draft record returned by list/get. The plaintext body is NEVER
45
+ * present — only `messageDigest`. `webhook` is always '[redacted]' when set.
46
+ */
47
+ export interface DraftRecord {
48
+ id: string;
49
+ createdAt: string; // ISO-8601
50
+ updatedAt: string; // ISO-8601
51
+ status: DraftStatus;
52
+ title?: string;
53
+ messageDigest: string; // sha256First(body, 12) — body is NOT transmitted
54
+ channel?: string;
55
+ route?: string;
56
+ webhook?: string; // '[redacted]' when a webhook is stored, else omitted
57
+ link?: string;
58
+ tags?: string[];
59
+ sentResponseId?: string;
60
+ sendError?: string;
61
+ }
62
+
63
+ /** Normalized input accepted by upsert(). */
64
+ export interface DraftSaveInput {
65
+ id?: string;
66
+ title?: string;
67
+ message: string;
68
+ channel?: string;
69
+ route?: string;
70
+ webhook?: string;
71
+ link?: string;
72
+ tags?: string[];
73
+ status?: DraftStatus;
74
+ /**
75
+ * Caller-supplied last-modified timestamp (ISO-8601). When provided it is
76
+ * persisted verbatim, enabling the sync contract's conflict model ('on
77
+ * conflict, most recent updatedAt wins') to be expressed through this store:
78
+ * an integrator can push the agent's authoritative updatedAt rather than
79
+ * having it overwritten by daemon-local time. When omitted, the store stamps
80
+ * updatedAt = now().
81
+ */
82
+ updatedAt?: string;
83
+ }
84
+
85
+ export interface DraftListQuery {
86
+ status?: DraftStatus;
87
+ limit?: number;
88
+ }
89
+
90
+ export interface DraftSaveResult {
91
+ id: string;
92
+ created: boolean;
93
+ }
94
+
95
+ export interface DraftSyncStoreOptions {
96
+ workingDirectory: string;
97
+ cipher: AtRestCipher;
98
+ /** Override the sqlite filename (tests). Defaults to 'drafts.sqlite'. */
99
+ fileName?: string;
100
+ /** UUID generator injection point (tests). Defaults to crypto.randomUUID. */
101
+ generateId?: () => string;
102
+ /** Clock injection point (tests). Defaults to () => new Date().toISOString(). */
103
+ now?: () => string;
104
+ }
105
+
106
+ const SCHEMA: string[] = [
107
+ `CREATE TABLE IF NOT EXISTS drafts (
108
+ id TEXT PRIMARY KEY,
109
+ createdAt TEXT NOT NULL,
110
+ updatedAt TEXT NOT NULL,
111
+ status TEXT NOT NULL,
112
+ title TEXT,
113
+ bodyEnc TEXT NOT NULL,
114
+ messageDigest TEXT NOT NULL,
115
+ channel TEXT,
116
+ route TEXT,
117
+ webhookEnc TEXT,
118
+ link TEXT,
119
+ tags TEXT,
120
+ sentResponseId TEXT,
121
+ sendError TEXT
122
+ )`,
123
+ 'CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts (status)',
124
+ 'CREATE INDEX IF NOT EXISTS idx_drafts_updatedAt ON drafts (updatedAt)',
125
+ ];
126
+
127
+ interface DraftRow {
128
+ id: string;
129
+ createdAt: string;
130
+ updatedAt: string;
131
+ status: string;
132
+ title: string | null;
133
+ messageDigest: string;
134
+ channel: string | null;
135
+ route: string | null;
136
+ webhookEnc: string | null;
137
+ link: string | null;
138
+ tags: string | null;
139
+ sentResponseId: string | null;
140
+ sendError: string | null;
141
+ }
142
+
143
+ function nullable(value: string | undefined): string | null {
144
+ return value === undefined ? null : value;
145
+ }
146
+
147
+ function parseTags(raw: string | null): string[] | undefined {
148
+ if (raw === null || raw === '') return undefined;
149
+ try {
150
+ const parsed = JSON.parse(raw) as unknown;
151
+ if (Array.isArray(parsed)) {
152
+ const tags = parsed.filter((t): t is string => typeof t === 'string');
153
+ return tags.length > 0 ? tags : undefined;
154
+ }
155
+ } catch {
156
+ // Corrupt tags column — treat as absent rather than throwing on read.
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ function normalizeStatus(status: DraftStatus | undefined): DraftStatus {
162
+ return status ?? 'draft';
163
+ }
164
+
165
+ /**
166
+ * Server-side mirror of agent drafts. Wraps OperatorSqliteStore; all body and
167
+ * webhook material is encrypted at rest via the injected AtRestCipher.
168
+ */
169
+ export class DraftSyncStore {
170
+ private readonly store: OperatorSqliteStore;
171
+ private readonly cipher: AtRestCipher;
172
+ private readonly generateId: () => string;
173
+ private readonly now: () => string;
174
+ private initialized = false;
175
+
176
+ constructor(options: DraftSyncStoreOptions) {
177
+ this.store = new OperatorSqliteStore({
178
+ workingDirectory: options.workingDirectory,
179
+ fileName: options.fileName ?? 'drafts.sqlite',
180
+ schema: SCHEMA,
181
+ });
182
+ this.cipher = options.cipher;
183
+ this.generateId = options.generateId ?? (() => crypto.randomUUID());
184
+ this.now = options.now ?? (() => new Date().toISOString());
185
+ }
186
+
187
+ get dbPath(): string {
188
+ return this.store.dbPath;
189
+ }
190
+
191
+ async init(): Promise<void> {
192
+ if (this.initialized) return;
193
+ await this.store.init();
194
+ this.initialized = true;
195
+ }
196
+
197
+ /** Persist the in-memory db to disk (atomic tmp+rename via the base store). */
198
+ async save(): Promise<void> {
199
+ await this.store.save();
200
+ }
201
+
202
+ close(): void {
203
+ this.store.close();
204
+ this.initialized = false;
205
+ }
206
+
207
+ /**
208
+ * Create (no id) or update (id present) a draft. Encrypts the body and
209
+ * webhook at rest, computes messageDigest, and sets updatedAt to the
210
+ * caller-supplied input.updatedAt when present (enabling the 'most recent
211
+ * updatedAt wins' conflict model) or now() otherwise. On update, createdAt is
212
+ * preserved from the existing row.
213
+ *
214
+ * FULL-REPLACE SEMANTICS (intentional, per the full-snapshot sync contract):
215
+ * every caller-supplied field is treated as a complete snapshot, NOT a partial
216
+ * patch. The ON CONFLICT UPDATE SET clause below assigns each column from the
217
+ * incoming row (excluded.*), so on update an OMITTED optional field is cleared,
218
+ * not preserved. Concretely: omitting `webhook` clears any previously-stored
219
+ * webhook (webhookEnc -> NULL); omitting `tags` removes them (tags -> NULL);
220
+ * the same applies to title/channel/route/link. Callers performing a partial
221
+ * edit MUST re-supply every field they want to keep. The ONLY exceptions are
222
+ * sentResponseId/sendError, which are owned by the send pipeline and are
223
+ * deliberately omitted from the UPDATE SET clause so they survive a save().
224
+ *
225
+ * NOTE: caller is responsible for store.save() after a batch of mutations,
226
+ * or this can be invoked through DraftSyncStore.save(). The register layer
227
+ * saves after every mutation.
228
+ */
229
+ async upsert(input: DraftSaveInput): Promise<DraftSaveResult> {
230
+ const existing = input.id !== undefined ? this.getRow(input.id) : null;
231
+ const created = existing === null;
232
+ const id = existing?.id ?? input.id ?? this.generateId();
233
+ const timestamp = this.now();
234
+ const createdAt = existing?.createdAt ?? timestamp;
235
+ const updatedAt = input.updatedAt ?? timestamp;
236
+ const status = normalizeStatus(input.status);
237
+
238
+ const bodyEnc = await this.cipher.encrypt(input.message);
239
+ const messageDigest = sha256First(input.message, DRAFT_MESSAGE_DIGEST_HEX);
240
+ const webhookEnc =
241
+ input.webhook !== undefined && input.webhook !== ''
242
+ ? await this.cipher.encrypt(input.webhook)
243
+ : null;
244
+ const tagsJson =
245
+ input.tags !== undefined && input.tags.length > 0
246
+ ? JSON.stringify(input.tags)
247
+ : null;
248
+
249
+ this.store.run(
250
+ `INSERT INTO drafts (
251
+ id, createdAt, updatedAt, status, title, bodyEnc, messageDigest,
252
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
253
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
254
+ ON CONFLICT(id) DO UPDATE SET
255
+ updatedAt = excluded.updatedAt,
256
+ status = excluded.status,
257
+ title = excluded.title,
258
+ bodyEnc = excluded.bodyEnc,
259
+ messageDigest = excluded.messageDigest,
260
+ channel = excluded.channel,
261
+ route = excluded.route,
262
+ webhookEnc = excluded.webhookEnc,
263
+ link = excluded.link,
264
+ tags = excluded.tags`,
265
+ [
266
+ id,
267
+ createdAt,
268
+ updatedAt,
269
+ status,
270
+ nullable(input.title),
271
+ bodyEnc,
272
+ messageDigest,
273
+ nullable(input.channel),
274
+ nullable(input.route),
275
+ webhookEnc,
276
+ nullable(input.link),
277
+ tagsJson,
278
+ // sentResponseId / sendError are owned by the send pipeline, not save().
279
+ // On update they are preserved (omitted from the UPDATE SET clause); on
280
+ // insert they default to NULL.
281
+ null,
282
+ null,
283
+ ],
284
+ );
285
+
286
+ return { id, created };
287
+ }
288
+
289
+ /** List drafts, newest-updated first, optionally filtered by status. */
290
+ list(query: DraftListQuery = {}): DraftRecord[] {
291
+ const limit = clampLimit(query.limit);
292
+ const rows =
293
+ query.status !== undefined
294
+ ? this.store.all<DraftRow>(
295
+ `SELECT id, createdAt, updatedAt, status, title, messageDigest,
296
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
297
+ FROM drafts WHERE status = ? ORDER BY updatedAt DESC, id ASC LIMIT ?`,
298
+ [query.status, limit],
299
+ )
300
+ : this.store.all<DraftRow>(
301
+ `SELECT id, createdAt, updatedAt, status, title, messageDigest,
302
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
303
+ FROM drafts ORDER BY updatedAt DESC, id ASC LIMIT ?`,
304
+ [limit],
305
+ );
306
+ return rows.map((row) => toRecord(row));
307
+ }
308
+
309
+ /** Fetch a single draft as a redacted record, or null when absent. */
310
+ get(id: string): DraftRecord | null {
311
+ const row = this.getRow(id);
312
+ return row === null ? null : toRecord(row);
313
+ }
314
+
315
+ /** Delete a draft. Returns true when a row was removed. */
316
+ delete(id: string): boolean {
317
+ const existed = this.getRow(id) !== null;
318
+ if (existed) {
319
+ this.store.run('DELETE FROM drafts WHERE id = ?', [id]);
320
+ }
321
+ return existed;
322
+ }
323
+
324
+ private getRow(id: string): DraftRow | null {
325
+ return this.store.get<DraftRow>(
326
+ `SELECT id, createdAt, updatedAt, status, title, messageDigest,
327
+ channel, route, webhookEnc, link, tags, sentResponseId, sendError
328
+ FROM drafts WHERE id = ?`,
329
+ [id],
330
+ );
331
+ }
332
+ }
333
+
334
+ function clampLimit(limit: number | undefined): number {
335
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_DRAFT_LIST_LIMIT;
336
+ const floored = Math.floor(limit);
337
+ if (floored < 1) return 1;
338
+ if (floored > MAX_DRAFT_LIST_LIMIT) return MAX_DRAFT_LIST_LIMIT;
339
+ return floored;
340
+ }
341
+
342
+ function toRecord(row: DraftRow): DraftRecord {
343
+ const record: DraftRecord = {
344
+ id: row.id,
345
+ createdAt: row.createdAt,
346
+ updatedAt: row.updatedAt,
347
+ status: row.status as DraftStatus,
348
+ messageDigest: row.messageDigest,
349
+ };
350
+ if (row.title !== null) record.title = row.title;
351
+ if (row.channel !== null) record.channel = row.channel;
352
+ if (row.route !== null) record.route = row.route;
353
+ // Webhook is encrypted at rest; reads ALWAYS redact. Presence of webhookEnc
354
+ // means a webhook exists → emit '[redacted]'; the raw URL never leaves here.
355
+ const redacted = redactWebhook(row.webhookEnc ?? undefined);
356
+ if (redacted !== undefined) record.webhook = redacted;
357
+ if (row.link !== null) record.link = row.link;
358
+ const tags = parseTags(row.tags);
359
+ if (tags !== undefined) record.tags = tags;
360
+ if (row.sentResponseId !== null) record.sentResponseId = row.sentResponseId;
361
+ if (row.sendError !== null) record.sendError = row.sendError;
362
+ return record;
363
+ }
@@ -0,0 +1,22 @@
1
+ // Surface barrel: Draft Sync Backend (channels.drafts.*).
2
+ // Integration calls registerDraftsMethods(ctx) to publish the four methods.
3
+
4
+ export { registerDraftsMethods } from './register.ts';
5
+ export type { RegisterDraftsOptions } from './register.ts';
6
+
7
+ export { DraftSyncStore } from './draft-store.ts';
8
+ export type {
9
+ DraftRecord,
10
+ DraftStatus,
11
+ DraftSaveInput,
12
+ DraftSaveResult,
13
+ DraftListQuery,
14
+ DraftSyncStoreOptions,
15
+ } from './draft-store.ts';
16
+ export {
17
+ ALL_DRAFT_STATUSES,
18
+ WRITABLE_DRAFT_STATUSES,
19
+ DEFAULT_DRAFT_LIST_LIMIT,
20
+ MAX_DRAFT_LIST_LIMIT,
21
+ DRAFT_MESSAGE_DIGEST_HEX,
22
+ } from './draft-store.ts';