@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/dist/package/main.js +5337 -2190
- package/package.json +2 -3
- package/release/live-verification/live-verification.json +12 -12
- package/release/live-verification/live-verification.md +13 -13
- package/release/release-readiness.json +7 -7
- package/src/agent/channel-draft.ts +431 -0
- package/src/agent/channel-profile-routing.ts +330 -0
- package/src/agent/email/style-reply-lane.ts +179 -0
- package/src/agent/email/style-reply.ts +358 -0
- package/src/agent/memory-prompt.ts +2 -1
- package/src/agent/skill-draft-proposer.ts +3 -3
- package/src/agent/unified-inbox.ts +393 -0
- package/src/tools/agent-harness-comms.ts +378 -0
- package/src/tools/agent-harness-learning-auto-promote.ts +288 -0
- package/src/tools/agent-harness-learning-curator.ts +9 -9
- package/src/tools/agent-harness-mode-catalog.ts +16 -0
- package/src/tools/agent-harness-personal-ops-discovery.ts +2 -1
- package/src/tools/agent-harness-personal-ops-lanes.ts +9 -5
- package/src/tools/agent-harness-remote.ts +220 -0
- package/src/tools/agent-harness-setup-smoke.ts +2 -4
- package/src/tools/agent-harness-tool-schema.ts +80 -1
- package/src/tools/agent-harness-tool-types.ts +18 -0
- package/src/tools/agent-harness-tool.ts +61 -0
- package/src/version.ts +1 -1
|
@@ -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
|
+
}
|