@pellux/goodvibes-agent 1.5.2 → 1.5.5

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.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * channel-draft.ts
3
+ *
4
+ * Agent-side outbox + draft model for channel messages.
5
+ *
6
+ * Provides:
7
+ * - `ChannelDraft` — a composed-but-not-yet-sent message
8
+ * - `saveDraft` — persist a draft to the local JSON store
9
+ * - `listDrafts` — read all drafts
10
+ * - `getDraft` — read one draft by id
11
+ * - `deleteDraft` — remove a draft
12
+ * - `queueDraftToSend` — promote a draft to a confirmed send input, consuming it
13
+ *
14
+ * Sends always route through the EXISTING `deliverAgentChannelMessage` path
15
+ * (which wraps `ChannelDeliveryRouter.deliver` via the agent_channel_send confirm
16
+ * pattern). No new SDK contract is introduced.
17
+ *
18
+ * Persistence: JSON file at {agentRoot}/channels/drafts.json via ShellPathService.
19
+ *
20
+ * SEAM — a future daemon `channels.drafts.*` operator method could mirror this
21
+ * store server-side for multi-surface sync. The local model is the source of
22
+ * truth until that contract is published.
23
+ */
24
+
25
+ import { createHash, randomUUID } from 'node:crypto';
26
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
27
+ import { dirname } from 'node:path';
28
+ import type { ShellPathService } from '@/runtime/index.ts';
29
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
30
+ import type { AgentChannelDeliveryInput } from './channel-delivery.ts';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type ChannelDraftStatus = 'draft' | 'queued' | 'sent' | 'failed';
37
+
38
+ export interface ChannelDraft {
39
+ readonly version: 1;
40
+ readonly id: string;
41
+ readonly createdAt: string;
42
+ readonly updatedAt: string;
43
+ readonly status: ChannelDraftStatus;
44
+ /** Human-readable subject / title. */
45
+ readonly title?: string;
46
+ /** Message body. */
47
+ readonly message: string;
48
+ /** Surface target shorthand: e.g. "slack:ops" */
49
+ readonly channel?: string;
50
+ /** Named route id. */
51
+ readonly route?: string;
52
+ /** Webhook URL (stored redacted-safe — callers must redact before persisting). */
53
+ readonly webhook?: string;
54
+ /** Link address. */
55
+ readonly link?: string;
56
+ /** Optional tags for grouping drafts. */
57
+ readonly tags?: readonly string[];
58
+ /** If the draft was sent, the delivery response id. */
59
+ readonly sentResponseId?: string;
60
+ /** If the draft failed to send, the error message. */
61
+ readonly sendError?: string;
62
+ }
63
+
64
+ export interface ChannelDraftSnapshot {
65
+ readonly path: string;
66
+ readonly exists: boolean;
67
+ readonly drafts: readonly ChannelDraft[];
68
+ readonly parseError?: string;
69
+ }
70
+
71
+ export interface ChannelDraftSaveResult {
72
+ readonly draft: ChannelDraft;
73
+ readonly path: string;
74
+ }
75
+
76
+ export interface ChannelDraftQueueResult {
77
+ /** The draft id that was promoted. */
78
+ readonly draftId: string;
79
+ /** The delivery input ready to pass to deliverAgentChannelMessage. */
80
+ readonly deliveryInput: AgentChannelDeliveryInput;
81
+ /** The updated draft (status: 'queued'). */
82
+ readonly draft: ChannelDraft;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Internal file model
87
+ // ---------------------------------------------------------------------------
88
+
89
+ interface ChannelDraftFile {
90
+ readonly version: 1;
91
+ readonly drafts: readonly ChannelDraft[];
92
+ }
93
+
94
+ type ShellPaths = Pick<ShellPathService, 'resolveUserPath'>;
95
+
96
+ const DRAFT_VERSION = 1 as const;
97
+ const DRAFT_FILE_VERSION = 1 as const;
98
+ const DRAFT_LIMIT = 200;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Path
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export function channelDraftFilePath(shellPaths: ShellPaths): string {
105
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'channels', 'drafts.json');
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Parsing
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function isRecord(value: unknown): value is Record<string, unknown> {
113
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
114
+ }
115
+
116
+ function readString(value: unknown): string {
117
+ return typeof value === 'string' ? value.trim() : '';
118
+ }
119
+
120
+ function readOptString(value: unknown): string | undefined {
121
+ const s = readString(value);
122
+ return s || undefined;
123
+ }
124
+
125
+ function parseDraftStatus(value: unknown): ChannelDraftStatus {
126
+ if (value === 'queued' || value === 'sent' || value === 'failed') return value;
127
+ return 'draft';
128
+ }
129
+
130
+ function parseTags(value: unknown): readonly string[] | undefined {
131
+ if (!Array.isArray(value)) return undefined;
132
+ const tags = value.map(readString).filter(Boolean);
133
+ return tags.length > 0 ? tags : undefined;
134
+ }
135
+
136
+ function parseDraft(value: unknown): ChannelDraft | null {
137
+ if (!isRecord(value) || value.version !== DRAFT_VERSION) return null;
138
+ const id = readString(value.id);
139
+ const createdAt = readString(value.createdAt);
140
+ const updatedAt = readString(value.updatedAt);
141
+ const message = readString(value.message);
142
+ if (!id || !createdAt || !updatedAt || !message) return null;
143
+ if (Number.isNaN(Date.parse(createdAt)) || Number.isNaN(Date.parse(updatedAt))) return null;
144
+ return {
145
+ version: DRAFT_VERSION,
146
+ id,
147
+ createdAt,
148
+ updatedAt,
149
+ status: parseDraftStatus(value.status),
150
+ message,
151
+ ...( readOptString(value.title) ? { title: readOptString(value.title) } : {}),
152
+ ...( readOptString(value.channel) ? { channel: readOptString(value.channel) } : {}),
153
+ ...( readOptString(value.route) ? { route: readOptString(value.route) } : {}),
154
+ ...( readOptString(value.webhook) ? { webhook: readOptString(value.webhook) } : {}),
155
+ ...( readOptString(value.link) ? { link: readOptString(value.link) } : {}),
156
+ ...(parseTags(value.tags) ? { tags: parseTags(value.tags) } : {}),
157
+ ...( readOptString(value.sentResponseId) ? { sentResponseId: readOptString(value.sentResponseId) } : {}),
158
+ ...( readOptString(value.sendError) ? { sendError: readOptString(value.sendError) } : {}),
159
+ };
160
+ }
161
+
162
+ function parseDraftFile(raw: unknown): ChannelDraftFile {
163
+ if (!isRecord(raw)) return { version: DRAFT_FILE_VERSION, drafts: [] };
164
+ const drafts = Array.isArray(raw.drafts)
165
+ ? raw.drafts.map(parseDraft).filter((d): d is ChannelDraft => d !== null)
166
+ : [];
167
+ return { version: DRAFT_FILE_VERSION, drafts };
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Read / write helpers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export function readChannelDrafts(shellPaths: ShellPaths): ChannelDraftSnapshot {
175
+ const path = channelDraftFilePath(shellPaths);
176
+ if (!existsSync(path)) return { path, exists: false, drafts: [] };
177
+ try {
178
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
179
+ return { path, exists: true, drafts: parseDraftFile(parsed).drafts };
180
+ } catch (error) {
181
+ return {
182
+ path,
183
+ exists: true,
184
+ drafts: [],
185
+ parseError: error instanceof Error ? error.message : String(error),
186
+ };
187
+ }
188
+ }
189
+
190
+ function writeDrafts(path: string, drafts: readonly ChannelDraft[]): void {
191
+ const file: ChannelDraftFile = { version: DRAFT_FILE_VERSION, drafts: drafts.slice(0, DRAFT_LIMIT) };
192
+ mkdirSync(dirname(path), { recursive: true });
193
+ const tempPath = `${path}.tmp`;
194
+ writeFileSync(tempPath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
195
+ renameSync(tempPath, path);
196
+ }
197
+
198
+ function generateDraftId(): string {
199
+ const ts = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
200
+ return `draft-${ts}-${randomUUID().slice(0, 8)}`;
201
+ }
202
+
203
+ function draftDigest(message: string): string {
204
+ return createHash('sha256').update(message).digest('hex').slice(0, 12);
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Public API
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Save a new draft or overwrite an existing draft by id.
213
+ *
214
+ * If `id` is provided in `input` and a draft with that id exists, it is
215
+ * updated in-place (updatedAt refreshed). Otherwise a new draft is created.
216
+ */
217
+ export function saveDraft(
218
+ shellPaths: ShellPaths,
219
+ input: {
220
+ readonly id?: string;
221
+ readonly title?: string;
222
+ readonly message: string;
223
+ readonly channel?: string;
224
+ readonly route?: string;
225
+ readonly webhook?: string;
226
+ readonly link?: string;
227
+ readonly tags?: readonly string[];
228
+ readonly status?: ChannelDraftStatus;
229
+ },
230
+ ): ChannelDraftSaveResult {
231
+ const message = input.message.trim();
232
+ if (!message) throw new Error('Draft message is required.');
233
+
234
+ const snapshot = readChannelDrafts(shellPaths);
235
+ const path = channelDraftFilePath(shellPaths);
236
+ const now = new Date().toISOString();
237
+
238
+ // Try to find existing draft to update
239
+ const existingIndex = input.id ? snapshot.drafts.findIndex((d) => d.id === input.id) : -1;
240
+ const existing = existingIndex >= 0 ? snapshot.drafts[existingIndex] : null;
241
+
242
+ const draft: ChannelDraft = {
243
+ version: DRAFT_VERSION,
244
+ id: existing?.id ?? generateDraftId(),
245
+ createdAt: existing?.createdAt ?? now,
246
+ updatedAt: now,
247
+ status: input.status ?? existing?.status ?? 'draft',
248
+ message,
249
+ ...(input.title?.trim() ? { title: input.title.trim() } : {}),
250
+ ...(input.channel?.trim() ? { channel: input.channel.trim() } : {}),
251
+ ...(input.route?.trim() ? { route: input.route.trim() } : {}),
252
+ ...(input.webhook?.trim() ? { webhook: input.webhook.trim() } : {}),
253
+ ...(input.link?.trim() ? { link: input.link.trim() } : {}),
254
+ ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
255
+ };
256
+
257
+ let updatedDrafts: readonly ChannelDraft[];
258
+ if (existingIndex >= 0) {
259
+ updatedDrafts = snapshot.drafts.map((d, i) => (i === existingIndex ? draft : d));
260
+ } else {
261
+ // Prepend new draft (most recent first)
262
+ updatedDrafts = [draft, ...snapshot.drafts];
263
+ }
264
+
265
+ writeDrafts(path, updatedDrafts);
266
+ return { draft, path };
267
+ }
268
+
269
+ /** List all drafts, optionally filtered by status. */
270
+ export function listDrafts(
271
+ shellPaths: ShellPaths,
272
+ options: { readonly status?: ChannelDraftStatus; readonly limit?: number } = {},
273
+ ): ChannelDraftSnapshot {
274
+ const snapshot = readChannelDrafts(shellPaths);
275
+ let drafts = snapshot.drafts;
276
+ if (options.status) {
277
+ drafts = drafts.filter((d) => d.status === options.status);
278
+ }
279
+ if (typeof options.limit === 'number' && options.limit > 0) {
280
+ drafts = drafts.slice(0, options.limit);
281
+ }
282
+ return { ...snapshot, drafts };
283
+ }
284
+
285
+ /** Get a single draft by id. Returns null if not found. */
286
+ export function getDraft(shellPaths: ShellPaths, draftId: string): ChannelDraft | null {
287
+ const snapshot = readChannelDrafts(shellPaths);
288
+ return snapshot.drafts.find((d) => d.id === draftId) ?? null;
289
+ }
290
+
291
+ /** Delete a draft by id. Returns true if deleted, false if not found. */
292
+ export function deleteDraft(shellPaths: ShellPaths, draftId: string): boolean {
293
+ const snapshot = readChannelDrafts(shellPaths);
294
+ const before = snapshot.drafts.length;
295
+ const updated = snapshot.drafts.filter((d) => d.id !== draftId);
296
+ if (updated.length === before) return false;
297
+ writeDrafts(channelDraftFilePath(shellPaths), updated);
298
+ return true;
299
+ }
300
+
301
+ /**
302
+ * Promote a draft to 'queued' status and return an AgentChannelDeliveryInput
303
+ * ready to pass to `deliverAgentChannelMessage`.
304
+ *
305
+ * The draft is updated to status 'queued'. The caller is responsible for
306
+ * calling `deliverAgentChannelMessage` and then `markDraftSent` or
307
+ * `markDraftFailed` to record the outcome.
308
+ *
309
+ * This routes through the EXISTING confirmed send path — no new SDK contract.
310
+ */
311
+ export function queueDraftToSend(
312
+ shellPaths: ShellPaths,
313
+ draftId: string,
314
+ ): ChannelDraftQueueResult {
315
+ const draft = getDraft(shellPaths, draftId);
316
+ if (!draft) throw new Error(`Draft not found: ${draftId}`);
317
+ if (draft.status === 'sent') throw new Error(`Draft ${draftId} is already sent.`);
318
+
319
+ const deliveryInput: AgentChannelDeliveryInput = {
320
+ message: draft.message,
321
+ ...(draft.title ? { title: draft.title } : {}),
322
+ ...(draft.channel ? { channel: draft.channel } : {}),
323
+ ...(draft.route ? { route: draft.route } : {}),
324
+ ...(draft.webhook ? { webhook: draft.webhook } : {}),
325
+ ...(draft.link ? { link: draft.link } : {}),
326
+ };
327
+
328
+ const queuedDraft = saveDraft(shellPaths, {
329
+ id: draft.id,
330
+ ...deliveryInput,
331
+ ...(draft.title ? { title: draft.title } : {}),
332
+ ...(draft.tags ? { tags: draft.tags } : {}),
333
+ status: 'queued',
334
+ }).draft;
335
+
336
+ return { draftId, deliveryInput, draft: queuedDraft };
337
+ }
338
+
339
+ /**
340
+ * Mark a draft as successfully sent. Records the response id from the delivery.
341
+ */
342
+ export function markDraftSent(
343
+ shellPaths: ShellPaths,
344
+ draftId: string,
345
+ responseId?: string,
346
+ ): ChannelDraft {
347
+ const snapshot = readChannelDrafts(shellPaths);
348
+ const existing = snapshot.drafts.find((d) => d.id === draftId);
349
+ if (!existing) throw new Error(`Draft not found: ${draftId}`);
350
+ const now = new Date().toISOString();
351
+ const patched: ChannelDraft = {
352
+ ...existing,
353
+ updatedAt: now,
354
+ status: 'sent',
355
+ ...(responseId ? { sentResponseId: responseId } : {}),
356
+ };
357
+ const updatedDrafts = snapshot.drafts.map((d) => (d.id === draftId ? patched : d));
358
+ writeDrafts(channelDraftFilePath(shellPaths), updatedDrafts);
359
+ return patched;
360
+ }
361
+
362
+ /**
363
+ * Mark a draft as failed to send. Records the error message.
364
+ */
365
+ export function markDraftFailed(
366
+ shellPaths: ShellPaths,
367
+ draftId: string,
368
+ error: string,
369
+ ): ChannelDraft {
370
+ const snapshot = readChannelDrafts(shellPaths);
371
+ const existing = snapshot.drafts.find((d) => d.id === draftId);
372
+ if (!existing) throw new Error(`Draft not found: ${draftId}`);
373
+ const now = new Date().toISOString();
374
+ const patched: ChannelDraft = {
375
+ ...existing,
376
+ updatedAt: now,
377
+ status: 'failed',
378
+ sendError: error,
379
+ };
380
+ const updatedDrafts = snapshot.drafts.map((d) => (d.id === draftId ? patched : d));
381
+ writeDrafts(channelDraftFilePath(shellPaths), updatedDrafts);
382
+ return patched;
383
+ }
384
+
385
+ /** Format a single draft for human-readable display. */
386
+ export function formatChannelDraft(draft: ChannelDraft): string {
387
+ const targetPart = draft.channel
388
+ ? `channel=${draft.channel}`
389
+ : draft.route
390
+ ? `route=${draft.route}`
391
+ : draft.webhook
392
+ ? 'webhook=[redacted]'
393
+ : draft.link
394
+ ? `link=${draft.link}`
395
+ : 'no-target';
396
+ const digest = draftDigest(draft.message);
397
+ const lines = [
398
+ `draft ${draft.id}`,
399
+ ` status: ${draft.status}`,
400
+ ` target: ${targetPart}`,
401
+ ...(draft.title ? [` title: ${draft.title}`] : []),
402
+ ` message: ${draft.message.length} chars sha256:${digest}`,
403
+ ` created: ${draft.createdAt} updated: ${draft.updatedAt}`,
404
+ ...(draft.tags && draft.tags.length > 0 ? [` tags: ${draft.tags.join(', ')}`] : []),
405
+ ...(draft.sentResponseId ? [` sent response: ${draft.sentResponseId}`] : []),
406
+ ...(draft.sendError ? [` error: ${draft.sendError}`] : []),
407
+ ];
408
+ return lines.join('\n');
409
+ }
410
+
411
+ /** Format the draft list summary. */
412
+ export function formatChannelDraftList(snapshot: ChannelDraftSnapshot): string {
413
+ const lines = [
414
+ 'Channel Drafts',
415
+ ` path: ${snapshot.path}`,
416
+ ` total: ${snapshot.drafts.length}`,
417
+ ` status: ${snapshot.parseError ? 'attention' : snapshot.exists ? 'ready' : 'empty'}`,
418
+ ...(snapshot.parseError ? [` parse error: ${snapshot.parseError}`] : []),
419
+ ' policy: drafts are agent-local; sends require queueDraftToSend + deliverAgentChannelMessage with explicit user confirmation',
420
+ '',
421
+ ];
422
+ if (snapshot.drafts.length === 0) {
423
+ lines.push(' no drafts');
424
+ } else {
425
+ for (const draft of snapshot.drafts) {
426
+ const targetPart = draft.channel ?? draft.route ?? (draft.webhook ? 'webhook=[redacted]' : null) ?? draft.link ?? 'no-target';
427
+ lines.push(` [${draft.status}] ${draft.id} target=${targetPart} ${draft.message.slice(0, 60)}${draft.message.length > 60 ? '...' : ''}`);
428
+ }
429
+ }
430
+ return lines.join('\n');
431
+ }