@pellux/goodvibes-tui 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +5 -3
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
  6. package/src/daemon/handlers/calendar/index.ts +316 -0
  7. package/src/daemon/handlers/context.ts +29 -0
  8. package/src/daemon/handlers/contracts.ts +77 -0
  9. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  10. package/src/daemon/handlers/drafts/index.ts +17 -0
  11. package/src/daemon/handlers/drafts/register.ts +331 -0
  12. package/src/daemon/handlers/email/config.ts +164 -0
  13. package/src/daemon/handlers/email/index.ts +43 -0
  14. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  15. package/src/daemon/handlers/email/runtime.ts +140 -0
  16. package/src/daemon/handlers/email/validation.ts +147 -0
  17. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  18. package/src/daemon/handlers/errors.ts +18 -0
  19. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  20. package/src/daemon/handlers/inbox/index.ts +210 -0
  21. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  22. package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
  23. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  24. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
  25. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  26. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
  27. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
  28. package/src/daemon/handlers/index.ts +107 -0
  29. package/src/daemon/handlers/register.ts +161 -0
  30. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  31. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  32. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  33. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  34. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  35. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  36. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  37. package/src/daemon/handlers/remote/index.ts +119 -0
  38. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  39. package/src/daemon/handlers/remote/service.ts +191 -0
  40. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  41. package/src/daemon/handlers/routing/index.ts +261 -0
  42. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  43. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  44. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  45. package/src/daemon/handlers/triage/index.ts +57 -0
  46. package/src/daemon/handlers/triage/integration.ts +212 -0
  47. package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
  48. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  49. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  50. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  51. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  52. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  53. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  54. package/src/daemon/handlers/triage/types.ts +50 -0
  55. package/src/runtime/services.ts +48 -35
  56. package/src/version.ts +1 -1
  57. package/src/daemon/calendar/index.ts +0 -52
  58. package/src/daemon/calendar/register.ts +0 -527
  59. package/src/daemon/channels/drafts/index.ts +0 -22
  60. package/src/daemon/channels/drafts/register.ts +0 -449
  61. package/src/daemon/channels/inbox/index.ts +0 -58
  62. package/src/daemon/channels/inbox/register.ts +0 -247
  63. package/src/daemon/channels/routing/index.ts +0 -39
  64. package/src/daemon/channels/routing/register.ts +0 -296
  65. package/src/daemon/email/index.ts +0 -68
  66. package/src/daemon/email/register.ts +0 -715
  67. package/src/daemon/operator/index.ts +0 -43
  68. package/src/daemon/operator/register-helper.ts +0 -150
  69. package/src/daemon/operator/surfaces.ts +0 -137
  70. package/src/daemon/operator/types.ts +0 -207
  71. package/src/daemon/remote/backends/index.ts +0 -34
  72. package/src/daemon/remote/index.ts +0 -74
  73. package/src/daemon/remote/register.ts +0 -411
  74. package/src/daemon/triage/index.ts +0 -59
  75. package/src/daemon/triage/integration.ts +0 -179
  76. package/src/daemon/triage/register.ts +0 -231
  77. package/src/daemon/triage/tagger.ts +0 -777
  78. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  79. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  80. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  81. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  82. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  83. /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
  84. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -1,9 +1,9 @@
1
- import { OperatorSqliteStore } from '../../operator/index.ts';
2
- import type { AtRestCipher } from '../../operator/index.ts';
3
- import { redactWebhook, sha256First } from '../../operator/index.ts';
1
+ import { createHash } from 'node:crypto';
2
+ import type { AtRestCipher } from '../credentials.ts';
3
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
- // Draft Sync Backend store.
6
+ // Draft Sync Backend store (rebased onto the daemon handler foundation).
7
7
  //
8
8
  // Mirrors the agent's local draft store (channels/drafts.json) server-side so
9
9
  // drafts are visible across surfaces. The agent's local file remains the source
@@ -13,15 +13,27 @@ import { redactWebhook, sha256First } from '../../operator/index.ts';
13
13
  //
14
14
  // SECURITY POSTURE:
