@khirby/plugin-ai-compose 1.0.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.
@@ -0,0 +1,619 @@
1
+ import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
2
+ import {
3
+ LEADS_SERVICE,
4
+ MAIL_THREAD_SERVICE,
5
+ POKELO_CONTEXT_SERVICE,
6
+ type MailThreadServiceLike,
7
+ type PokeloContextServiceLike,
8
+ AppException,
9
+ } from '../../../packages/plugin-host/src';
10
+ import { AiComposeSettingsService } from './ai-compose-settings.service';
11
+
12
+ const CHAR_BUDGET = 14_000;
13
+
14
+ /** Listmonk campaign body formats (visual is editor-only — not generated here). */
15
+ export const NEWSLETTER_CONTENT_TYPES = ['html', 'markdown', 'plain', 'richtext'] as const;
16
+ export type NewsletterContentType = (typeof NEWSLETTER_CONTENT_TYPES)[number];
17
+
18
+ export type LeadsServiceLike = {
19
+ findById(id: string): Promise<{
20
+ id: string;
21
+ title: string | null;
22
+ contactEmail: string | null;
23
+ contactName: string | null;
24
+ formName: string | null;
25
+ value: string | null;
26
+ priority: string | null;
27
+ submission?: {
28
+ data?: Record<string, unknown> | null;
29
+ } | null;
30
+ } | null>;
31
+ };
32
+
33
+ @Injectable()
34
+ export class AiComposeSuggestService {
35
+ private readonly logger = new Logger(AiComposeSuggestService.name);
36
+
37
+ constructor(
38
+ private readonly settings: AiComposeSettingsService,
39
+ @Inject(MAIL_THREAD_SERVICE) private readonly mailThreads: MailThreadServiceLike,
40
+ @Inject(LEADS_SERVICE) private readonly leads: LeadsServiceLike,
41
+ @Optional()
42
+ @Inject(POKELO_CONTEXT_SERVICE)
43
+ private readonly pokeloContext: PokeloContextServiceLike | null = null,
44
+ ) {}
45
+
46
+ async availability(): Promise<{ available: boolean; defaultModel: string | null }> {
47
+ const defaultModel = await this.settings.getDefaultModel();
48
+ try {
49
+ await this.settings.getDecryptedApiKey();
50
+ return { available: true, defaultModel };
51
+ } catch {
52
+ return { available: false, defaultModel };
53
+ }
54
+ }
55
+
56
+ async suggest(input: {
57
+ threadId?: string;
58
+ leadId?: string;
59
+ model?: string;
60
+ instruction?: string;
61
+ }): Promise<{ draft: string; modelUsed: string }> {
62
+ if (!input.threadId && !input.leadId) {
63
+ throw AppException.badRequest('Either threadId or leadId is required');
64
+ }
65
+
66
+ const modelUsed = await this.resolveModel(input.model);
67
+
68
+ const thread = input.threadId ? await this.mailThreads.getThread(input.threadId) : null;
69
+
70
+ let leadContext = '';
71
+ if (input.leadId) {
72
+ const lead = await this.leads.findById(input.leadId);
73
+ if (lead) {
74
+ leadContext = [
75
+ lead.title ? `Lead: ${lead.title}` : '',
76
+ lead.contactName ? `Contact: ${lead.contactName}` : '',
77
+ lead.contactEmail ? `Email: ${lead.contactEmail}` : '',
78
+ lead.value ? `Value: ${lead.value}` : '',
79
+ lead.priority ? `Priority: ${lead.priority}` : '',
80
+ lead.formName ? `Source form: ${lead.formName}` : '',
81
+ formatSubmissionData(lead.submission?.data),
82
+ ]
83
+ .filter(Boolean)
84
+ .join('\n');
85
+ }
86
+ }
87
+
88
+ if (!thread && !leadContext) {
89
+ throw AppException.badRequest('Lead not found — cannot draft without context');
90
+ }
91
+
92
+ const systemPrompt = await this.settings.getSystemPrompt();
93
+ const defaultSystemPrompt = thread
94
+ ? [
95
+ 'You are a professional CRM email assistant.',
96
+ 'Your task is to draft a plain-text reply to the provided email thread.',
97
+ 'Write only the reply body — no subject line, no greeting instructions.',
98
+ 'Match the language of the last inbound message.',
99
+ 'Be concise, professional, and helpful.',
100
+ 'Output only the draft text — no commentary, no meta-notes.',
101
+ ].join(' ')
102
+ : [
103
+ 'You are a professional CRM email assistant.',
104
+ 'Your task is to draft a plain-text first outbound email to a sales lead.',
105
+ 'Write only the email body — no subject line.',
106
+ 'Use the form submission fields as the inbound request — address what the lead actually wrote.',
107
+ 'Match the language of the submission when possible.',
108
+ 'Be concise, professional, and helpful.',
109
+ 'Output only the draft text — no commentary, no meta-notes.',
110
+ ].join(' ');
111
+
112
+ const systemContent = [
113
+ systemPrompt || defaultSystemPrompt,
114
+ input.instruction ? `Additional instruction: ${input.instruction}` : '',
115
+ ]
116
+ .filter(Boolean)
117
+ .join('\n\n');
118
+
119
+ const userContent = thread
120
+ ? buildThreadContext(thread, leadContext)
121
+ : buildLeadOnlyContext(leadContext);
122
+
123
+ const lastMessage = thread?.messages?.slice(-1)?.[0]?.bodyText?.slice(0, 300) ?? '';
124
+ const ragQuery = [input.instruction ?? '', leadContext.slice(0, 500), lastMessage]
125
+ .filter(Boolean)
126
+ .join(' ');
127
+
128
+ const draft = await this.completeChat({ modelUsed, systemContent, userContent, ragQuery });
129
+ return { draft, modelUsed };
130
+ }
131
+
132
+ /**
133
+ * Free-form newsletter campaign body for Listmonk (and similar).
134
+ * Output format follows `contentType` — never wraps in markdown fences.
135
+ */
136
+ async generateNewsletter(input: {
137
+ contentType: NewsletterContentType;
138
+ name?: string;
139
+ subject?: string;
140
+ instruction?: string;
141
+ existingBody?: string;
142
+ /** Selected Listmonk template name — body is injected into {{ template }} slot. */
143
+ templateName?: string;
144
+ model?: string;
145
+ }): Promise<{ draft: string; modelUsed: string }> {
146
+ if (!NEWSLETTER_CONTENT_TYPES.includes(input.contentType)) {
147
+ throw AppException.badRequest(
148
+ `contentType must be one of: ${NEWSLETTER_CONTENT_TYPES.join(', ')}`,
149
+ );
150
+ }
151
+
152
+ const brief = (input.instruction ?? '').trim();
153
+ const hasContext =
154
+ brief.length > 0 ||
155
+ Boolean(input.name?.trim()) ||
156
+ Boolean(input.subject?.trim()) ||
157
+ Boolean(input.existingBody?.trim());
158
+ if (!hasContext) {
159
+ throw AppException.badRequest(
160
+ 'Provide an instruction, campaign name/subject, or existing body to generate from',
161
+ );
162
+ }
163
+
164
+ const modelUsed = await this.resolveModel(input.model);
165
+ const formatSpec = formatSpecFor(input.contentType);
166
+ const systemPrompt = await this.settings.getSystemPrompt();
167
+
168
+ const systemContent = [
169
+ systemPrompt ||
170
+ [
171
+ 'You are a professional newsletter copywriter for Listmonk campaigns.',
172
+ 'Write ONLY the campaign body fragment that will be injected into an existing email template.',
173
+ 'The template already provides chrome (header bar, footer, brand note, outer layout) — never recreate those.',
174
+ 'Do not invent a subject line as a separate field; put copy in the body only.',
175
+ 'Match the language of the user brief when possible.',
176
+ 'Output only the body — no commentary, no meta-notes, no markdown code fences.',
177
+ ].join(' '),
178
+ formatSpec,
179
+ brief ? `Additional instruction: ${brief}` : '',
180
+ ]
181
+ .filter(Boolean)
182
+ .join('\n\n');
183
+
184
+ const userParts = [
185
+ 'Write a Listmonk campaign body fragment with this context:',
186
+ input.name?.trim() ? `Campaign name: ${input.name.trim()}` : '',
187
+ input.subject?.trim()
188
+ ? `Subject line (for tone only — do not output it): ${input.subject.trim()}`
189
+ : '',
190
+ input.templateName?.trim()
191
+ ? `Injected into Listmonk template: ${input.templateName.trim()} (header/footer already in template)`
192
+ : 'Injected into a Listmonk email template (header/footer already in template)',
193
+ `Required output format: ${input.contentType}`,
194
+ input.existingBody?.trim()
195
+ ? [
196
+ '',
197
+ 'Existing draft to improve or rewrite (keep intent unless the instruction says otherwise):',
198
+ input.existingBody.trim().slice(0, CHAR_BUDGET),
199
+ ].join('\n')
200
+ : '',
201
+ brief ? ['', 'Brief:', brief].join('\n') : '',
202
+ '',
203
+ 'Return only the body fragment in the required format.',
204
+ ].filter(Boolean);
205
+
206
+ const ragQuery = [input.instruction ?? '', input.name ?? '', input.subject ?? '']
207
+ .filter(Boolean)
208
+ .join(' ');
209
+
210
+ const draft = stripCodeFences(
211
+ await this.completeChat({
212
+ modelUsed,
213
+ systemContent,
214
+ userContent: userParts.join('\n'),
215
+ ragQuery,
216
+ }),
217
+ );
218
+ return { draft, modelUsed };
219
+ }
220
+
221
+ async fetchModels(baseUrl: string, apiKey: string): Promise<{ id: string; label: string }[]> {
222
+ const response = await fetch(`${baseUrl}/models`, {
223
+ headers: { Authorization: `Bearer ${apiKey}` },
224
+ });
225
+
226
+ if (!response.ok) {
227
+ const errorText = await response.text().catch(() => 'unknown error');
228
+ throw AppException.badRequest(
229
+ `Failed to fetch models from provider: ${response.status} — ${errorText.slice(0, 200)}`,
230
+ );
231
+ }
232
+
233
+ const data = (await response.json()) as {
234
+ data?: Array<{ id: string; object?: string }>;
235
+ };
236
+
237
+ return (data?.data ?? [])
238
+ .filter((m) => m.object === 'model' || !m.object)
239
+ .map((m) => ({ id: m.id, label: m.id }));
240
+ }
241
+
242
+ /** Allowed models for compose UIs that are not integrations admins. */
243
+ async getComposeModels(): Promise<{
244
+ models: { id: string; label: string }[];
245
+ defaultModel: string | null;
246
+ }> {
247
+ const defaultModel = await this.settings.getDefaultModel();
248
+ const allowedModels = await this.settings.getAllowedModels();
249
+ const toEntries = (ids: string[]) => ids.map((m) => ({ id: m, label: m }));
250
+
251
+ if (allowedModels.length === 0) {
252
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey().catch(() => ({
253
+ apiKey: '',
254
+ baseUrl: '',
255
+ }));
256
+ if (apiKey) {
257
+ try {
258
+ const all = await this.fetchModels(baseUrl, apiKey);
259
+ return { models: all, defaultModel };
260
+ } catch {
261
+ return { models: [], defaultModel };
262
+ }
263
+ }
264
+ return { models: [], defaultModel };
265
+ }
266
+
267
+ try {
268
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
269
+ const allModels = await this.fetchModels(baseUrl, apiKey);
270
+ const allIds = new Set(allModels.map((m) => m.id));
271
+ const models = allowedModels
272
+ .filter((m) => allIds.has(m))
273
+ .map((m) => {
274
+ const found = allModels.find((x) => x.id === m);
275
+ return { id: m, label: found?.label ?? m };
276
+ });
277
+ return { models, defaultModel };
278
+ } catch {
279
+ return { models: toEntries(allowedModels), defaultModel };
280
+ }
281
+ }
282
+
283
+ private async resolveModel(requested?: string): Promise<string> {
284
+ const allowedModels = await this.settings.getAllowedModels();
285
+ const defaultModel = await this.settings.getDefaultModel();
286
+
287
+ if (requested) {
288
+ if (allowedModels.length > 0 && !allowedModels.includes(requested)) {
289
+ throw AppException.badRequest(`Model "${requested}" is not in the allowed models list`);
290
+ }
291
+ return requested;
292
+ }
293
+ if (defaultModel) return defaultModel;
294
+ if (allowedModels.length > 0) return allowedModels[0];
295
+ throw AppException.badRequest('No model specified and no default model configured');
296
+ }
297
+
298
+ private async completeChat(input: {
299
+ modelUsed: string;
300
+ systemContent: string;
301
+ userContent: string;
302
+ /** Preferred RAG query; defaults to a slice of userContent. */
303
+ ragQuery?: string;
304
+ }): Promise<string> {
305
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
306
+
307
+ let pokeloSnippets = '';
308
+ if (this.pokeloContext) {
309
+ const query = (input.ragQuery ?? input.userContent).slice(0, 800);
310
+ pokeloSnippets = await this.resolvePokeloSnippets({
311
+ query,
312
+ apiKey,
313
+ baseUrl,
314
+ modelUsed: input.modelUsed,
315
+ }).catch(() => '');
316
+ }
317
+
318
+ const systemContent = [input.systemContent, pokeloSnippets].filter(Boolean).join('\n\n');
319
+
320
+ const response = await fetch(`${baseUrl}/chat/completions`, {
321
+ method: 'POST',
322
+ headers: {
323
+ 'Content-Type': 'application/json',
324
+ Authorization: `Bearer ${apiKey}`,
325
+ },
326
+ body: JSON.stringify({
327
+ model: input.modelUsed,
328
+ messages: [
329
+ { role: 'system', content: systemContent },
330
+ { role: 'user', content: input.userContent },
331
+ ],
332
+ temperature: 0.7,
333
+ }),
334
+ });
335
+
336
+ if (!response.ok) {
337
+ const errorText = await response.text().catch(() => 'unknown error');
338
+ this.logger.error(`AI provider error ${response.status}: ${errorText}`);
339
+ throw AppException.badRequest(
340
+ `AI provider returned ${response.status}: ${errorText.slice(0, 200)}`,
341
+ );
342
+ }
343
+
344
+ const data = (await response.json()) as {
345
+ choices?: Array<{ message?: { content?: string } }>;
346
+ };
347
+
348
+ const draft = data?.choices?.[0]?.message?.content?.trim() ?? '';
349
+ if (!draft) {
350
+ throw AppException.badRequest('AI provider returned an empty response');
351
+ }
352
+ return draft;
353
+ }
354
+
355
+ /**
356
+ * Multi-project Pokelo RAG (ADR-0022):
357
+ * - 0 bound → ''
358
+ * - 1–2 bound → direct search (no extra LLM round-trip)
359
+ * - 3+ → cheap router LLM picks primary (+ optional followUp), then search
360
+ */
361
+ private async resolvePokeloSnippets(input: {
362
+ query: string;
363
+ apiKey: string;
364
+ baseUrl: string;
365
+ modelUsed: string;
366
+ }): Promise<string> {
367
+ if (!this.pokeloContext) return '';
368
+
369
+ const bound = (await this.pokeloContext.listBoundProjects?.().catch(() => [])) ?? [];
370
+ if (bound.length === 0) {
371
+ return this.pokeloContext.fetchContext(input.query).catch(() => '');
372
+ }
373
+
374
+ // One or two projects: search them all — router adds latency/cost for little gain
375
+ // and some providers reject the router's stricter completion params (HTTP 400).
376
+ if (bound.length <= 2) {
377
+ return this.pokeloContext.fetchContext(input.query, {
378
+ projectIds: bound.map((p) => p.id),
379
+ });
380
+ }
381
+
382
+ const route = await this.routePokeloProjects({
383
+ query: input.query,
384
+ projects: bound,
385
+ apiKey: input.apiKey,
386
+ baseUrl: input.baseUrl,
387
+ modelUsed: input.modelUsed,
388
+ });
389
+
390
+ const primaryIds = route.primary.length > 0 ? route.primary : [bound[0].id];
391
+ let snippets = await this.pokeloContext.fetchContext(input.query, {
392
+ projectIds: primaryIds,
393
+ });
394
+
395
+ // Second pass: also pull from another brand/project when the router asked for it.
396
+ const followUp = route.followUp.filter((id) => !primaryIds.includes(id));
397
+ if (followUp.length > 0) {
398
+ const more = await this.pokeloContext.fetchContext(input.query, {
399
+ projectIds: followUp,
400
+ });
401
+ if (more) {
402
+ snippets = [snippets, more].filter(Boolean).join('\n\n');
403
+ }
404
+ }
405
+
406
+ return snippets;
407
+ }
408
+
409
+ private async routePokeloProjects(input: {
410
+ query: string;
411
+ projects: Array<{ id: string; name: string }>;
412
+ apiKey: string;
413
+ baseUrl: string;
414
+ modelUsed: string;
415
+ }): Promise<{ primary: string[]; followUp: string[] }> {
416
+ const catalog = input.projects.map((p) => `- ${p.name} (${p.id})`).join('\n');
417
+
418
+ const system = [
419
+ 'You route knowledge-base lookups for a CRM AI assistant.',
420
+ 'Given a drafting query and available Pokelo projects (brands/products),',
421
+ 'choose which projects to search.',
422
+ 'Return ONLY compact JSON: {"primary":["uuid",...],"followUp":["uuid",...]}',
423
+ 'Rules:',
424
+ '- primary: 1–2 most relevant projects to search first',
425
+ '- followUp: 0–1 extra project if a second brand/product may add useful context',
426
+ '- use only IDs from the catalog',
427
+ '- if unsure, put the broadest/most central project in primary and leave followUp empty',
428
+ ].join(' ');
429
+
430
+ const user = [
431
+ 'Available projects:',
432
+ catalog,
433
+ '',
434
+ 'Drafting query:',
435
+ input.query.slice(0, 800),
436
+ ].join('\n');
437
+
438
+ try {
439
+ // Keep the body aligned with completeChat — many OpenAI-compatible providers
440
+ // reject max_tokens and/or temperature: 0 (HTTP 400) while accepting the draft call.
441
+ const response = await fetch(`${input.baseUrl}/chat/completions`, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/json',
445
+ Authorization: `Bearer ${input.apiKey}`,
446
+ },
447
+ body: JSON.stringify({
448
+ model: input.modelUsed,
449
+ messages: [
450
+ { role: 'system', content: system },
451
+ { role: 'user', content: user },
452
+ ],
453
+ temperature: 0.7,
454
+ }),
455
+ });
456
+
457
+ if (!response.ok) {
458
+ const errText = await response.text().catch(() => '');
459
+ this.logger.warn(
460
+ `Pokelo router HTTP ${response.status} — falling back to all projects: ${errText.slice(0, 300)}`,
461
+ );
462
+ return {
463
+ primary: input.projects.slice(0, 2).map((p) => p.id),
464
+ followUp: input.projects.slice(2, 3).map((p) => p.id),
465
+ };
466
+ }
467
+
468
+ const data = (await response.json()) as {
469
+ choices?: Array<{ message?: { content?: string } }>;
470
+ };
471
+ const raw = data?.choices?.[0]?.message?.content ?? '';
472
+ return parsePokeloRoute(
473
+ raw,
474
+ input.projects.map((p) => p.id),
475
+ );
476
+ } catch (err) {
477
+ this.logger.warn(`Pokelo router failed: ${(err as Error).message}`);
478
+ return {
479
+ primary: input.projects.slice(0, 2).map((p) => p.id),
480
+ followUp: input.projects.slice(2, 3).map((p) => p.id),
481
+ };
482
+ }
483
+ }
484
+ }
485
+
486
+ /** Exported for unit tests. */
487
+ export function parsePokeloRoute(
488
+ raw: string,
489
+ allowedIds: string[],
490
+ ): { primary: string[]; followUp: string[] } {
491
+ const allowed = new Set(allowedIds);
492
+ const empty = { primary: [] as string[], followUp: [] as string[] };
493
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
494
+ if (!jsonMatch) return empty;
495
+ try {
496
+ const parsed = JSON.parse(jsonMatch[0]) as {
497
+ primary?: unknown;
498
+ followUp?: unknown;
499
+ };
500
+ const pick = (v: unknown, max: number) =>
501
+ (Array.isArray(v) ? v : [])
502
+ .filter((id): id is string => typeof id === 'string' && allowed.has(id))
503
+ .filter((id, i, arr) => arr.indexOf(id) === i)
504
+ .slice(0, max);
505
+ return {
506
+ primary: pick(parsed.primary, 2),
507
+ followUp: pick(parsed.followUp, 1),
508
+ };
509
+ } catch {
510
+ return empty;
511
+ }
512
+ }
513
+
514
+ function formatSpecFor(contentType: NewsletterContentType): string {
515
+ switch (contentType) {
516
+ case 'html':
517
+ return [
518
+ 'Output format: HTML fragment for a Listmonk template body slot.',
519
+ 'Shape the copy like this structure (adapt wording to the brief):',
520
+ '<h1>…</h1> then <p>…</p>, optional <div class="note-box"> with <strong>…</strong> and <ul><li>…</li></ul>,',
521
+ 'closing <p>…</p>, and a CTA <p><a href="https://example.com@TrackLink">Label →</a></p>.',
522
+ 'Use class="note-box" for callout blocks when highlighting a short list or tip.',
523
+ 'For trackable CTAs append @TrackLink to the href (Listmonk tracking), e.g. https://app.example.com/register@TrackLink.',
524
+ 'Do NOT output <html>, <head>, <body>, outer wrappers, header bars, or footer brand boxes — those live in the template.',
525
+ 'Do not wrap the answer in markdown code fences.',
526
+ ].join(' ');
527
+ case 'markdown':
528
+ return [
529
+ 'Output format: Markdown body fragment for a Listmonk template.',
530
+ 'Use headings, paragraphs, lists, and links only — no HTML chrome, no fenced code block wrapper.',
531
+ 'The template already supplies header/footer; write inner content only.',
532
+ ].join(' ');
533
+ case 'plain':
534
+ return [
535
+ 'Output format: plain text body fragment for a Listmonk template.',
536
+ 'No HTML tags, no Markdown syntax. Blank lines between paragraphs.',
537
+ 'No header/footer chrome — template provides that.',
538
+ ].join(' ');
539
+ case 'richtext':
540
+ return [
541
+ 'Output format: simple HTML richtext fragment for Listmonk (paragraphs, bold/italic, lists, links).',
542
+ 'No full document, no template chrome, no scripts. Do not wrap in markdown code fences.',
543
+ ].join(' ');
544
+ }
545
+ }
546
+
547
+ /** Models often wrap output in ```html … ``` — strip for paste-into-editor use. */
548
+ export function stripCodeFences(text: string): string {
549
+ const trimmed = text.trim();
550
+ const matched = trimmed.match(/^```(?:[a-zA-Z0-9_-]+)?\s*\n?([\s\S]*?)\n?```$/);
551
+ return matched ? matched[1].trim() : trimmed;
552
+ }
553
+
554
+ function buildThreadContext(
555
+ thread: Awaited<ReturnType<MailThreadServiceLike['getThread']>>,
556
+ leadContext: string,
557
+ ): string {
558
+ const parts: string[] = [];
559
+
560
+ if (thread.subject) {
561
+ parts.push(`Subject: ${thread.subject}`);
562
+ }
563
+ if (thread.contactEmail) {
564
+ parts.push(`Contact email: ${thread.contactEmail}`);
565
+ }
566
+ if (thread.contactName) {
567
+ parts.push(`Contact name: ${thread.contactName}`);
568
+ }
569
+ if (leadContext) {
570
+ parts.push('');
571
+ parts.push('Lead context:');
572
+ parts.push(leadContext);
573
+ }
574
+
575
+ parts.push('');
576
+ parts.push('Email thread (oldest first):');
577
+
578
+ let budget = CHAR_BUDGET;
579
+ const included: string[] = [];
580
+
581
+ for (const msg of thread.messages) {
582
+ const entry = [
583
+ `[${msg.direction.toUpperCase()}] ${msg.fromAddress ?? 'unknown'}:`,
584
+ msg.bodyText.slice(0, 4000),
585
+ ].join('\n');
586
+ if (budget - entry.length < 0 && included.length > 0) break;
587
+ included.push(entry);
588
+ budget -= entry.length;
589
+ }
590
+
591
+ parts.push(...included);
592
+ parts.push('');
593
+ parts.push('Please draft a reply to the last inbound message above.');
594
+
595
+ return parts.join('\n');
596
+ }
597
+
598
+ function buildLeadOnlyContext(leadContext: string): string {
599
+ return [
600
+ 'Lead context:',
601
+ leadContext,
602
+ '',
603
+ "There is no prior email thread. Draft a first outbound email responding to this lead's form submission.",
604
+ ].join('\n');
605
+ }
606
+
607
+ /** Serialize form submission fields for the prompt (skip honeypot / empty). */
608
+ function formatSubmissionData(data: Record<string, unknown> | null | undefined): string {
609
+ if (!data || typeof data !== 'object') return '';
610
+ const lines = Object.entries(data)
611
+ .filter(([key, value]) => key !== '_hp' && value != null && String(value).trim() !== '')
612
+ .map(([key, value]) => {
613
+ const label = key.replace(/_/g, ' ');
614
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
615
+ return `${label}: ${text.slice(0, 4000)}`;
616
+ });
617
+ if (!lines.length) return '';
618
+ return ['Form submission:', ...lines].join('\n');
619
+ }
@@ -0,0 +1,17 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { AiComposeSettingsService } from './ai-compose-settings.service';
3
+ import { AiComposeSuggestService } from './ai-compose-suggest.service';
4
+ import { AiComposeSettingsController } from './ai-compose-settings.controller';
5
+ import { AiComposeSuggestController } from './ai-compose-suggest.controller';
6
+ import { AiComposeGenerateController } from './ai-compose-generate.controller';
7
+
8
+ /** Host DI (DB_TOKEN, LEADS_SERVICE, MAIL_THREAD_SERVICE, PLUGIN_REGISTRY) comes from global PluginBridgeModule (ADR-0016). */
9
+ @Module({
10
+ controllers: [
11
+ AiComposeSettingsController,
12
+ AiComposeSuggestController,
13
+ AiComposeGenerateController,
14
+ ],
15
+ providers: [AiComposeSettingsService, AiComposeSuggestService],
16
+ })
17
+ export class AiComposeModule {}
@@ -0,0 +1,32 @@
1
+ import type { CrmPlugin, PluginContext, PluginSqlClient } from '@khirby/plugin-sdk';
2
+ import { AiComposeModule } from './ai-compose.module';
3
+ import { AI_COMPOSE_MIGRATIONS_SQL } from './migrations';
4
+
5
+ export class AiComposePlugin implements CrmPlugin {
6
+ name = 'crm_ai_compose';
7
+ displayName = 'AI Compose';
8
+ displayNameKey = 'plugins.aiCompose.displayName';
9
+ description = 'AI-powered reply draft suggestions (BYOK, OpenAI-compatible)';
10
+ descriptionKey = 'plugins.aiCompose.description';
11
+ version = '1.0.0';
12
+
13
+ getNestModule() {
14
+ return AiComposeModule;
15
+ }
16
+
17
+ async onMigrate(sql: PluginSqlClient): Promise<void> {
18
+ const statements = AI_COMPOSE_MIGRATIONS_SQL.split(';')
19
+ .map((s) => s.trim())
20
+ .filter((s) => s.length > 0);
21
+
22
+ for (const statement of statements) {
23
+ await sql.unsafe(statement);
24
+ }
25
+ }
26
+
27
+ // Settings UI lives in Settings → Plugins (expand panel), not a sidebar route (ADR-0023).
28
+
29
+ onInit(ctx: PluginContext): void {
30
+ ctx.log('AiComposePlugin: initialized');
31
+ }
32
+ }