cap-copilot-sdk 0.1.0 → 0.2.2

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/cds-plugin.js CHANGED
@@ -1,67 +1,630 @@
1
- 'use strict';
1
+ "use strict";
2
2
  // Resolve @sap/cds from the project root (process.cwd()) to get the same
3
3
  // singleton instance that cds watch uses — not a duplicate from this package's location.
4
- const cdsPath = require.resolve('@sap/cds', { paths: [process.cwd()] });
4
+ const cdsPath = require.resolve("@sap/cds", { paths: [process.cwd()] });
5
5
  const cds = require(cdsPath);
6
- const { extract, extractFromServices } = require('./src/SchemaExtractor');
7
- const { scan } = require('./src/ProjectScanner');
8
- const { register } = require('./src/Registrar');
6
+ const path = require("path");
7
+ const fs = require("fs");
8
+ const { extract, extractFromServices } = require("./src/SchemaExtractor");
9
+ const { scan } = require("./src/ProjectScanner");
10
+ const {
11
+ register,
12
+ registerServiceTool,
13
+ registerAllServiceTools,
14
+ } = require("./src/Registrar");
15
+
16
+ // Read consuming app's package.json for zero-config defaults (name → appId, description → appName)
17
+ let _appPkg = {};
18
+ try {
19
+ _appPkg = JSON.parse(
20
+ fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"),
21
+ );
22
+ } catch {}
9
23
 
10
- const cfg = cds.env.requires?.['btp-copilot'] ?? {};
24
+ const cfg = cds.env.requires?.["btp-copilot"] ?? {};
11
25
  const {
12
- backendUrl = process.env.BTP_COPILOT_URL,
13
- appId = process.env.BTP_COPILOT_APP_ID,
14
- appName,
15
- serviceUrl,
16
- token = process.env.BTP_COPILOT_TOKEN,
26
+ // Zero-config defaults — works out of the box without any cds.requires config
27
+ backendUrl = process.env.BTP_COPILOT_URL ?? "http://localhost:8000",
28
+ appId = process.env.BTP_COPILOT_APP_ID ??
29
+ (_appPkg.name ?? "cap-app").replace(/[^a-zA-Z0-9_-]/g, "-"),
30
+ appName = _appPkg.description ?? cfg.appId ?? _appPkg.name ?? "CAP App",
31
+ serviceUrl, // single service URL (legacy / simple apps)
32
+ serviceUrls, // array of service URLs for multi-service CAP apps
33
+ iframeUrl = process.env.BTP_COPILOT_IFRAME_URL ?? "http://localhost:5173",
34
+ token = process.env.BTP_COPILOT_TOKEN,
17
35
  includeProjectFiles = true,
18
- includeHandlers = true,
19
- includeViews = true,
36
+ includeHandlers = true,
37
+ includeViews = true,
38
+ injectWidget = true,
39
+ widgetPosition = "bottom-right",
40
+ autoWatch = false, // auto-register READ + mutation hooks on all entities (opt-in for enterprise)
41
+ autoSync = false, // background paginated sync of all entities on startup (opt-in for enterprise)
42
+ syncPageSize = 100, // rows per sync page
43
+ syncJitter = true, // random 0-30 s delay before startup sync (staggers multi-app restarts)
44
+ maxDocs = 200, // max documents sent to backend per startup (increase if your app has many entities)
45
+ // multiTenant=true → the app has many end-users; raw record data MUST NOT go into
46
+ // the shared vector store (User A would see User B's records).
47
+ // In this mode autoWatch + autoSync push only entity stats (count +
48
+ // field list). Per-user live data is fetched at query time via OData
49
+ // using each user's own JWT — the CAP auth layer enforces isolation.
50
+ // multiTenant=false → single-user or dev; raw records are pushed to the vector store
51
+ // so the AI can answer questions even without an OData token.
52
+ multiTenant = process.env.BTP_COPILOT_MULTI_TENANT === "true"
53
+ ? true
54
+ : process.env.BTP_COPILOT_MULTI_TENANT === "false"
55
+ ? false
56
+ : false, // default: safe for single-user / dev; set true for production
20
57
  } = cfg;