15
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`.
16
+ // via the daemon at-rest cipher (AES-256-GCM) as `bodyEnc`.
17
17
  // - The webhook URL is encrypted at rest (`webhookEnc`) and is ALWAYS
18
18
  // redacted ('[redacted]') in every list/get response — the raw URL never
19
19
  // leaves the store.
20
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.
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.
23
24
  // ---------------------------------------------------------------------------
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
+
25
37
  export type DraftStatus = 'draft' | 'queued' | 'sent' | 'failed';
26
38
 
27
39
  /** Statuses callers are permitted to write via save(). */
@@ -36,21 +48,25 @@ export const ALL_DRAFT_STATUSES: readonly DraftStatus[] = [
36
48
  ];
37
49
 
38
50
  export const DRAFT_MESSAGE_DIGEST_HEX = 12;
39
-
51
+ export const DEFAULT_DRAFT_VERSION = 1;
40
52
  export const DEFAULT_DRAFT_LIST_LIMIT = 50;
41
53
  export const MAX_DRAFT_LIST_LIMIT = 200;
42
54
 
43
55
  /**
44
- * Public draft record returned by list/get. The plaintext body is NEVER
45
- * present only `messageDigest`. `webhook` is always '[redacted]' when set.
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.
46
61
  */
47
62
  export interface DraftRecord {
63
+ version: number;
48
64
  id: string;
49
65
  createdAt: string; // ISO-8601
50
66
  updatedAt: string; // ISO-8601
51
67
  status: DraftStatus;
68
+ message: string; // sha256First(body, 12) digest — plaintext body is NEVER transmitted
52
69
  title?: string;
53
- messageDigest: string; // sha256First(body, 12) — body is NOT transmitted
54
70
  channel?: string;
55
71
  route?: string;
56
72
  webhook?: string; // '[redacted]' when a webhook is stored, else omitted
@@ -60,9 +76,10 @@ export interface DraftRecord {
60
76
  sendError?: string;
61
77
  }
62
78
 
63
- /** Normalized input accepted by upsert(). */
79
+ /** Normalized input accepted by upsert(). `message` is the FULL plaintext body. */
64
80
  export interface DraftSaveInput {
65
81
  id?: string;
82
+ version?: number;
66
83
  title?: string;
67
84
  message: string;
68
85
  channel?: string;
@@ -71,13 +88,11 @@ export interface DraftSaveInput {
71
88
  link?: string;
72
89
  tags?: string[];
73
90
  status?: DraftStatus;
91
+ createdAt?: string;
74
92
  /**
75
93
  * 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().
94
+ * persisted verbatim, enabling the sync contract's 'most recent updatedAt
95
+ * wins' conflict model; when omitted the store stamps updatedAt = now().
81
96
  */
82
97
  updatedAt?: string;
83
98
  }
@@ -88,7 +103,7 @@ export interface DraftListQuery {
88
103
  }
89
104
 
90
105
  export interface DraftSaveResult {
91
- id: string;
106
+ draft: DraftRecord;
92
107
  created: boolean;
93
108
  }
94
109
 
@@ -106,6 +121,7 @@ export interface DraftSyncStoreOptions {
106
121
  const SCHEMA: string[] = [
107
122
  `CREATE TABLE IF NOT EXISTS drafts (
108
123
  id TEXT PRIMARY KEY,
124
+ version INTEGER NOT NULL,
109
125
  createdAt TEXT NOT NULL,
110
126
  updatedAt TEXT NOT NULL,
111
127
  status TEXT NOT NULL,
@@ -126,6 +142,7 @@ const SCHEMA: string[] = [
126
142
 
127
143
  interface DraftRow {
128
144
  id: string;
145
+ version: number;
129
146
  createdAt: string;
130
147
  updatedAt: string;
131
148
  status: string;
@@ -162,19 +179,25 @@ function normalizeStatus(status: DraftStatus | undefined): DraftStatus {
162
179
  return status ?? 'draft';
163
180
  }
164
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
+
165
188
  /**
166
- * Server-side mirror of agent drafts. Wraps OperatorSqliteStore; all body and
189
+ * Server-side mirror of agent drafts. Wraps HandlerSqliteStore; all body and
167
190
  * webhook material is encrypted at rest via the injected AtRestCipher.
168
191
  */
169
192
  export class DraftSyncStore {
170
- private readonly store: OperatorSqliteStore;
193
+ private readonly store: HandlerSqliteStore;
171
194
  private readonly cipher: AtRestCipher;
172
195
  private readonly generateId: () => string;
173
196
  private readonly now: () => string;
174
197
  private initialized = false;
175
198
 
176
199
  constructor(options: DraftSyncStoreOptions) {
177
- this.store = new OperatorSqliteStore({
200
+ this.store = new HandlerSqliteStore({
178
201
  workingDirectory: options.workingDirectory,
179
202
  fileName: options.fileName ?? 'drafts.sqlite',
180
203
  schema: SCHEMA,
@@ -206,34 +229,26 @@ export class DraftSyncStore {
206
229
 
207
230
  /**
208
231
  * 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.
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.
213
235
  *
214
236
  * 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.
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().
228
242
  */
229
243
  async upsert(input: DraftSaveInput): Promise<DraftSaveResult> {
230
244
  const existing = input.id !== undefined ? this.getRow(input.id) : null;
231
245
  const created = existing === null;
232
246
  const id = existing?.id ?? input.id ?? this.generateId();
233
247
  const timestamp = this.now();
234
- const createdAt = existing?.createdAt ?? timestamp;
248
+ const createdAt = existing?.createdAt ?? input.createdAt ?? timestamp;
235
249
  const updatedAt = input.updatedAt ?? timestamp;
236
250
  const status = normalizeStatus(input.status);
251
+ const version = normalizeVersion(input.version);
237
252
 
238
253
  const bodyEnc = await this.cipher.encrypt(input.message);
239
254
  const messageDigest = sha256First(input.message, DRAFT_MESSAGE_DIGEST_HEX);
@@ -248,10 +263,11 @@ export class DraftSyncStore {
248
263
 
249
264
  this.store.run(
250
265
  `INSERT INTO drafts (
251
- id, createdAt, updatedAt, status, title, bodyEnc, messageDigest,
266
+ id, version, createdAt, updatedAt, status, title, bodyEnc, messageDigest,
252
267
  channel, route, webhookEnc, link, tags, sentResponseId, sendError
253
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
268
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
254
269
  ON CONFLICT(id) DO UPDATE SET
270
+ version = excluded.version,
255
271
  updatedAt = excluded.updatedAt,
256
272
  status = excluded.status,
257
273
  title = excluded.title,
@@ -264,6 +280,7 @@ export class DraftSyncStore {
264
280
  tags = excluded.tags`,
265
281
  [
266
282
  id,
283
+ version,
267
284
  createdAt,
268
285
  updatedAt,
269
286
  status,
@@ -276,14 +293,14 @@ export class DraftSyncStore {
276
293
  nullable(input.link),
277
294
  tagsJson,
278
295
  // 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.
296
+ // On update they are preserved (omitted from UPDATE SET); on insert NULL.
281
297
  null,
282
298
  null,
283
299
  ],
284
300
  );
285
301
 
286
- return { id, created };
302
+ const row = this.getRow(id);
303
+ return { draft: toRecord(row ?? this.fallbackRow(id, version, createdAt, updatedAt, status, messageDigest, webhookEnc, input)), created };
287
304
  }
288
305
 
289
306
  /** List drafts, newest-updated first, optionally filtered by status. */
@@ -292,13 +309,13 @@ export class DraftSyncStore {
292
309
  const rows =
293
310
  query.status !== undefined
294
311
  ? this.store.all<DraftRow>(
295
- `SELECT id, createdAt, updatedAt, status, title, messageDigest,
312
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
296
313
  channel, route, webhookEnc, link, tags, sentResponseId, sendError
297
314
  FROM drafts WHERE status = ? ORDER BY updatedAt DESC, id ASC LIMIT ?`,
298
315
  [query.status, limit],
299
316
  )
300
317
  : this.store.all<DraftRow>(
301
- `SELECT id, createdAt, updatedAt, status, title, messageDigest,
318
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
302
319
  channel, route, webhookEnc, link, tags, sentResponseId, sendError
303
320
  FROM drafts ORDER BY updatedAt DESC, id ASC LIMIT ?`,
304
321
  [limit],
@@ -306,6 +323,15 @@ export class DraftSyncStore {
306
323
  return rows.map((row) => toRecord(row));
307
324
  }
308
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
+
309
335
  /** Fetch a single draft as a redacted record, or null when absent. */
310
336
  get(id: string): DraftRecord | null {
311
337
  const row = this.getRow(id);
@@ -323,12 +349,45 @@ export class DraftSyncStore {
323
349
 
324
350
  private getRow(id: string): DraftRow | null {
325
351
  return this.store.get<DraftRow>(
326
- `SELECT id, createdAt, updatedAt, status, title, messageDigest,
352
+ `SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
327
353
  channel, route, webhookEnc, link, tags, sentResponseId, sendError
328
354
  FROM drafts WHERE id = ?`,
329
355
  [id],
330
356
  );
331
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
+ }
332
391
  }
333
392
 
334
393
  function clampLimit(limit: number | undefined): number {
@@ -339,20 +398,25 @@ function clampLimit(limit: number | undefined): number {
339
398
  return floored;
340
399
  }
341
400
 
342
- function toRecord(row: DraftRow): DraftRecord {
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 {
343
406
  const record: DraftRecord = {
407
+ version: row.version,
344
408
  id: row.id,
345
409
  createdAt: row.createdAt,
346
410
  updatedAt: row.updatedAt,
347
411
  status: row.status as DraftStatus,
348
- messageDigest: row.messageDigest,
412
+ message: row.messageDigest,
349
413
  };
350
414
  if (row.title !== null) record.title = row.title;
351
415
  if (row.channel !== null) record.channel = row.channel;
352
416
  if (row.route !== null) record.route = row.route;
353
417
  // Webhook is encrypted at rest; reads ALWAYS redact. Presence of webhookEnc
354
418
  // means a webhook exists → emit '[redacted]'; the raw URL never leaves here.
355
- const redacted = redactWebhook(row.webhookEnc ?? undefined);
419
+ const redacted = redactWebhook(row.webhookEnc);
356
420
  if (redacted !== undefined) record.webhook = redacted;
357
421
  if (row.link !== null) record.link = row.link;
358
422
  const tags = parseTags(row.tags);
@@ -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';