@khirby/plugin-pokelo 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-pokelo",
3
+ "version": "1.0.0",
4
+ "description": "Khirby — Pokelo RAG knowledge base context for AI Compose",
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { PokeloPlugin } from './pokelo.plugin';
2
+ import { PokeloPlugin } from './pokelo.plugin';
3
+ import type { CrmPlugin } from '@khirby/plugin-sdk';
4
+
5
+ export function createPlugin(): CrmPlugin {
6
+ return new PokeloPlugin();
7
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Idempotent SQL for pokelo_settings.
3
+ * Statements are split on `;` — do not use DO $$ blocks.
4
+ */
5
+ export const POKELO_MIGRATIONS_SQL = `
6
+ CREATE TABLE IF NOT EXISTS pokelo_settings (
7
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ base_url TEXT NOT NULL DEFAULT 'https://rag.bearly.pro/v1',
9
+ encrypted_token TEXT,
10
+ project_id TEXT,
11
+ project_ids TEXT[] NOT NULL DEFAULT '{}',
12
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
14
+ );
15
+
16
+ ALTER TABLE pokelo_settings ADD COLUMN IF NOT EXISTS project_ids TEXT[] NOT NULL DEFAULT '{}';
17
+
18
+ UPDATE pokelo_settings
19
+ SET project_ids = ARRAY[project_id]
20
+ WHERE project_id IS NOT NULL
21
+ AND project_id <> ''
22
+ AND (project_ids IS NULL OR cardinality(project_ids) = 0)
23
+ `;
@@ -0,0 +1,237 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import type { PokeloContextServiceLike, PokeloFetchOpts } from '../../../packages/plugin-host/src';
3
+ import { PokeloSettingsService } from './pokelo-settings.service';
4
+
5
+ const SNIPPET_LIMIT_TOTAL = 8;
6
+ const SNIPPET_LIMIT_PER_PROJECT = 3;
7
+ const SNIPPET_MAX_CHARS = 800;
8
+
9
+ type McpToolResult = {
10
+ result?: {
11
+ content?: Array<{ type?: string; text?: string }>;
12
+ };
13
+ error?: { message?: string };
14
+ };
15
+
16
+ @Injectable()
17
+ export class PokeloContextService implements PokeloContextServiceLike {
18
+ private readonly logger = new Logger(PokeloContextService.name);
19
+
20
+ constructor(private readonly settings: PokeloSettingsService) {}
21
+
22
+ async fetchContext(query: string, opts?: PokeloFetchOpts): Promise<string> {
23
+ try {
24
+ if (!(await this.settings.isPluginEnabled())) {
25
+ return '';
26
+ }
27
+
28
+ const creds = await this.settings.getCredentials();
29
+ if (!creds?.token || creds.projectIds.length === 0) {
30
+ return '';
31
+ }
32
+
33
+ const trimmed = query.trim();
34
+ if (!trimmed) {
35
+ return '';
36
+ }
37
+
38
+ const requested = opts?.projectIds?.filter(Boolean) ?? [];
39
+ const targetIds =
40
+ requested.length > 0
41
+ ? requested.filter((id) => creds.projectIds.includes(id))
42
+ : creds.projectIds;
43
+
44
+ if (targetIds.length === 0) {
45
+ return '';
46
+ }
47
+
48
+ const nameById = await this.resolveNames(creds.baseUrl, creds.token, targetIds);
49
+
50
+ const perProjectLimit =
51
+ targetIds.length === 1 ? SNIPPET_LIMIT_TOTAL : SNIPPET_LIMIT_PER_PROJECT;
52
+
53
+ const settled = await Promise.all(
54
+ targetIds.map(async (projectId) => {
55
+ try {
56
+ const text = await this.callMcpTool(creds.baseUrl, creds.token, 'search_documents', {
57
+ projectId,
58
+ query: trimmed.slice(0, 4000),
59
+ limit: perProjectLimit,
60
+ });
61
+ const matches = parseSearchMatches(text)
62
+ .slice(0, perProjectLimit)
63
+ .map((m) => m.slice(0, SNIPPET_MAX_CHARS).trim())
64
+ .filter(Boolean);
65
+ return { projectId, name: nameById.get(projectId) ?? projectId, matches };
66
+ } catch (err) {
67
+ this.logger.warn(`Pokelo search failed for ${projectId}: ${(err as Error).message}`);
68
+ return {
69
+ projectId,
70
+ name: nameById.get(projectId) ?? projectId,
71
+ matches: [] as string[],
72
+ };
73
+ }
74
+ }),
75
+ );
76
+
77
+ const labeled: string[] = [];
78
+ for (const block of settled) {
79
+ for (const m of block.matches) {
80
+ labeled.push(`[${block.name}] ${m}`);
81
+ if (labeled.length >= SNIPPET_LIMIT_TOTAL) break;
82
+ }
83
+ if (labeled.length >= SNIPPET_LIMIT_TOTAL) break;
84
+ }
85
+
86
+ if (labeled.length === 0) {
87
+ return '';
88
+ }
89
+
90
+ return [
91
+ '--- Kontekst z Pokelo ---',
92
+ ...labeled.map((s, i) => `[${i + 1}] ${s}`),
93
+ '--- Koniec kontekstu Pokelo ---',
94
+ ].join('\n\n');
95
+ } catch (err) {
96
+ this.logger.warn(`Pokelo fetchContext failed: ${(err as Error).message}`);
97
+ return '';
98
+ }
99
+ }
100
+
101
+ async listProjects(): Promise<Array<{ id: string; name: string }>> {
102
+ const creds = await this.settings.getCredentials();
103
+ if (!creds?.token) {
104
+ return [];
105
+ }
106
+
107
+ const text = await this.callMcpTool(creds.baseUrl, creds.token, 'list_projects', {
108
+ limit: 100,
109
+ });
110
+
111
+ return parseProjectList(text);
112
+ }
113
+
114
+ async listBoundProjects(): Promise<Array<{ id: string; name: string }>> {
115
+ const creds = await this.settings.getCredentials();
116
+ if (!creds?.token || creds.projectIds.length === 0) {
117
+ return [];
118
+ }
119
+ const all = await this.listProjects().catch(() => [] as Array<{ id: string; name: string }>);
120
+ const byId = new Map(all.map((p) => [p.id, p.name]));
121
+ return creds.projectIds.map((id) => ({ id, name: byId.get(id) ?? id }));
122
+ }
123
+
124
+ private async resolveNames(
125
+ baseUrl: string,
126
+ token: string,
127
+ projectIds: string[],
128
+ ): Promise<Map<string, string>> {
129
+ const map = new Map<string, string>();
130
+ try {
131
+ const text = await this.callMcpTool(baseUrl, token, 'list_projects', { limit: 100 });
132
+ for (const p of parseProjectList(text)) {
133
+ map.set(p.id, p.name);
134
+ }
135
+ } catch {
136
+ // names are cosmetic for snippet labels
137
+ }
138
+ for (const id of projectIds) {
139
+ if (!map.has(id)) map.set(id, id);
140
+ }
141
+ return map;
142
+ }
143
+
144
+ private async callMcpTool(
145
+ baseUrl: string,
146
+ token: string,
147
+ name: string,
148
+ args: Record<string, unknown>,
149
+ ): Promise<string> {
150
+ const url = `${baseUrl.replace(/\/$/, '')}/mcp`;
151
+ const response = await fetch(url, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ Accept: 'application/json, text/event-stream',
156
+ Authorization: `Bearer ${token}`,
157
+ },
158
+ body: JSON.stringify({
159
+ jsonrpc: '2.0',
160
+ id: 1,
161
+ method: 'tools/call',
162
+ params: { name, arguments: args },
163
+ }),
164
+ });
165
+
166
+ if (!response.ok) {
167
+ const errText = await response.text().catch(() => 'unknown error');
168
+ throw new Error(`Pokelo MCP ${response.status}: ${errText.slice(0, 200)}`);
169
+ }
170
+
171
+ const contentType = response.headers.get('content-type') ?? '';
172
+ const raw = await response.text();
173
+ const envelope = contentType.includes('text/event-stream')
174
+ ? parseSseJsonRpc(raw)
175
+ : (JSON.parse(raw) as McpToolResult);
176
+
177
+ if (envelope.error) {
178
+ throw new Error(envelope.error.message ?? 'Pokelo MCP tool error');
179
+ }
180
+
181
+ return envelope.result?.content?.[0]?.text ?? '';
182
+ }
183
+ }
184
+
185
+ /** Parse last JSON-RPC payload from an SSE body (`data: {...}` lines). */
186
+ export function parseSseJsonRpc(raw: string): McpToolResult {
187
+ const dataLines: string[] = [];
188
+ for (const line of raw.split(/\r?\n/)) {
189
+ if (line.startsWith('data:')) {
190
+ dataLines.push(line.slice(5).trim());
191
+ }
192
+ }
193
+ if (dataLines.length === 0) {
194
+ return JSON.parse(raw) as McpToolResult;
195
+ }
196
+ for (let i = dataLines.length - 1; i >= 0; i--) {
197
+ if (dataLines[i] && dataLines[i] !== '[DONE]') {
198
+ return JSON.parse(dataLines[i]) as McpToolResult;
199
+ }
200
+ }
201
+ throw new Error('Empty SSE response from Pokelo MCP');
202
+ }
203
+
204
+ export function parseSearchMatches(text: string): string[] {
205
+ if (!text.trim()) return [];
206
+ try {
207
+ const parsed = JSON.parse(text) as {
208
+ matches?: Array<{ content?: string }>;
209
+ matchCount?: number;
210
+ };
211
+ if (Array.isArray(parsed.matches)) {
212
+ return parsed.matches
213
+ .map((m) => (typeof m.content === 'string' ? m.content : ''))
214
+ .filter(Boolean);
215
+ }
216
+ } catch {
217
+ // fall through
218
+ }
219
+ return [text];
220
+ }
221
+
222
+ export function parseProjectList(text: string): Array<{ id: string; name: string }> {
223
+ if (!text.trim()) return [];
224
+ try {
225
+ const parsed = JSON.parse(text) as {
226
+ items?: Array<{ id?: string; name?: string }>;
227
+ };
228
+ if (Array.isArray(parsed.items)) {
229
+ return parsed.items
230
+ .filter((p): p is { id: string; name: string } => !!p.id && !!p.name)
231
+ .map((p) => ({ id: p.id, name: p.name }));
232
+ }
233
+ } catch {
234
+ // ignore
235
+ }
236
+ return [];
237
+ }
@@ -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.POKELO_SECRETS_KEY?.trim();
9
+ if (!raw) {
10
+ throw new Error('POKELO_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
+ "POKELO_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 isPokeloSecretsKeyConfigured(): 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,74 @@
1
+ import { Controller, Get, Patch, Body, UseGuards, Logger } 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 { PokeloSettingsService, POKELO_PLUGIN_NAME } from './pokelo-settings.service';
12
+ import { PokeloContextService } from './pokelo-context.service';
13
+
14
+ @ApiTags('plugins-pokelo')
15
+ @Controller('plugins/pokelo')
16
+ @UseGuards(SessionGuard, PermissionGuard, PluginEnabledGuard)
17
+ @RequirePermission('integrations', 'manage')
18
+ @RequirePluginEnabled(POKELO_PLUGIN_NAME)
19
+ export class PokeloSettingsController {
20
+ private readonly logger = new Logger(PokeloSettingsController.name);
21
+
22
+ constructor(
23
+ private readonly settings: PokeloSettingsService,
24
+ private readonly context: PokeloContextService,
25
+ ) {}
26
+
27
+ @Get('settings')
28
+ @ApiOperation({ summary: 'Get Pokelo settings (token never returned)' })
29
+ getSettings() {
30
+ return this.settings.getSettings();
31
+ }
32
+
33
+ @Patch('settings')
34
+ @ApiOperation({
35
+ summary: 'Update Pokelo settings; auto-select the only project when none are bound',
36
+ })
37
+ async updateSettings(
38
+ @Body()
39
+ dto: {
40
+ token?: string;
41
+ baseUrl?: string;
42
+ projectIds?: string[];
43
+ },
44
+ ) {
45
+ let result = await this.settings.updateSettings(dto);
46
+
47
+ const shouldAutoPick =
48
+ !!dto.token?.trim() && dto.projectIds === undefined && result.projectIds.length === 0;
49
+
50
+ if (shouldAutoPick && result.tokenConfigured) {
51
+ try {
52
+ const projects = await this.context.listProjects();
53
+ if (projects.length === 1) {
54
+ result = await this.settings.updateSettings({ projectIds: [projects[0].id] });
55
+ this.logger.log(`Auto-selected Pokelo project ${projects[0].id}`);
56
+ }
57
+ } catch (err) {
58
+ this.logger.warn(`Could not auto-select Pokelo project: ${(err as Error).message}`);
59
+ }
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ @Get('projects')
66
+ @ApiOperation({ summary: 'List Pokelo projects visible to the configured token' })
67
+ async listProjects() {
68
+ const settings = await this.settings.getSettings();
69
+ if (!settings.tokenConfigured) {
70
+ throw AppException.badRequest('Pokelo token is not configured. Save your settings first.');
71
+ }
72
+ return this.context.listProjects();
73
+ }
74
+ }
@@ -0,0 +1,167 @@
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 { pokeloSettings } from './schema';
11
+ import { encrypt, decrypt, isPokeloSecretsKeyConfigured } from './pokelo-crypto';
12
+
13
+ export const POKELO_PLUGIN_NAME = 'crm_pokelo';
14
+ export const DEFAULT_POKELO_BASE_URL = 'https://rag.bearly.pro/v1';
15
+
16
+ export type PokeloSettingsPublic = {
17
+ baseUrl: string;
18
+ /** Bound Pokelo project IDs (multi-select). */
19
+ projectIds: string[];
20
+ tokenConfigured: boolean;
21
+ };
22
+
23
+ function normalizeProjectIds(ids: unknown): string[] {
24
+ if (!Array.isArray(ids)) return [];
25
+ const seen = new Set<string>();
26
+ const out: string[] = [];
27
+ for (const raw of ids) {
28
+ if (typeof raw !== 'string') continue;
29
+ const id = raw.trim();
30
+ if (!id || seen.has(id)) continue;
31
+ seen.add(id);
32
+ out.push(id);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Resolve bound IDs from row (project_ids preferred; legacy project_id fallback). */
38
+ export function resolveBoundProjectIds(
39
+ row: {
40
+ projectIds?: string[] | null;
41
+ projectId?: string | null;
42
+ } | null,
43
+ ): string[] {
44
+ if (!row) return [];
45
+ const fromArray = normalizeProjectIds(row.projectIds);
46
+ if (fromArray.length > 0) return fromArray;
47
+ if (row.projectId?.trim()) return [row.projectId.trim()];
48
+ return [];
49
+ }
50
+
51
+ @Injectable()
52
+ export class PokeloSettingsService {
53
+ private readonly logger = new Logger(PokeloSettingsService.name);
54
+
55
+ constructor(
56
+ @Inject(DB_TOKEN) private readonly db: Db,
57
+ @Inject(PLUGIN_REGISTRY) private readonly registry: PluginRegistryLike,
58
+ ) {}
59
+
60
+ async assertPluginEnabled(): Promise<void> {
61
+ const plugin = await this.registry.findByName(POKELO_PLUGIN_NAME);
62
+ if (!plugin?.enabled) {
63
+ throw AppException.pluginDisabled('pokelo');
64
+ }
65
+ }
66
+
67
+ async isPluginEnabled(): Promise<boolean> {
68
+ if (this.registry.isEnabled?.(POKELO_PLUGIN_NAME) === false) {
69
+ return false;
70
+ }
71
+ const plugin = await this.registry.findByName(POKELO_PLUGIN_NAME);
72
+ return !!plugin?.enabled;
73
+ }
74
+
75
+ private async getRow() {
76
+ const [row] = await this.db.select().from(pokeloSettings).limit(1);
77
+ return row ?? null;
78
+ }
79
+
80
+ async getSettings(): Promise<PokeloSettingsPublic> {
81
+ await this.assertPluginEnabled();
82
+ const row = await this.getRow();
83
+ return {
84
+ baseUrl: row?.baseUrl ?? DEFAULT_POKELO_BASE_URL,
85
+ projectIds: resolveBoundProjectIds(row),
86
+ tokenConfigured: !!row?.encryptedToken,
87
+ };
88
+ }
89
+
90
+ async updateSettings(dto: {
91
+ token?: string;
92
+ baseUrl?: string;
93
+ projectIds?: string[];
94
+ }): Promise<PokeloSettingsPublic> {
95
+ await this.assertPluginEnabled();
96
+
97
+ const existing = await this.getRow();
98
+
99
+ let encryptedToken: string | undefined = undefined;
100
+ if (dto.token !== undefined && dto.token.trim()) {
101
+ if (!isPokeloSecretsKeyConfigured()) {
102
+ throw AppException.badRequest('POKELO_SECRETS_KEY is not configured');
103
+ }
104
+ encryptedToken = encrypt(dto.token.trim());
105
+ }
106
+
107
+ const baseUrl = dto.baseUrl ?? existing?.baseUrl ?? DEFAULT_POKELO_BASE_URL;
108
+ if (
109
+ !baseUrl.startsWith('https://') &&
110
+ baseUrl !== 'http://localhost' &&
111
+ !baseUrl.startsWith('http://localhost:')
112
+ ) {
113
+ throw AppException.badRequest(
114
+ 'baseUrl must use https:// (or http://localhost for local Pokelo)',
115
+ );
116
+ }
117
+
118
+ const projectIds =
119
+ dto.projectIds !== undefined
120
+ ? normalizeProjectIds(dto.projectIds)
121
+ : resolveBoundProjectIds(existing);
122
+
123
+ const patch: Record<string, unknown> = {
124
+ baseUrl,
125
+ projectIds,
126
+ // Keep legacy column in sync (first id or null) for older rows / tooling
127
+ projectId: projectIds[0] ?? null,
128
+ updatedAt: new Date(),
129
+ };
130
+
131
+ if (encryptedToken !== undefined) {
132
+ patch.encryptedToken = encryptedToken;
133
+ }
134
+
135
+ if (!existing) {
136
+ await this.db.insert(pokeloSettings).values(patch as any);
137
+ } else {
138
+ await this.db
139
+ .update(pokeloSettings)
140
+ .set(patch as any)
141
+ .where(eq(pokeloSettings.id, existing.id));
142
+ }
143
+
144
+ this.logger.log('Pokelo settings updated');
145
+ return this.getSettings();
146
+ }
147
+
148
+ /** Internal: credentials for MCP calls. Returns null if token missing. */
149
+ async getCredentials(): Promise<{
150
+ token: string;
151
+ baseUrl: string;
152
+ projectIds: string[];
153
+ } | null> {
154
+ const row = await this.getRow();
155
+ if (!row?.encryptedToken) return null;
156
+ try {
157
+ return {
158
+ token: decrypt(row.encryptedToken),
159
+ baseUrl: row.baseUrl || DEFAULT_POKELO_BASE_URL,
160
+ projectIds: resolveBoundProjectIds(row),
161
+ };
162
+ } catch (err) {
163
+ this.logger.warn(`Failed to decrypt Pokelo token: ${(err as Error).message}`);
164
+ return null;
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,21 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { POKELO_CONTEXT_SERVICE } from '../../../packages/plugin-host/src';
3
+ import { PokeloSettingsService } from './pokelo-settings.service';
4
+ import { PokeloContextService } from './pokelo-context.service';
5
+ import { PokeloSettingsController } from './pokelo-settings.controller';
6
+
7
+ /**
8
+ * @Global so AI Compose (sibling plugin module) can @Optional()-inject
9
+ * POKELO_CONTEXT_SERVICE (ADR-0022).
10
+ */
11
+ @Global()
12
+ @Module({
13
+ controllers: [PokeloSettingsController],
14
+ providers: [
15
+ PokeloSettingsService,
16
+ PokeloContextService,
17
+ { provide: POKELO_CONTEXT_SERVICE, useExisting: PokeloContextService },
18
+ ],
19
+ exports: [POKELO_CONTEXT_SERVICE],
20
+ })
21
+ export class PokeloModule {}
@@ -0,0 +1,32 @@
1
+ import type { CrmPlugin, PluginContext, PluginSqlClient } from '@khirby/plugin-sdk';
2
+ import { PokeloModule } from './pokelo.module';
3
+ import { POKELO_MIGRATIONS_SQL } from './migrations';
4
+
5
+ export class PokeloPlugin implements CrmPlugin {
6
+ name = 'crm_pokelo';
7
+ displayName = 'Pokelo Knowledge Base';
8
+ displayNameKey = 'plugins.pokelo.displayName';
9
+ description = 'Enriches AI Compose with firm knowledge from Pokelo RAG (mail + campaigns).';
10
+ descriptionKey = 'plugins.pokelo.description';
11
+ version = '1.0.0';
12
+
13
+ getNestModule() {
14
+ return PokeloModule;
15
+ }
16
+
17
+ async onMigrate(sql: PluginSqlClient): Promise<void> {
18
+ const statements = POKELO_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('PokeloPlugin: initialized');
31
+ }
32
+ }
@@ -0,0 +1,322 @@
1
+ import { encrypt, decrypt, isPokeloSecretsKeyConfigured } from './pokelo-crypto';
2
+ import { parseSseJsonRpc, parseSearchMatches, parseProjectList } from './pokelo-context.service';
3
+ import { PokeloSettingsService } from './pokelo-settings.service';
4
+ import { PokeloContextService } from './pokelo-context.service';
5
+
6
+ describe('pokelo-crypto', () => {
7
+ const HEX_KEY = 'c'.repeat(64);
8
+
9
+ beforeEach(() => {
10
+ process.env.POKELO_SECRETS_KEY = HEX_KEY;
11
+ });
12
+
13
+ afterEach(() => {
14
+ delete process.env.POKELO_SECRETS_KEY;
15
+ });
16
+
17
+ it('encrypts and decrypts back to the same plaintext', () => {
18
+ const plaintext = 'mcp_test_token';
19
+ const cipher = encrypt(plaintext);
20
+ expect(cipher).not.toEqual(plaintext);
21
+ expect(decrypt(cipher)).toEqual(plaintext);
22
+ });
23
+
24
+ it('isPokeloSecretsKeyConfigured returns false when key is unset', () => {
25
+ delete process.env.POKELO_SECRETS_KEY;
26
+ expect(isPokeloSecretsKeyConfigured()).toBe(false);
27
+ });
28
+
29
+ it('throws on missing key at encrypt time', () => {
30
+ delete process.env.POKELO_SECRETS_KEY;
31
+ expect(() => encrypt('anything')).toThrow('POKELO_SECRETS_KEY is not set');
32
+ });
33
+ });
34
+
35
+ describe('parseSseJsonRpc / parseSearchMatches / parseProjectList', () => {
36
+ it('parses SSE data lines', () => {
37
+ const raw = [
38
+ 'event: message',
39
+ 'data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\\"matches\\":[]}"}]}}',
40
+ '',
41
+ ].join('\n');
42
+ const envelope = parseSseJsonRpc(raw);
43
+ expect(envelope.result?.content?.[0]?.text).toContain('matches');
44
+ });
45
+
46
+ it('parses search matches JSON', () => {
47
+ const matches = parseSearchMatches(
48
+ JSON.stringify({ matches: [{ content: 'Snippet A' }, { content: 'Snippet B' }] }),
49
+ );
50
+ expect(matches).toEqual(['Snippet A', 'Snippet B']);
51
+ });
52
+
53
+ it('parses project list JSON', () => {
54
+ const projects = parseProjectList(
55
+ JSON.stringify({ items: [{ id: 'p1', name: 'Bearly CRM' }, { id: 'x' }] }),
56
+ );
57
+ expect(projects).toEqual([{ id: 'p1', name: 'Bearly CRM' }]);
58
+ });
59
+ });
60
+
61
+ function makeSelectChain(returnValue: unknown[]) {
62
+ const chain: Record<string, unknown> = {};
63
+ const resolved = Promise.resolve(returnValue);
64
+ (chain as any).then = resolved.then.bind(resolved);
65
+ (chain as any).catch = resolved.catch.bind(resolved);
66
+ chain.select = () => chain;
67
+ chain.from = () => chain;
68
+ chain.where = () => chain;
69
+ chain.limit = () => chain;
70
+ return chain;
71
+ }
72
+
73
+ function makeMockDb(row?: unknown) {
74
+ const rows = row !== undefined ? [row] : [];
75
+ return {
76
+ select: () => makeSelectChain(rows),
77
+ insert: () => {
78
+ const chain: Record<string, unknown> = {};
79
+ const resolved = Promise.resolve([]);
80
+ (chain as any).then = resolved.then.bind(resolved);
81
+ chain.values = () => chain;
82
+ return chain;
83
+ },
84
+ update: () => {
85
+ const chain: Record<string, unknown> = {};
86
+ const resolved = Promise.resolve([]);
87
+ (chain as any).then = resolved.then.bind(resolved);
88
+ chain.set = () => chain;
89
+ chain.where = () => chain;
90
+ return chain;
91
+ },
92
+ };
93
+ }
94
+
95
+ function makeMockRegistry(enabled = true) {
96
+ return {
97
+ findByName: jest.fn().mockResolvedValue({ name: 'crm_pokelo', enabled, config: null }),
98
+ isEnabled: jest.fn().mockReturnValue(enabled),
99
+ };
100
+ }
101
+
102
+ describe('PokeloSettingsService', () => {
103
+ const HEX_KEY = 'd'.repeat(64);
104
+
105
+ beforeEach(() => {
106
+ process.env.POKELO_SECRETS_KEY = HEX_KEY;
107
+ });
108
+
109
+ afterEach(() => {
110
+ delete process.env.POKELO_SECRETS_KEY;
111
+ });
112
+
113
+ it('getSettings returns tokenConfigured false when empty', async () => {
114
+ const service = new PokeloSettingsService(makeMockDb() as any, makeMockRegistry() as any);
115
+ const settings = await service.getSettings();
116
+ expect(settings.tokenConfigured).toBe(false);
117
+ expect(settings.baseUrl).toBe('https://rag.bearly.pro/v1');
118
+ });
119
+
120
+ it('getCredentials decrypts stored token', async () => {
121
+ const enc = encrypt('mcp_secret');
122
+ const service = new PokeloSettingsService(
123
+ makeMockDb({
124
+ id: '1',
125
+ baseUrl: 'https://rag.bearly.pro/v1',
126
+ encryptedToken: enc,
127
+ projectId: 'proj-1',
128
+ projectIds: ['proj-1', 'proj-2'],
129
+ }) as any,
130
+ makeMockRegistry() as any,
131
+ );
132
+ const creds = await service.getCredentials();
133
+ expect(creds?.token).toBe('mcp_secret');
134
+ expect(creds?.projectIds).toEqual(['proj-1', 'proj-2']);
135
+ });
136
+
137
+ it('falls back to legacy projectId when projectIds empty', async () => {
138
+ const enc = encrypt('mcp_secret');
139
+ const service = new PokeloSettingsService(
140
+ makeMockDb({
141
+ id: '1',
142
+ baseUrl: 'https://rag.bearly.pro/v1',
143
+ encryptedToken: enc,
144
+ projectId: 'legacy-only',
145
+ projectIds: [],
146
+ }) as any,
147
+ makeMockRegistry() as any,
148
+ );
149
+ const settings = await service.getSettings();
150
+ expect(settings.projectIds).toEqual(['legacy-only']);
151
+ });
152
+ });
153
+
154
+ describe('PokeloContextService.fetchContext', () => {
155
+ const HEX_KEY = 'e'.repeat(64);
156
+
157
+ beforeEach(() => {
158
+ process.env.POKELO_SECRETS_KEY = HEX_KEY;
159
+ global.fetch = jest.fn();
160
+ });
161
+
162
+ afterEach(() => {
163
+ delete process.env.POKELO_SECRETS_KEY;
164
+ jest.restoreAllMocks();
165
+ });
166
+
167
+ it('returns empty string when plugin disabled', async () => {
168
+ const settings = new PokeloSettingsService(
169
+ makeMockDb({
170
+ encryptedToken: encrypt('t'),
171
+ projectIds: ['p'],
172
+ projectId: 'p',
173
+ baseUrl: 'https://rag.bearly.pro/v1',
174
+ }) as any,
175
+ makeMockRegistry(false) as any,
176
+ );
177
+ const ctx = new PokeloContextService(settings);
178
+ expect(await ctx.fetchContext('hello')).toBe('');
179
+ expect(global.fetch).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('returns formatted snippets on MCP success', async () => {
183
+ const settings = new PokeloSettingsService(
184
+ makeMockDb({
185
+ encryptedToken: encrypt('mcp_tok'),
186
+ projectIds: ['proj-uuid'],
187
+ projectId: 'proj-uuid',
188
+ baseUrl: 'https://rag.bearly.pro/v1',
189
+ }) as any,
190
+ makeMockRegistry(true) as any,
191
+ );
192
+
193
+ const listPayload = {
194
+ jsonrpc: '2.0',
195
+ id: 1,
196
+ result: {
197
+ content: [
198
+ {
199
+ type: 'text',
200
+ text: JSON.stringify({ items: [{ id: 'proj-uuid', name: 'Bearly CRM' }] }),
201
+ },
202
+ ],
203
+ },
204
+ };
205
+
206
+ const searchPayload = {
207
+ jsonrpc: '2.0',
208
+ id: 1,
209
+ result: {
210
+ content: [
211
+ {
212
+ type: 'text',
213
+ text: JSON.stringify({
214
+ matches: [{ content: 'Firm pricing is X' }, { content: 'SLA is Y' }],
215
+ }),
216
+ },
217
+ ],
218
+ },
219
+ };
220
+
221
+ (global.fetch as jest.Mock)
222
+ .mockResolvedValueOnce({
223
+ ok: true,
224
+ headers: { get: () => 'text/event-stream' },
225
+ text: async () => `data: ${JSON.stringify(listPayload)}\n\n`,
226
+ })
227
+ .mockResolvedValueOnce({
228
+ ok: true,
229
+ headers: { get: () => 'text/event-stream' },
230
+ text: async () => `data: ${JSON.stringify(searchPayload)}\n\n`,
231
+ });
232
+
233
+ const ctx = new PokeloContextService(settings);
234
+ const result = await ctx.fetchContext('pricing');
235
+ expect(result).toContain('--- Kontekst z Pokelo ---');
236
+ expect(result).toContain('[Bearly CRM]');
237
+ expect(result).toContain('Firm pricing is X');
238
+ expect(result).toContain('SLA is Y');
239
+ expect(result).toContain('--- Koniec kontekstu Pokelo ---');
240
+
241
+ const searchCall = (global.fetch as jest.Mock).mock.calls.find(
242
+ (c) => JSON.parse(c[1].body).params?.name === 'search_documents',
243
+ );
244
+ expect(searchCall[0]).toBe('https://rag.bearly.pro/v1/mcp');
245
+ expect(searchCall[1].headers.Accept).toContain('text/event-stream');
246
+ expect(searchCall[1].headers.Authorization).toBe('Bearer mcp_tok');
247
+ });
248
+
249
+ it('searches only requested projectIds subset', async () => {
250
+ const settings = new PokeloSettingsService(
251
+ makeMockDb({
252
+ encryptedToken: encrypt('mcp_tok'),
253
+ projectIds: ['a', 'b'],
254
+ baseUrl: 'https://rag.bearly.pro/v1',
255
+ }) as any,
256
+ makeMockRegistry(true) as any,
257
+ );
258
+
259
+ const listPayload = {
260
+ jsonrpc: '2.0',
261
+ result: {
262
+ content: [
263
+ {
264
+ type: 'text',
265
+ text: JSON.stringify({
266
+ items: [
267
+ { id: 'a', name: 'CRM' },
268
+ { id: 'b', name: 'Finsly' },
269
+ ],
270
+ }),
271
+ },
272
+ ],
273
+ },
274
+ };
275
+ const searchPayload = {
276
+ jsonrpc: '2.0',
277
+ result: {
278
+ content: [{ type: 'text', text: JSON.stringify({ matches: [{ content: 'Only A' }] }) }],
279
+ },
280
+ };
281
+
282
+ (global.fetch as jest.Mock).mockImplementation(async (_url: string, init: { body: string }) => {
283
+ const body = JSON.parse(init.body);
284
+ const payload = body.params?.name === 'list_projects' ? listPayload : searchPayload;
285
+ return {
286
+ ok: true,
287
+ headers: { get: () => 'application/json' },
288
+ text: async () => JSON.stringify(payload),
289
+ };
290
+ });
291
+
292
+ const ctx = new PokeloContextService(settings);
293
+ await ctx.fetchContext('q', { projectIds: ['a'] });
294
+
295
+ const searchBodies = (global.fetch as jest.Mock).mock.calls
296
+ .map((c) => JSON.parse(c[1].body))
297
+ .filter((b) => b.params?.name === 'search_documents');
298
+ expect(searchBodies).toHaveLength(1);
299
+ expect(searchBodies[0].params.arguments.projectId).toBe('a');
300
+ });
301
+
302
+ it('returns empty string on MCP error', async () => {
303
+ const settings = new PokeloSettingsService(
304
+ makeMockDb({
305
+ encryptedToken: encrypt('mcp_tok'),
306
+ projectIds: ['proj-uuid'],
307
+ projectId: 'proj-uuid',
308
+ baseUrl: 'https://rag.bearly.pro/v1',
309
+ }) as any,
310
+ makeMockRegistry(true) as any,
311
+ );
312
+
313
+ (global.fetch as jest.Mock).mockResolvedValue({
314
+ ok: false,
315
+ status: 401,
316
+ text: async () => 'Unauthorized',
317
+ });
318
+
319
+ const ctx = new PokeloContextService(settings);
320
+ expect(await ctx.fetchContext('pricing')).toBe('');
321
+ });
322
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
2
+
3
+ export const pokeloSettings = pgTable('pokelo_settings', {
4
+ id: uuid('id').defaultRandom().primaryKey(),
5
+ baseUrl: text('base_url').notNull().default('https://rag.bearly.pro/v1'),
6
+ encryptedToken: text('encrypted_token'),
7
+ /** @deprecated prefer projectIds — kept for migration from single-project installs */
8
+ projectId: text('project_id'),
9
+ projectIds: text('project_ids').array().notNull().default([]),
10
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
11
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
12
+ });
13
+
14
+ export type PokeloSettings = typeof pokeloSettings.$inferSelect;