21
58
 
22
- const log = (...args) => console.log('\x1b[36m[btp-copilot]\x1b[0m', ...args);
23
- const warn = (...args) => console.warn('\x1b[33m[btp-copilot]\x1b[0m', ...args);
24
- const err = (...args) => console.error('\x1b[31m[btp-copilot]\x1b[0m', ...args);
59
+ // Normalize service URLs: accept both `serviceUrl` (single, backward-compat) and
60
+ // `serviceUrls` (array, for apps with multiple OData services).
61
+ // When NEITHER is configured the plugin auto-discovers all internal service paths
62
+ // from the running CDS services at startup — zero config needed for any app size.
63
+ //
64
+ // Auto-discovery rules (applied inside cds.on('served')):
65
+ // - Include only services that have a .path starting with /odata
66
+ // - Exclude services backed by external .csn/.edmx imports (proxy services)
67
+ // because those forward to external systems not reachable via localhost
68
+ // - Exclude pure value-help / reference services with no writable entities
69
+ // (they are still indexed in the vector store via autoWatch)
70
+ const _configuredUrls = Array.isArray(serviceUrls)
71
+ ? serviceUrls.filter(Boolean)
72
+ : serviceUrl
73
+ ? [serviceUrl]
74
+ : null; // null = auto-discover at served time
75
+
76
+ // Resolved after 'served' fires; used for widget attribute + schema docs.
77
+ // Initialised to the first configured URL (or null) so the widget snippet
78
+ // built during 'bootstrap' has a value even before 'served' fires.
79
+ let _serviceUrls = _configuredUrls ?? [];
80
+ let _primarySvcUrl = _serviceUrls[0] ?? null;
81
+
82
+ // Base URL of this CAP server (e.g. http://localhost:4004).
83
+ // Sent to the chatbot backend so it can resolve relative OData paths to full URLs.
84
+ // Resolved from the CDS server address after 'served' fires.
85
+ // Can be overridden via BTP_COPILOT_BASE_URL env var for non-standard setups.
86
+ let _appBaseUrl = process.env.BTP_COPILOT_BASE_URL ?? null;
87
+
88
+ const log = (...args) => console.log("\x1b[36m[btp-copilot]\x1b[0m", ...args);
89
+ const warn = (...args) => console.warn("\x1b[33m[btp-copilot]\x1b[0m", ...args);
90
+ const err = (...args) => console.error("\x1b[31m[btp-copilot]\x1b[0m", ...args);
91
+
92
+ // ── HTML attribute escaping ───────────────────────────────────────────────────
93
+ // Prevents XSS if appName or other config values contain special characters.
94
+ function _escHtml(str) {
95
+ return String(str ?? "")
96
+ .replace(/&/g, "&")
97
+ .replace(/"/g, """)
98
+ .replace(/'/g, "'")
99
+ .replace(/</g, "&lt;")
100
+ .replace(/>/g, "&gt;");
101
+ }
102
+
103
+ // ── Widget auto-injection middleware ─────────────────────────────────────────
104
+ // Intercepts every HTML response from CAP and appends the <btp-copilot> tag
105
+ // + the widget script. Works for ALL Fiori/UI5 apps served by this CAP server
106
+ // without any manual changes to their index.html files.
107
+ function _buildWidgetSnippet() {
108
+ // Serve the widget JS bundle from node_modules so no CDN dependency
109
+ const widgetBundlePath = (() => {
110
+ try {
111
+ return require.resolve("cap-copilot-widget/dist/btp-copilot.js", {
112
+ paths: [process.cwd()],
113
+ });
114
+ } catch {
115
+ return null;
116
+ }
117
+ })();
118
+
119
+ if (!widgetBundlePath) {
120
+ warn(
121
+ "cap-copilot-widget not found in node_modules — falling back to unpkg.com CDN. " +
122
+ "Add cap-copilot-widget to your dependencies for offline/air-gapped deployments.",
123
+ );
124
+ }
125
+ const scriptTag = widgetBundlePath
126
+ ? `<script src="/__btp-copilot-widget/btp-copilot.js"></script>`
127
+ : `<script src="https://unpkg.com/cap-copilot-widget/dist/btp-copilot.js"></script>`;
128
+
129
+ const attrs = [
130
+ `app-id="${_escHtml(appId)}"`,
131
+ `position="${_escHtml(widgetPosition)}"`,
132
+ iframeUrl ? `iframe-url="${_escHtml(iframeUrl)}"` : "",
133
+ backendUrl ? `backend-url="${_escHtml(backendUrl)}"` : "",
134
+ _primarySvcUrl ? `service-url="${_escHtml(_primarySvcUrl)}"` : "",
135
+ appName ? `app-name="${_escHtml(appName)}"` : "",
136
+ ]
137
+ .filter(Boolean)
138
+ .join(" ");
139
+
140
+ return {
141
+ snippet: `\n${scriptTag}\n<btp-copilot ${attrs}></btp-copilot>\n</body>`,
142
+ widgetBundlePath,
143
+ };
144
+ }
145
+
146
+ function _registerWidgetMiddleware(app) {
147
+ // Always serve the widget bundle — even when iframeUrl is not configured
148
+ // so that apps that manually embed <btp-copilot> in their index.html can
149
+ // load the script from /__btp-copilot-widget/btp-copilot.js.
150
+ const { snippet, widgetBundlePath } = _buildWidgetSnippet();
151
+
152
+ if (widgetBundlePath) {
153
+ app.get("/__btp-copilot-widget/btp-copilot.js", (_req, res) => {
154
+ res.setHeader("Content-Type", "application/javascript");
155
+ res.setHeader("Cache-Control", "public, max-age=3600");
156
+ res.sendFile(widgetBundlePath);
157
+ });
158
+ log("Serving widget bundle at /__btp-copilot-widget/btp-copilot.js");
159
+ }
160
+
161
+ // Auto-inject widget into HTML responses only when iframeUrl is configured
162
+ if (!injectWidget || !iframeUrl) {
163
+ if (!iframeUrl)
164
+ warn(
165
+ 'No iframeUrl configured — widget auto-injection skipped. Add "iframeUrl" to cds.requires["btp-copilot"] or embed <btp-copilot> manually in your index.html.',
166
+ );
167
+ return;
168
+ }
169
+
170
+ // Intercept HTML responses and inject the widget before </body>
171
+ app.use((req, res, next) => {
172
+ const _write = res.write.bind(res);
173
+ const _end = res.end.bind(res);
174
+ let _buffer = "";
175
+ let _isHtml = false;
176
+ let _intercepted = false;
177
+
178
+ res.on("pipe", () => {});
179
+
180
+ const _intercept = () => {
181
+ const ct = res.getHeader("content-type") || "";
182
+ _isHtml = ct.includes("text/html");
183
+ };
184
+
185
+ const _inject = (chunk) => {
186
+ if (!_isHtml) return chunk;
187
+ const str = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk || "";
188
+ if (str.includes("</body>")) {
189
+ _intercepted = true;
190
+ return str.replace("</body>", snippet);
191
+ }
192
+ return str;
193
+ };
194
+
195
+ // Wrap write
196
+ res.write = function (chunk, encoding, callback) {
197
+ _intercept();
198
+ const patched = _inject(chunk);
199
+ return _write(patched, encoding, callback);
200
+ };
201
+
202
+ // Wrap end
203
+ res.end = function (chunk, encoding, callback) {
204
+ _intercept();
205
+ if (chunk) {
206
+ const patched = _inject(chunk);
207
+ return _end(patched, encoding, callback);
208
+ }
209
+ return _end(chunk, encoding, callback);
210
+ };
211
+
212
+ next();
213
+ });
214
+
215
+ log(
216
+ `Widget auto-injection active — <btp-copilot> will appear in all HTML pages.`,
217
+ );
218
+ }
219
+
220
+ // ── Graceful shutdown: flush debounce queue ───────────────────────────────────
221
+ // Ensures any queued entity data is sent before the process exits so no records
222
+ // are silently lost when cds watch restarts or the container is killed.
223
+ const _gracefulShutdown = async () => {
224
+ try {
225
+ const { flushAll } = require("./src/DataSender");
226
+ log("Flushing pending data before shutdown…");
227
+ await flushAll();
228
+ } catch {}
229
+ };
230
+ // cds.on("shutdown") is the preferred CDS lifecycle hook; SIGTERM is the fallback
231
+ // for Docker/Kubernetes environments where the CDS hook may not fire.
232
+ try { cds.on("shutdown", _gracefulShutdown); } catch {}
233
+ process.once("SIGTERM", _gracefulShutdown);
25
234
 
26
235
  if (!backendUrl || !appId) {
27
- log('No backendUrl/appId in cds.requires["btp-copilot"] — skipping registration.');
236
+ log(
237
+ 'No backendUrl/appId in cds.requires["btp-copilot"] — skipping registration.',
238
+ );
28
239
  } else if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
29
- warn(`appId "${appId}" contains invalid characters. Use only letters, digits, _ and -.`);
240
+ warn(
241
+ `appId "${appId}" contains invalid characters. Use only letters, digits, _ and -.`,
242
+ );
30
243
  } else {
31
- cds.on('served', async (services) => {
32
- try {
33
- log(`Collecting context for app "${appId}"…`);
34
- const allDocs = [];
35
-
36
- const schemaDocs = extract(cds, { serviceUrl });
37
- allDocs.push(...schemaDocs);
38
- log(` + ${schemaDocs.length} schema documents`);
39
-
40
- const serviceList = Array.isArray(services) ? services : Object.values(services ?? {});
41
- const runtimeDocs = extractFromServices(serviceList);
42
- allDocs.push(...runtimeDocs);
43
- if (runtimeDocs.length) log(` + ${runtimeDocs.length} runtime service documents`);
44
-
45
- if (includeProjectFiles) {
46
- const projectRoot = cds.env.root ?? process.cwd();
47
- const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
48
- allDocs.push(...fileDocs);
49
- log(` + ${fileDocs.length} project file documents`);
244
+ // Register the middleware as early as possible so it wraps all HTML routes
245
+ cds.on("bootstrap", (app) => {
246
+ _registerWidgetMiddleware(app);
247
+ });
248
+
249
+ cds.on("served", (services) => {
250
+ // Resolve the CAP server's own listening URL so relative service paths can be
251
+ // reconstructed into full URLs by the chatbot backend.
252
+ if (!_appBaseUrl) {
253
+ try {
254
+ // Cloud Foundry / BTP: external URL from VCAP_APPLICATION
255
+ const vcap = process.env.VCAP_APPLICATION;
256
+ if (vcap) {
257
+ const vcapData = JSON.parse(vcap);
258
+ const externalUri = vcapData.application_uris?.[0];
259
+ if (externalUri) {
260
+ _appBaseUrl = `https://${externalUri}`;
261
+ log(`Detected BTP/CF external URL: ${_appBaseUrl}`);
262
+ }
263
+ }
264
+ // Local development: use the actual listening port
265
+ if (!_appBaseUrl) {
266
+ const addr = cds.server?.address?.();
267
+ const port = addr?.port || cds.env?.server?.port || 4004;
268
+ _appBaseUrl = `http://localhost:${port}`;
269
+ }
270
+ } catch {
271
+ _appBaseUrl = "http://localhost:4004";
272
+ }
273
+ }
274
+
275
+ // ── Layer 1: Auto-register READ + mutation hooks on all entities ──────────
276
+ // Every OData read AND every create/update is captured automatically.
277
+ // In multiTenant mode: hooks still fire but the data is NOT sent to the
278
+ // vector store — the SDK silently skips push since each user's OData data
279
+ // must stay private and is fetched live at query time instead.
280
+ if (autoWatch && !multiTenant) {
281
+ const { watchEntity: _autoWatch } = require("./src/DataSender");
282
+ const svcList = Array.isArray(services)
283
+ ? services
284
+ : Object.values(services ?? {});
285
+ let watchCount = 0;
286
+ for (const svc of svcList) {
287
+ if (typeof svc.after !== "function" || !svc.entities) continue;
288
+ for (const [, entity] of Object.entries(svc.entities)) {
289
+ _autoWatch(svc, entity, { backendUrl, appId, token, appName });
290
+ watchCount++;
291
+ }
292
+ }
293
+ if (watchCount > 0)
294
+ log(
295
+ `Auto-watching ${watchCount} entit${watchCount === 1 ? "y" : "ies"} (READ + mutations) across all services.`,
296
+ );
297
+ } else if (autoWatch && multiTenant) {
298
+ log(
299
+ "multiTenant=true — skipping autoWatch (raw record push). Live user data is fetched via OData at query time.",
300
+ );
301
+ }
302
+
303
+ // ── Layer 2: Background startup sync ─────────────────────────────────────
304
+ // Catches all historical data in the DB — not just records visited today.
305
+ // Incremental on restart: only re-syncs rows changed since last sync.
306
+ if (autoSync) {
307
+ const { syncAllEntities } = require("./src/DataSender");
308
+ // Use os.tmpdir() so cds watch never sees this file and doesn't restart
309
+ const cacheFile = path.join(
310
+ require("os").tmpdir(),
311
+ `.btp-copilot-${appId}.json`,
312
+ );
313
+
314
+ let modifiedSince = null;
315
+ try {
316
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
317
+ modifiedSince = cached.lastSyncedAt ?? null;
318
+ } catch {}
319
+
320
+ const svcList = Array.isArray(services)
321
+ ? services
322
+ : Object.values(services ?? {});
323
+ const syncStart = new Date().toISOString();
324
+
325
+ setImmediate(async () => {
326
+ try {
327
+ // Startup jitter: delay by a random amount so that when 100+ apps
328
+ // restart simultaneously they don't all hammer the backend at once.
329
+ if (syncJitter) {
330
+ const jitterMs = Math.floor(Math.random() * 30_000);
331
+ log(
332
+ `Startup sync delayed ${Math.round(jitterMs / 1000)}s (jitter) to spread backend load.`,
333
+ );
334
+ await new Promise((r) => setTimeout(r, jitterMs));
335
+ }
336
+ log(
337
+ modifiedSince
338
+ ? `Starting incremental sync (changes since ${modifiedSince})…`
339
+ : `Starting full background sync — all entity data will be indexed…`,
340
+ );
341
+ const total = await syncAllEntities(
342
+ cds,
343
+ svcList,
344
+ { backendUrl, appId, token, appName },
345
+ {
346
+ pageSize: syncPageSize,
347
+ modifiedSince,
348
+ multiTenant,
349
+ },
350
+ );
351
+ log(
352
+ `\x1b[32m✔ Background sync complete — ${total} row(s) indexed.\x1b[0m`,
353
+ );
354
+ try {
355
+ let cached = {};
356
+ try {
357
+ cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
358
+ } catch {}
359
+ fs.writeFileSync(
360
+ cacheFile,
361
+ JSON.stringify({ ...cached, lastSyncedAt: syncStart }),
362
+ );
363
+ } catch {}
364
+ } catch (e) {
365
+ warn("Background sync error —", e.message);
366
+ }
367
+ });
368
+ }
369
+
370
+ // ── Layer 3: Auto-discover + register OData service(s) as live query tools ─
371
+ // When the user has not configured serviceUrl(s) we build the list automatically
372
+ // from every CDS service that has an /odata path and real (non-external) entities.
373
+ // This means zero config for any app, regardless of how many services it has.
374
+ {
375
+ const svcList = Array.isArray(services)
376
+ ? services
377
+ : Object.values(services ?? {});
378
+
379
+ // ── Identify external/proxy services (backed by .csn/.edmx imports) ───
380
+ // CDS marks external service definitions with a "from" pointing to an
381
+ // external file. We detect them by checking the model definitions.
382
+ const _externalServiceNames = new Set();
383
+ try {
384
+ const defs = cds.model?.definitions ?? {};
385
+ for (const [name, def] of Object.entries(defs)) {
386
+ if (def.kind === "service") {
387
+ const src = def.$location?.file ?? def["@src"] ?? "";
388
+ if (src && !/\.cds$/i.test(src)) _externalServiceNames.add(name);
389
+ }
390
+ }
391
+ } catch {
392
+ /* ignore */
50
393
  }
51
394
 
52
- if (!allDocs.length) { log('No documents to register.'); return; }
395
+ // ── Auto-discover: build URL list from running services ───────────────
396
+ if (_configuredUrls === null) {
397
+ _serviceUrls = svcList
398
+ .filter((svc) => {
399
+ // Must have an /odata path
400
+ if (!svc.path?.startsWith("/odata")) return false;
401
+ // Skip CDS-internal services: the database service (cds.db) is
402
+ // accessible via svc.path="/odata/v4/db" but is NOT a real OData
403
+ // endpoint — it is the internal persistence layer. Same for any
404
+ // service whose name is exactly "db" or is a DatabaseService instance.
405
+ if (svc.name === "db" || svc.path === "/odata/v4/db") return false;
406
+ if (cds.DatabaseService && svc instanceof cds.DatabaseService)
407
+ return false;
408
+ // Skip external proxy services (backed by .csn/.edmx imports)
409
+ if (_externalServiceNames.has(svc.name)) return false;
410
+ // Must expose at least one entity
411
+ if (!svc.entities || Object.keys(svc.entities).length === 0)
412
+ return false;
413
+ return true;
414
+ })
415
+ .map((svc) => svc.path);
53
416
 
54
- const toSend = Math.min(allDocs.length, 50);
55
- log(`Registering ${toSend} of ${allDocs.length} documents with backend…`);
417
+ _primarySvcUrl = _serviceUrls[0] ?? null;
418
+ if (_serviceUrls.length) {
419
+ log(
420
+ `Auto-discovered ${_serviceUrls.length} OData service(s) for live-query tool registration.`,
421
+ );
422
+ }
423
+ }
424
+
425
+ if (_serviceUrls.length) {
426
+ setImmediate(() => {
427
+ const registrations = _serviceUrls.map((svcUrl) => {
428
+ // Match the service whose path equals this URL.
429
+ const matchedSvc = svcList.find((svc) => svc.path === svcUrl);
56
430
 
57
- const ok = await register({ backendUrl, appId, appName: appName ?? appId, serviceUrl, documents: allDocs, token });
58
- if (ok) {
59
- log(`\x1b[32m✔ App "${appId}" registered — chatbot is now context-aware!\x1b[0m`);
60
- } else {
61
- warn(`✘ Registration failed. Backend: ${backendUrl} — start your chatbot backend to enable AI answers.`);
431
+ // Only pass entities that belong to THIS service.
432
+ const entityNames = matchedSvc?.entities
433
+ ? Object.keys(matchedSvc.entities)
434
+ : [];
435
+
436
+ return registerServiceTool({
437
+ backendUrl,
438
+ appId,
439
+ appName,
440
+ serviceUrl: svcUrl,
441
+ entities: entityNames,
442
+ token,
443
+ appBaseUrl: _appBaseUrl,
444
+ });
445
+ });
446
+ Promise.all(registrations).catch(() => {});
447
+ });
62
448
  }
63
- } catch (e) {
64
- err('Registration error —', e.message);
65
449
  }
450
+ // ─────────────────────────────────────────────────────────────────────────
451
+
452
+ setImmediate(async () => {
453
+ try {
454
+ log(`Collecting context for app "${appId}"…`);
455
+ const allDocs = [];
456
+
457
+
458
+ const serviceList = Array.isArray(services)
459
+ ? services
460
+ : Object.values(services ?? {});
461
+
462
+ const schemaDocs = extract(cds, {
463
+ serviceUrl: _primarySvcUrl,
464
+ serviceList,
465
+ });
466
+ allDocs.push(...schemaDocs);
467
+ log(` + ${schemaDocs.length} schema documents`);
468
+
469
+ const runtimeDocs = extractFromServices(serviceList);
470
+ allDocs.push(...runtimeDocs);
471
+ if (runtimeDocs.length)
472
+ log(` + ${runtimeDocs.length} runtime service documents`);
473
+
474
+ if (includeProjectFiles) {
475
+ const projectRoot = cds.env.root ?? process.cwd();
476
+ const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
477
+ allDocs.push(...fileDocs);
478
+ log(` + ${fileDocs.length} project file documents`);
479
+ }
480
+
481
+ if (!allDocs.length) {
482
+ log("No documents to register.");
483
+ return;
484
+ }
485
+
486
+ // ── Hash-based cache: skip re-registration if docs haven't changed ──
487
+ const crypto = require("crypto");
488
+ // Use os.tmpdir() so cds watch never sees this file and doesn't restart
489
+ const cacheFile = path.join(
490
+ require("os").tmpdir(),
491
+ `.btp-copilot-${appId}.json`,
492
+ );
493
+ const contentHash = crypto
494
+ .createHash("sha256")
495
+ .update(allDocs.map((d) => d.title + d.content).join("|"))
496
+ .digest("hex");
497
+
498
+ try {
499
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
500
+ if (cached.appId === appId && cached.hash === contentHash) {
501
+ log(
502
+ `\x1b[32m✔ Schema unchanged — skipping re-registration (cached). Live data will still be seeded on first READ.\x1b[0m`,
503
+ );
504
+ return;
505
+ }
506
+ } catch {
507
+ // No cache file yet — proceed with registration
508
+ }
509
+ // ────────────────────────────────────────────────────────────────────
510
+
511
+ const toSend = Math.min(allDocs.length, maxDocs);
512
+ if (toSend < allDocs.length) {
513
+ warn(
514
+ `Sending ${toSend} of ${allDocs.length} documents (maxDocs=${maxDocs}). ` +
515
+ `Increase via cds.requires["btp-copilot"].maxDocs to index all documents.`,
516
+ );
517
+ }
518
+ log(
519
+ `Registering ${toSend} of ${allDocs.length} documents with backend…`,
520
+ );
521
+
522
+ const ok = await register({
523
+ backendUrl,
524
+ appId,
525
+ appName: appName ?? appId,
526
+ serviceUrl,
527
+ documents: allDocs,
528
+ token,
529
+ });
530
+ if (ok) {
531
+ // Save cache so next restart skips re-registration
532
+ try {
533
+ fs.writeFileSync(
534
+ cacheFile,
535
+ JSON.stringify({ appId, hash: contentHash, ts: Date.now() }),
536
+ );
537
+ } catch {
538
+ /* ignore cache write errors */
539
+ }
540
+ log(
541
+ `\x1b[32m✔ App "${appId}" registered — chatbot is now context-aware!\x1b[0m`,
542
+ );
543
+ } else {
544
+ warn(
545
+ `✘ Registration failed. Backend: ${backendUrl} — start your chatbot backend to enable AI answers.`,
546
+ );
547
+ }
548
+ } catch (e) {
549
+ err("Registration error —", e.message);
550
+ }
551
+ }); // end setImmediate
66
552
  });
67
553
  }
554
+
555
+ // ── Public API ────────────────────────────────────────────────────────────────
556
+ const {
557
+ sendEntityData,
558
+ watchEntity: _watchEntity,
559
+ syncAllEntities: _syncAll,
560
+ } = require("./src/DataSender");
561
+
562
+ /**
563
+ * Push live DB query results to the BTP Copilot backend from any CAP
564
+ * service handler or action — minimum code, no extra config needed.
565
+ *
566
+ * The plugin already has backendUrl / appId / token from cds.env so you
567
+ * only pass the entity name and the data you just queried.
568
+ *
569
+ * @example
570
+ * const { sendData } = require('cap-copilot-sdk');
571
+ *
572
+ * // inside any srv.on / srv.before / srv.after handler:
573
+ * const data = await tx.run(SELECT.from(Orders).where({ ID: orderID }));
574
+ * await sendData('Orders', data);
575
+ *
576
+ * @param {string} entityName - entity name, e.g. 'Orders'
577
+ * @param {object[]|object} data - result of tx.run(SELECT.from(...))
578
+ * @param {string} [label] - optional custom document title
579
+ * @returns {Promise<boolean>}
580
+ */
581
+ module.exports.sendData = (entityName, data, label) =>
582
+ sendEntityData(
583
+ { backendUrl, appId, token, appName },
584
+ entityName,
585
+ data,
586
+ label,
587
+ );
588
+
589
+ /**
590
+ * Register an after-READ hook so this entity's read results are pushed to the
591
+ * BTP Copilot chatbot automatically — no manual sendData calls needed.
592
+ *
593
+ * This is the recommended pattern for CAP apps. Only data that the user
594
+ * actually reads is sent, so it scales to large tables without bulk dumps.
595
+ *
596
+ * @example
597
+ * const { watchEntity } = require('cap-copilot-sdk');
598
+ *
599
+ * // In module.exports = (srv) => { ... }:
600
+ * const { FertilizerBlend, AssignMaterialToBlend, UnitsOfMeasuresV1 } = srv.entities;
601
+ *
602
+ * // Transactional: group by key so each order is a separate titled document
603
+ * watchEntity(srv, FertilizerBlend, { groupByKey: 'orderID' });
604
+ * watchEntity(srv, AssignMaterialToBlend, { groupByKey: 'to_FertilizerBlend_orderID' });
605
+ *
606
+ * // Reference/lookup: send only on the first READ, then cache
607
+ * watchEntity(srv, UnitsOfMeasuresV1, { once: true });
608
+ *
609
+ * @param {object} srv
610
+ * @param {object} entity CDS entity from srv.entities
611
+ * @param {object} [options]
612
+ * @param {string} [options.groupByKey] Field to group rows by (one doc per unique value)
613
+ * @param {string} [options.labelPrefix] Custom document title prefix (default: entity name)
614
+ * @param {boolean} [options.once] Only send on the first READ
615
+ */
616
+ module.exports.watchEntity = (srv, entity, options) =>
617
+ _watchEntity(srv, entity, { backendUrl, appId, token, appName }, options);
618
+
619
+ /**
620
+ * Manually trigger a paginated background sync of all entities across all services.
621
+ * Useful for triggering a re-sync after bulk imports or migrations.
622
+ *
623
+ * @param {object} cds - @sap/cds instance
624
+ * @param {object[]} services - array of CDS service instances
625
+ * @param {object} [options]
626
+ * @param {number} [options.pageSize] rows per page (default: 100)
627
+ * @param {string} [options.modifiedSince] ISO timestamp — only sync rows newer than this
628
+ */
629
+ module.exports.syncAll = (cds, services, options) =>
630
+ _syncAll(cds, services, { backendUrl, appId, token, appName }, options);