@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khirby Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@khirby/plugin-ai-compose",
3
+ "version": "1.0.0",
4
+ "description": "Khirby — AI-powered reply draft suggestions (BYOK, OpenAI-compatible)",
5
+ "main": "src/index.ts",
6
+ "keywords": [
7
+ "khirby-plugin"
8
+ ],
9
+ "peerDependencies": {
10
+ "@khirby/plugin-host": "^1.0.0",
11
+ "@khirby/plugin-sdk": "^1.0.0",
12
+ "@nestjs/common": "*",
13
+ "@nestjs/swagger": "*",
14
+ "drizzle-orm": "*"
15
+ },
16
+ "types": "src/index.ts",
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org"
23
+ }
24
+ }
@@ -0,0 +1,60 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
2
+
3
+ const ALGORITHM = 'aes-256-gcm';
4
+ const IV_BYTES = 12;
5
+ const TAG_BYTES = 16;
6
+
7
+ function getKey(): Buffer {
8
+ const raw = process.env.AI_COMPOSE_SECRETS_KEY?.trim();
9
+ if (!raw) {
10
+ throw new Error('AI_COMPOSE_SECRETS_KEY is not set');
11
+ }
12
+ let buf: Buffer;
13
+ if (/^[0-9a-fA-F]{64}$/.test(raw)) {
14
+ buf = Buffer.from(raw, 'hex');
15
+ } else {
16
+ buf = Buffer.from(raw, 'base64');
17
+ }
18
+ if (buf.length !== 32) {
19
+ throw new Error(
20
+ "AI_COMPOSE_SECRETS_KEY must be 32 bytes as hex (64 chars) or base64 — generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
21
+ );
22
+ }
23
+ return buf;
24
+ }
25
+
26
+ export function isAiComposeSecretsKeyConfigured(): boolean {
27
+ try {
28
+ getKey();
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Encrypts plaintext using AES-256-GCM.
37
+ * Returns a base64 string: iv(12) + ciphertext + tag(16)
38
+ */
39
+ export function encrypt(plaintext: string): string {
40
+ const key = getKey();
41
+ const iv = randomBytes(IV_BYTES);
42
+ const cipher = createCipheriv(ALGORITHM, key, iv);
43
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
44
+ const tag = cipher.getAuthTag();
45
+ return Buffer.concat([iv, encrypted, tag]).toString('base64');
46
+ }
47
+
48
+ /**
49
+ * Decrypts a base64 blob produced by `encrypt`.
50
+ */
51
+ export function decrypt(ciphertext: string): string {
52
+ const key = getKey();
53
+ const buf = Buffer.from(ciphertext, 'base64');
54
+ const iv = buf.subarray(0, IV_BYTES);
55
+ const tag = buf.subarray(buf.length - TAG_BYTES);
56
+ const encrypted = buf.subarray(IV_BYTES, buf.length - TAG_BYTES);
57
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
58
+ decipher.setAuthTag(tag);
59
+ return decipher.update(encrypted) + decipher.final('utf8');
60
+ }
@@ -0,0 +1,69 @@
1
+ import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
2
+ import { ApiTags, ApiOperation } from '@nestjs/swagger';
3
+ import {
4
+ SessionGuard,
5
+ PermissionGuard,
6
+ RequireAnyPermission,
7
+ RequirePluginEnabled,
8
+ PluginEnabledGuard,
9
+ } from '../../../packages/plugin-host/src';
10
+ import { AiComposeSuggestService, type NewsletterContentType } from './ai-compose-suggest.service';
11
+ import { AI_COMPOSE_PLUGIN_NAME } from './ai-compose-settings.service';
12
+
13
+ @ApiTags('plugins-ai-compose')
14
+ @Controller('plugins/ai-compose')
15
+ @UseGuards(SessionGuard, PermissionGuard, PluginEnabledGuard)
16
+ @RequirePluginEnabled(AI_COMPOSE_PLUGIN_NAME)
17
+ export class AiComposeGenerateController {
18
+ constructor(private readonly suggest: AiComposeSuggestService) {}
19
+
20
+ @Get('availability')
21
+ @ApiOperation({
22
+ summary:
23
+ 'Whether AI Compose can generate (plugin enabled + API key). For feature gates in other plugins.',
24
+ })
25
+ availability() {
26
+ return this.suggest.availability();
27
+ }
28
+
29
+ @Get('models/compose')
30
+ @RequireAnyPermission(
31
+ ['newsletter', 'manage'],
32
+ ['leads', 'manage'],
33
+ ['contacts', 'manage'],
34
+ ['integrations', 'manage'],
35
+ )
36
+ @ApiOperation({
37
+ summary:
38
+ 'Allowed models for compose UIs (mail / newsletter) — not limited to integrations admins',
39
+ })
40
+ composeModels() {
41
+ return this.suggest.getComposeModels();
42
+ }
43
+
44
+ @Post('generate')
45
+ @RequireAnyPermission(
46
+ ['newsletter', 'manage'],
47
+ ['leads', 'manage'],
48
+ ['contacts', 'manage'],
49
+ ['integrations', 'manage'],
50
+ )
51
+ @ApiOperation({
52
+ summary:
53
+ 'Generate newsletter campaign body in the requested format (html / markdown / plain / richtext)',
54
+ })
55
+ generate(
56
+ @Body()
57
+ dto: {
58
+ contentType: NewsletterContentType;
59
+ name?: string;
60
+ subject?: string;
61
+ instruction?: string;
62
+ existingBody?: string;
63
+ templateName?: string;
64
+ model?: string;
65
+ },
66
+ ) {
67
+ return this.suggest.generateNewsletter(dto);
68
+ }
69
+ }
@@ -0,0 +1,95 @@
1
+ import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
2
+ import { ApiTags, ApiOperation } from '@nestjs/swagger';
3
+ import {
4
+ SessionGuard,
5
+ PermissionGuard,
6
+ RequirePermission,
7
+ RequirePluginEnabled,
8
+ PluginEnabledGuard,
9
+ AppException,
10
+ } from '../../../packages/plugin-host/src';
11
+ import { AiComposeSettingsService } from './ai-compose-settings.service';
12
+ import { AiComposeSuggestService } from './ai-compose-suggest.service';
13
+ import { AI_COMPOSE_PLUGIN_NAME } from './ai-compose-settings.service';
14
+
15
+ @ApiTags('plugins-ai-compose')
16
+ @Controller('plugins/ai-compose')
17
+ @UseGuards(SessionGuard, PermissionGuard, PluginEnabledGuard)
18
+ @RequirePermission('integrations', 'manage')
19
+ @RequirePluginEnabled(AI_COMPOSE_PLUGIN_NAME)
20
+ export class AiComposeSettingsController {
21
+ constructor(
22
+ private readonly settings: AiComposeSettingsService,
23
+ private readonly suggest: AiComposeSuggestService,
24
+ ) {}
25
+
26
+ @Get('settings')
27
+ @ApiOperation({ summary: 'Get AI Compose settings (API key never returned)' })
28
+ getSettings() {
29
+ return this.settings.getSettings();
30
+ }
31
+
32
+ @Patch('settings')
33
+ @ApiOperation({ summary: 'Update AI Compose settings' })
34
+ updateSettings(
35
+ @Body()
36
+ dto: {
37
+ apiKey?: string;
38
+ baseUrl?: string;
39
+ defaultModel?: string | null;
40
+ allowedModels?: string[];
41
+ systemPrompt?: string | null;
42
+ },
43
+ ) {
44
+ return this.settings.updateSettings(dto);
45
+ }
46
+
47
+ @Get('models')
48
+ @ApiOperation({ summary: 'Fetch available models from the configured AI provider' })
49
+ async getModels() {
50
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey().catch(() => {
51
+ throw AppException.badRequest('API key is not configured. Save your settings first.');
52
+ });
53
+ return this.suggest.fetchModels(baseUrl, apiKey);
54
+ }
55
+
56
+ @Get('models/allowed')
57
+ @ApiOperation({ summary: 'Get intersection of allowed models (admin allowlist) + default' })
58
+ async getAllowedModels() {
59
+ const defaultModel = await this.settings.getDefaultModel();
60
+ const allowedModels = await this.settings.getAllowedModels();
61
+
62
+ const toEntries = (ids: string[]) => ids.map((m) => ({ id: m, label: m }));
63
+
64
+ if (allowedModels.length === 0) {
65
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey().catch(() => ({
66
+ apiKey: '',
67
+ baseUrl: '',
68
+ }));
69
+ if (apiKey) {
70
+ try {
71
+ const all = await this.suggest.fetchModels(baseUrl, apiKey);
72
+ return { models: all, defaultModel };
73
+ } catch {
74
+ return { models: [], defaultModel };
75
+ }
76
+ }
77
+ return { models: [], defaultModel };
78
+ }
79
+
80
+ try {
81
+ const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
82
+ const allModels = await this.suggest.fetchModels(baseUrl, apiKey);
83
+ const allIds = new Set(allModels.map((m) => m.id));
84
+ const models = allowedModels
85
+ .filter((m) => allIds.has(m))
86
+ .map((m) => {
87
+ const found = allModels.find((x) => x.id === m);
88
+ return { id: m, label: found?.label ?? m };
89
+ });
90
+ return { models, defaultModel };
91
+ } catch {
92
+ return { models: toEntries(allowedModels), defaultModel };
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,139 @@
1
+ import { Injectable, Inject, Logger } from '@nestjs/common';
2
+ import { eq } from 'drizzle-orm';
3
+ import {
4
+ Db,
5
+ DB_TOKEN,
6
+ PLUGIN_REGISTRY,
7
+ type PluginRegistryLike,
8
+ AppException,
9
+ } from '../../../packages/plugin-host/src';
10
+ import { aiComposeSettings } from './schema';
11
+ import { encrypt, decrypt, isAiComposeSecretsKeyConfigured } from './ai-compose-crypto';
12
+
13
+ export const AI_COMPOSE_PLUGIN_NAME = 'crm_ai_compose';
14
+
15
+ export type AiComposeSettingsPublic = {
16
+ baseUrl: string;
17
+ defaultModel: string | null;
18
+ allowedModels: string[];
19
+ systemPrompt: string | null;
20
+ apiKeyConfigured: boolean;
21
+ };
22
+
23
+ @Injectable()
24
+ export class AiComposeSettingsService {
25
+ private readonly logger = new Logger(AiComposeSettingsService.name);
26
+
27
+ constructor(
28
+ @Inject(DB_TOKEN) private readonly db: Db,
29
+ @Inject(PLUGIN_REGISTRY) private readonly registry: PluginRegistryLike,
30
+ ) {}
31
+
32
+ async assertPluginEnabled(): Promise<void> {
33
+ const plugin = await this.registry.findByName(AI_COMPOSE_PLUGIN_NAME);
34
+ if (!plugin?.enabled) {
35
+ throw AppException.pluginDisabled('ai-compose');
36
+ }
37
+ }
38
+
39
+ private async getRow() {
40
+ const [row] = await this.db.select().from(aiComposeSettings).limit(1);
41
+ return row ?? null;
42
+ }
43
+
44
+ async getSettings(): Promise<AiComposeSettingsPublic> {
45
+ await this.assertPluginEnabled();
46
+ const row = await this.getRow();
47
+ return {
48
+ baseUrl: row?.baseUrl ?? 'https://api.openai.com/v1',
49
+ defaultModel: row?.defaultModel ?? null,
50
+ allowedModels: row?.allowedModels ?? [],
51
+ systemPrompt: row?.systemPrompt ?? null,
52
+ apiKeyConfigured: !!row?.apiKeyEnc,
53
+ };
54
+ }
55
+
56
+ async updateSettings(dto: {
57
+ apiKey?: string;
58
+ baseUrl?: string;
59
+ defaultModel?: string | null;
60
+ allowedModels?: string[];
61
+ systemPrompt?: string | null;
62
+ }): Promise<AiComposeSettingsPublic> {
63
+ await this.assertPluginEnabled();
64
+
65
+ const existing = await this.getRow();
66
+
67
+ let apiKeyEnc: string | undefined = undefined;
68
+ if (dto.apiKey !== undefined && dto.apiKey.trim()) {
69
+ if (!isAiComposeSecretsKeyConfigured()) {
70
+ throw AppException.badRequest('AI_COMPOSE_SECRETS_KEY is not configured');
71
+ }
72
+ apiKeyEnc = encrypt(dto.apiKey.trim());
73
+ }
74
+
75
+ const baseUrl = dto.baseUrl ?? existing?.baseUrl ?? 'https://api.openai.com/v1';
76
+ if (
77
+ !baseUrl.startsWith('https://') &&
78
+ baseUrl !== 'http://localhost' &&
79
+ !baseUrl.startsWith('http://localhost:')
80
+ ) {
81
+ throw AppException.badRequest(
82
+ 'baseUrl must use https:// (or http://localhost for local models)',
83
+ );
84
+ }
85
+
86
+ const patch: Record<string, unknown> = {
87
+ baseUrl,
88
+ defaultModel:
89
+ dto.defaultModel !== undefined ? dto.defaultModel : (existing?.defaultModel ?? null),
90
+ allowedModels: dto.allowedModels ?? existing?.allowedModels ?? [],
91
+ systemPrompt:
92
+ dto.systemPrompt !== undefined ? dto.systemPrompt : (existing?.systemPrompt ?? null),
93
+ updatedAt: new Date(),
94
+ };
95
+
96
+ if (apiKeyEnc !== undefined) {
97
+ patch.apiKeyEnc = apiKeyEnc;
98
+ }
99
+
100
+ if (!existing) {
101
+ await this.db.insert(aiComposeSettings).values(patch as any);
102
+ } else {
103
+ await this.db
104
+ .update(aiComposeSettings)
105
+ .set(patch as any)
106
+ .where(eq(aiComposeSettings.id, existing.id));
107
+ }
108
+
109
+ this.logger.log('AI Compose settings updated');
110
+ return this.getSettings();
111
+ }
112
+
113
+ /** Decrypt the stored API key for internal use; throws if missing. */
114
+ async getDecryptedApiKey(): Promise<{ apiKey: string; baseUrl: string }> {
115
+ const row = await this.getRow();
116
+ if (!row?.apiKeyEnc) {
117
+ throw AppException.badRequest('AI Compose API key is not configured');
118
+ }
119
+ return {
120
+ apiKey: decrypt(row.apiKeyEnc),
121
+ baseUrl: row.baseUrl,
122
+ };
123
+ }
124
+
125
+ async getAllowedModels(): Promise<string[]> {
126
+ const row = await this.getRow();
127
+ return row?.allowedModels ?? [];
128
+ }
129
+
130
+ async getDefaultModel(): Promise<string | null> {
131
+ const row = await this.getRow();
132
+ return row?.defaultModel ?? null;
133
+ }
134
+
135
+ async getSystemPrompt(): Promise<string | null> {
136
+ const row = await this.getRow();
137
+ return row?.systemPrompt ?? null;
138
+ }
139
+ }
@@ -0,0 +1,37 @@
1
+ import { Controller, Post, Body, UseGuards } from '@nestjs/common';
2
+ import { ApiTags, ApiOperation } from '@nestjs/swagger';
3
+ import {
4
+ SessionGuard,
5
+ PermissionGuard,
6
+ RequireAnyPermission,
7
+ RequirePluginEnabled,
8
+ PluginEnabledGuard,
9
+ } from '../../../packages/plugin-host/src';
10
+ import { AiComposeSuggestService } from './ai-compose-suggest.service';
11
+ import { AI_COMPOSE_PLUGIN_NAME } from './ai-compose-settings.service';
12
+
13
+ @ApiTags('plugins-ai-compose')
14
+ @Controller('plugins/ai-compose')
15
+ @UseGuards(SessionGuard, PermissionGuard, PluginEnabledGuard)
16
+ @RequireAnyPermission(['leads', 'manage'], ['contacts', 'manage'])
17
+ @RequirePluginEnabled(AI_COMPOSE_PLUGIN_NAME)
18
+ export class AiComposeSuggestController {
19
+ constructor(private readonly suggest: AiComposeSuggestService) {}
20
+
21
+ @Post('suggest')
22
+ @ApiOperation({
23
+ summary:
24
+ 'Generate an AI draft for a mail thread reply or a first outbound to a lead (never auto-sends)',
25
+ })
26
+ generateSuggestion(
27
+ @Body()
28
+ dto: {
29
+ threadId?: string;
30
+ leadId?: string;
31
+ model?: string;
32
+ instruction?: string;
33
+ },
34
+ ) {
35
+ return this.suggest.suggest(dto);
36
+ }
37
+ }