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/package.json CHANGED
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "name": "cap-copilot-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "CDS plugin: auto-extracts entity schemas, relationships, project structure and registers with BTP Copilot backend",
5
5
  "main": "cds-plugin.js",
6
- "files": ["src", "cds-plugin.js"],
6
+ "exports": {
7
+ ".": "./cds-plugin.js",
8
+ "./cds-plugin": "./cds-plugin.js"
9
+ },
10
+ "files": ["src", "cds-plugin.js", "postinstall.js"],
11
+ "scripts": {
12
+ "postinstall": "node postinstall.js"
13
+ },
7
14
  "keywords": ["sap", "cap", "cds", "btp", "ai", "chatbot", "plugin"],
8
15
  "license": "MIT",
16
+ "cds": {
17
+ "plugin": true
18
+ },
9
19
  "peerDependencies": {
10
20
  "@sap/cds": ">=7.0.0"
11
21
  }
package/postinstall.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * postinstall.js — auto-configure consuming CAP app's package.json
3
+ *
4
+ * Runs automatically after `npm install cap-copilot-sdk`.
5
+ * Adds only the app-specific keys (appId, appName) to cds.requires["btp-copilot"].
6
+ *
7
+ * URL defaults (backendUrl, iframeUrl) are intentionally kept inside the SDK —
8
+ * they are not written to the consuming app's package.json so developers never
9
+ * need to see or manage them. Override via env vars when deploying to production:
10
+ * BTP_COPILOT_URL → backend URL
11
+ * BTP_COPILOT_IFRAME_URL → frontend chatbot UI URL
12
+ */
13
+ "use strict";
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ // Set BTP_COPILOT_SKIP_POSTINSTALL=1 to opt out of auto-configuration.
18
+ // Useful in CI/CD pipelines where package.json is read-only or managed by tooling.
19
+ if (process.env.BTP_COPILOT_SKIP_POSTINSTALL === "1") process.exit(0);
20
+
21
+ // INIT_CWD = directory from which `npm install` was run (the consuming app root)
22
+ const appRoot = process.env.INIT_CWD;
23
+
24
+ if (!appRoot || appRoot === __dirname || appRoot === path.resolve(__dirname, "../..")) {
25
+ process.exit(0);
26
+ }
27
+
28
+ const pkgPath = path.join(appRoot, "package.json");
29
+ if (!fs.existsSync(pkgPath)) process.exit(0);
30
+
31
+ let pkg;
32
+ try {
33
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
34
+ } catch {
35
+ process.exit(0);
36
+ }
37
+
38
+ const hasCds = pkg.dependencies?.["@sap/cds"] || pkg.devDependencies?.["@sap/cds"];
39
+ if (!hasCds) process.exit(0);
40
+
41
+ const cdsSection = pkg.cds ?? {};
42
+ const requires = cdsSection.requires ?? {};
43
+ const existing = requires["btp-copilot"] ?? {};
44
+
45
+ // Only the app-specific keys belong in package.json.
46
+ // URL defaults are owned by the SDK (cds-plugin.js) — not exposed to the user.
47
+ const safeId = (pkg.name ?? "cap-app").replace(/[^a-zA-Z0-9_-]/g, "-");
48
+ const appDefaults = {
49
+ appId: existing.appId ?? safeId,
50
+ appName: existing.appName ?? pkg.description ?? pkg.name ?? "CAP App",
51
+ };
52
+
53
+ const changed =
54
+ existing.appId === undefined ||
55
+ existing.appName === undefined ||
56
+ existing.backendUrl !== undefined ||
57
+ existing.iframeUrl !== undefined;
58
+
59
+ if (!changed) {
60
+ process.exit(0);
61
+ }
62
+
63
+ requires["btp-copilot"] = { ...existing, ...appDefaults };
64
+
65
+ // Remove URL keys if a previous version of postinstall wrote them — they belong in the SDK
66
+ delete requires["btp-copilot"].backendUrl;
67
+ delete requires["btp-copilot"].iframeUrl;
68
+
69
+ cdsSection.requires = requires;
70
+ pkg.cds = cdsSection;
71
+
72
+ try {
73
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
74
+ console.log(`\x1b[36m[cap-copilot-sdk]\x1b[0m \u2714 Configured cds.requires["btp-copilot"] in package.json`);
75
+ console.log(`\x1b[36m[cap-copilot-sdk]\x1b[0m appId="${appDefaults.appId}" | appName="${appDefaults.appName}"`);
76
+ console.log(`\x1b[36m[cap-copilot-sdk]\x1b[0m To use custom backend/frontend URLs set env vars:`);
77
+ console.log(`\x1b[36m[cap-copilot-sdk]\x1b[0m BTP_COPILOT_URL=https://your-backend.cfapps.eu10.hana.ondemand.com`);
78
+ console.log(`\x1b[36m[cap-copilot-sdk]\x1b[0m BTP_COPILOT_IFRAME_URL=https://your-frontend.cfapps.eu10.hana.ondemand.com`);
79
+ } catch {
80
+ // Non-fatal
81
+ }
@@ -0,0 +1,469 @@
1
+ "use strict";
2
+ const { register } = require("./Registrar");
3
+
4
+ const DEBOUNCE_MS = 2000;
5
+ const MAX_RECORDS = 200;
6
+ // Truncate individual document content to prevent extremely large payloads.
7
+ // Content exceeding this is cut with a [truncated] marker so the backend still
8
+ // receives a valid document rather than an oversized JSON payload.
9
+ const MAX_DOC_CONTENT_BYTES = 512_000; // 512 KB per document
10
+
11
+ // Global rate limiter: enforce a minimum gap between consecutive flushes
12
+ // (across ALL apps in this process) so the backend never receives more than
13
+ // ~5 bulk pushes per second even under heavy parallel load.
14
+ const MIN_FLUSH_GAP_MS = 200;
15
+ let _lastFlushAt = 0;
16
+
17
+ function _sleep(ms) {
18
+ return new Promise((r) => setTimeout(r, ms));
19
+ }
20
+
21
+ // Per-app debounce queue: appId -> { cfg, docs: Map<title,content>, timer, flushing }
22
+ const _queues = new Map();
23
+
24
+ function _getQueue(cfg) {
25
+ if (!_queues.has(cfg.appId)) {
26
+ _queues.set(cfg.appId, {
27
+ cfg,
28
+ docs: new Map(),
29
+ timer: null,
30
+ flushing: false,
31
+ });
32
+ }
33
+ return _queues.get(cfg.appId);
34
+ }
35
+
36
+ async function _flush(appId) {
37
+ const q = _queues.get(appId);
38
+ if (!q || q.docs.size === 0 || q.flushing) return;
39
+
40
+ // Snapshot and clear immediately so new writes arriving during the HTTP
41
+ // round-trip are queued into a fresh batch, not lost.
42
+ const documents = Array.from(q.docs.entries()).map(([title, content]) => ({
43
+ title,
44
+ content: Buffer.byteLength(content) > MAX_DOC_CONTENT_BYTES
45
+ ? content.slice(0, MAX_DOC_CONTENT_BYTES) + "\n[truncated — content exceeded 512 KB]"
46
+ : content,
47
+ }));
48
+ q.docs.clear();
49
+ q.timer = null;
50
+ q.flushing = true;
51
+
52
+ // Global rate limit: wait if we flushed too recently
53
+ const gap = _lastFlushAt + MIN_FLUSH_GAP_MS - Date.now();
54
+ if (gap > 0) await _sleep(gap);
55
+ _lastFlushAt = Date.now();
56
+
57
+ console.log(
58
+ `[btp-copilot] Flushing ${documents.length} queued document(s) to backend…`,
59
+ );
60
+
61
+ const { cfg } = q;
62
+ try {
63
+ await register({
64
+ backendUrl: cfg.backendUrl,
65
+ appId: cfg.appId,
66
+ appName: cfg.appName,
67
+ token: cfg.token,
68
+ documents,
69
+ replace: false,
70
+ });
71
+ } finally {
72
+ q.flushing = false;
73
+ // Re-schedule if new docs arrived while the HTTP request was in flight
74
+ if (q.docs.size > 0 && !q.timer) {
75
+ q.timer = setTimeout(() => _flush(appId), DEBOUNCE_MS);
76
+ }
77
+ }
78
+ }
79
+
80
+ async function sendEntityData(cfg, entityName, data, label) {
81
+ const raw = Array.isArray(data) ? data : data != null ? [data] : [];
82
+
83
+ // Filter out $count-only responses. CAP fires after("READ") for
84
+ // GET /Entity/$count requests too. Depending on the CAP version the handler
85
+ // receives either a plain number (22), or a single-key object ({"$count":22}).
86
+ // Neither is real record data — drop them to avoid corrupting the index.
87
+ const records = raw.filter(
88
+ (r) =>
89
+ r != null &&
90
+ typeof r === "object" &&
91
+ !("$count" in r && Object.keys(r).length === 1) &&
92
+ !("@odata.count" in r && Object.keys(r).length === 1),
93
+ );
94
+
95
+ if (records.length === 0) return false;
96
+
97
+ if (!cfg.backendUrl || !cfg.appId) {
98
+ console.warn(
99
+ "[btp-copilot] sendData: backendUrl or appId not configured — skipping push. " +
100
+ 'Ensure cds.requires["btp-copilot"].backendUrl and .appId are set.',
101
+ );
102
+ return false;
103
+ }
104
+
105
+ const capped =
106
+ records.length > MAX_RECORDS ? records.slice(0, MAX_RECORDS) : records;
107
+ if (capped.length < records.length) {
108
+ console.log(
109
+ `[btp-copilot] sendData("${entityName}") — ${records.length} records, truncated to ${MAX_RECORDS}`,
110
+ );
111
+ }
112
+
113
+ console.log(
114
+ `[btp-copilot] sendData("${entityName}") — ${capped.length} record(s) being pushed to chatbot`,
115
+ );
116
+ if (capped.length > 0) {
117
+ if (cfg.verboseLogging) {
118
+ // Full JSON for every record — set verboseLogging:true or BTP_COPILOT_VERBOSE=true
119
+ console.log(
120
+ `[btp-copilot] full payload (${entityName}):\n${JSON.stringify(capped, null, 2)}`,
121
+ );
122
+ } else {
123
+ const sample = JSON.stringify(capped[0]);
124
+ console.log(
125
+ `[btp-copilot] sample record[0]: ${
126
+ sample.length > 300 ? sample.slice(0, 300) + "..." : sample
127
+ }`,
128
+ );
129
+ }
130
+ }
131
+
132
+
133
+ const shortEntityName = entityName.split(".").pop();
134
+ const title = label ?? `${shortEntityName} entity data`;
135
+ const content = [
136
+ `Entity: ${shortEntityName}`,
137
+ `Record count: ${capped.length}`,
138
+ `Pushed at: ${new Date().toISOString()}`,
139
+ "",
140
+ "Records:",
141
+ JSON.stringify(capped, null, 2),
142
+ ].join("\n");
143
+
144
+ const q = _getQueue(cfg);
145
+ q.docs.set(title, content);
146
+
147
+ if (q.timer) clearTimeout(q.timer);
148
+ q.timer = setTimeout(() => _flush(cfg.appId), DEBOUNCE_MS);
149
+
150
+ return true;
151
+ }
152
+
153
+ function watchEntity(srv, entity, cfg, options = {}) {
154
+ const { groupByKey, labelPrefix, once = false } = options;
155
+ const entityName = entity.name ?? String(entity);
156
+ let _readSent = false;
157
+
158
+ function _push(rows) {
159
+ setImmediate(async () => {
160
+ try {
161
+ if (groupByKey) {
162
+ const groups = {};
163
+ for (const row of rows) {
164
+ const id = row[groupByKey];
165
+ if (id == null) continue;
166
+ (groups[id] = groups[id] || []).push(row);
167
+ }
168
+ await Promise.all(
169
+ Object.entries(groups).map(([id, groupRows]) =>
170
+ sendEntityData(
171
+ cfg,
172
+ entityName,
173
+ groupRows,
174
+ `${labelPrefix ?? entityName} ${id}`,
175
+ ),
176
+ ),
177
+ );
178
+ } else {
179
+ await sendEntityData(cfg, entityName, rows);
180
+ }
181
+ } catch (e) {
182
+ console.warn(
183
+ `[btp-copilot] watchEntity(${entityName}) failed:`,
184
+ e.message,
185
+ );
186
+ }
187
+ });
188
+ }
189
+
190
+ // ── Layer 1a: READ hook — push data as the user views it ──────────────────
191
+ srv.after("READ", entity, (results) => {
192
+ if (!results) return;
193
+ if (once && _readSent) return;
194
+ const rows = Array.isArray(results) ? results : [results];
195
+ if (rows.length === 0) return;
196
+ if (once) _readSent = true;
197
+ _push(rows);
198
+ });
199
+
200
+ // ── Layer 1b: Mutation hooks — push immediately when data is written ───────
201
+ // This is critical: records created via API, imports, migrations, or batch
202
+ // jobs are NEVER seen by the READ hook. Without this, the chatbot is blind
203
+ // to any data the user creates but hasn't navigated back to view.
204
+ if (!once) {
205
+ srv.after(["CREATE", "UPDATE"], entity, (result) => {
206
+ if (!result) return;
207
+ const rows = Array.isArray(result) ? result : [result];
208
+ if (rows.length === 0) return;
209
+ _push(rows);
210
+ });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Background paginated sync of all entities across all services.
216
+ *
217
+ * TWO MODES:
218
+ *
219
+ * multiTenant=false (default, single-user / dev):
220
+ * Pushes raw records per page to the vector store. The AI can answer
221
+ * questions about any record without a live OData call.
222
+ *
223
+ * multiTenant=true (recommended for production apps with many users):
224
+ * Pushes ONLY per-entity aggregate stats (total count + field list).
225
+ * Raw record data is NEVER stored in the shared vector store — it is
226
+ * fetched at query time via OData using each user's own JWT token.
227
+ * This prevents User A's records from appearing in User B's answers.
228
+ *
229
+ * @param {object} cds - @sap/cds instance
230
+ * @param {object[]} services - array of CDS service instances
231
+ * @param {object} cfg - { backendUrl, appId, token, appName }
232
+ * @param {object} [options]
233
+ * @param {number} [options.pageSize=100] rows per document page
234
+ * @param {string} [options.modifiedSince] ISO timestamp — only sync newer records
235
+ * @param {boolean} [options.multiTenant=false] true = push stats only, not raw records
236
+ * @returns {Promise<number>} total rows synced (0 in multiTenant mode)
237
+ */
238
+ async function syncAllEntities(cds, services, cfg, options = {}) {
239
+ const { pageSize = 100, modifiedSince = null, multiTenant = false } = options;
240
+
241
+ // ── multiTenant mode: push per-entity stats only ─────────────────────────
242
+ // Each entity gets a single summary document: entity name, total count, and
243
+ // the fields that exist. The AI knows the shape of the data and how many
244
+ // records there are, but raw records are never stored in the shared vector
245
+ // store. Per-user, per-record data comes live via OData at chat time.
246
+ if (multiTenant) {
247
+ let db;
248
+ try { db = await cds.connect.to("db"); } catch { db = null; }
249
+
250
+ const svcList = Array.isArray(services) ? services : Object.values(services ?? {});
251
+ for (const svc of svcList) {
252
+ if (!svc.entities) continue;
253
+ for (const [entityName, entity] of Object.entries(svc.entities)) {
254
+ try {
255
+ const fields = entity.elements
256
+ ? Object.keys(entity.elements).filter((k) => !k.startsWith("_")).join(", ")
257
+ : "unknown";
258
+ let totalCount = "unknown";
259
+ if (db) {
260
+ try {
261
+ const { SELECT } = cds;
262
+ const r = await db.run(SELECT.one.from(entity).columns("count(*) as n"));
263
+ totalCount = r?.n ?? 0;
264
+ } catch { /* entity not queryable */ }
265
+ }
266
+ const content = [
267
+ `Entity: ${entityName}`,
268
+ `Total records in database: ${totalCount}`,
269
+ `Available fields: ${fields}`,
270
+ `Note: Live per-user data is fetched via OData at query time using the user's token.`,
271
+ ].join("\n");
272
+ const q = _getQueue(cfg);
273
+ q.docs.set(`${entityName} entity summary`, content);
274
+ if (q.timer) clearTimeout(q.timer);
275
+ q.timer = setTimeout(() => _flush(cfg.appId), DEBOUNCE_MS);
276
+ } catch { /* skip unqueryable entities */ }
277
+ }
278
+ }
279
+ return 0;
280
+ }
281
+
282
+
283
+ let db;
284
+ try {
285
+ db = await cds.connect.to("db");
286
+ } catch {
287
+ console.warn(
288
+ "[btp-copilot] syncAllEntities: could not connect to db — skipping startup sync.",
289
+ );
290
+ return 0;
291
+ }
292
+
293
+ // cds.SELECT may not be directly on the module depending on @sap/cds version;
294
+ // try cds.SELECT first, then cds.ql.SELECT (both are the same object).
295
+ const SELECT = cds.SELECT ?? cds.ql?.SELECT;
296
+ if (!SELECT) {
297
+ console.warn(
298
+ "[btp-copilot] syncAllEntities: cds.SELECT not available — skipping background sync.",
299
+ );
300
+ return 0;
301
+ }
302
+
303
+ const svcList = Array.isArray(services)
304
+ ? services
305
+ : Object.values(services ?? {});
306
+ let totalSynced = 0;
307
+
308
+ for (const svc of svcList) {
309
+ if (!svc.entities) continue;
310
+ for (const [entityName, entity] of Object.entries(svc.entities)) {
311
+ try {
312
+ const hasModifiedAt =
313
+ entity.elements && "modifiedAt" in entity.elements;
314
+
315
+ // Incremental: skip entities with no changes since last sync.
316
+ // Use CDS tagged-template syntax for parameterized queries — never string interpolation.
317
+ if (modifiedSince && hasModifiedAt) {
318
+ const changed = await db.run(
319
+ SELECT.from(entity).columns("count(*) as n").where`modifiedAt > ${modifiedSince}`,
320
+ );
321
+ if (!changed?.[0]?.n) continue;
322
+ }
323
+
324
+ // Count total records for the stats document
325
+ let totalCount = "unknown";
326
+ try {
327
+ const r = await db.run(SELECT.from(entity).columns("count(*) as n"));
328
+ totalCount = r?.[0]?.n ?? 0;
329
+ } catch { /* entity may not support COUNT (e.g. view, external) */ }
330
+
331
+ const fields = entity.elements
332
+ ? Object.keys(entity.elements).filter((k) => !k.startsWith("_")).join(", ")
333
+ : "unknown";
334
+
335
+ // Avoid SELECT * which picks up virtual elements (e.g. IsActiveEntity
336
+ // on draft-enabled entities) that have no DB column and cause SQL errors.
337
+ // Association/composition elements also have no column (they use FK cols).
338
+ const persistedCols = entity.elements
339
+ ? Object.entries(entity.elements)
340
+ .filter(([, el]) => !el.virtual && !el.target)
341
+ .map(([name]) => name)
342
+ : [];
343
+
344
+ // Paginate through ALL records — no artificial cap.
345
+ // Each page is stored as a separate titled document so the vector
346
+ // store can retrieve any record regardless of how large the table is.
347
+ let offset = 0;
348
+ let pageNum = 0;
349
+
350
+ while (true) {
351
+ const baseQ =
352
+ persistedCols.length > 0
353
+ ? SELECT.from(entity).columns(persistedCols)
354
+ : SELECT.from(entity);
355
+ const pageQ = hasModifiedAt
356
+ ? baseQ.orderBy("modifiedAt desc").limit(pageSize, offset)
357
+ : baseQ.limit(pageSize, offset);
358
+ const page = await db.run(pageQ);
359
+
360
+ if (!page || page.length === 0) break;
361
+
362
+ pageNum++;
363
+ totalSynced += page.length;
364
+
365
+ const docTitle =
366
+ pageNum === 1
367
+ ? `${entityName} entity data`
368
+ : `${entityName} entity data (page ${pageNum})`;
369
+
370
+ const content = [
371
+ `Entity: ${entityName}`,
372
+ `Total records in database: ${totalCount}`,
373
+ `Available fields: ${fields}`,
374
+ `Page: ${pageNum} (records ${offset + 1}–${offset + page.length} of ${totalCount})`,
375
+ "",
376
+ `Records:\n${JSON.stringify(page, null, 2)}`,
377
+ ].join("\n");
378
+
379
+ const q = _getQueue(cfg);
380
+ q.docs.set(docTitle, content);
381
+ if (q.timer) clearTimeout(q.timer);
382
+ q.timer = setTimeout(() => _flush(cfg.appId), DEBOUNCE_MS);
383
+
384
+ offset += page.length;
385
+ if (page.length < pageSize) break;
386
+ }
387
+
388
+ // If entity was empty, still push a summary doc so the AI knows it exists
389
+ if (pageNum === 0) {
390
+ const content = [
391
+ `Entity: ${entityName}`,
392
+ `Total records in database: ${totalCount}`,
393
+ `Available fields: ${fields}`,
394
+ "",
395
+ "No records found.",
396
+ ].join("\n");
397
+ const q = _getQueue(cfg);
398
+ q.docs.set(`${entityName} entity data`, content);
399
+ if (q.timer) clearTimeout(q.timer);
400
+ q.timer = setTimeout(() => _flush(cfg.appId), DEBOUNCE_MS);
401
+ }
402
+ } catch (e) {
403
+ // Log skipped entities so admins can diagnose issues (external services,
404
+ // views, or entities with unsupported field types are expected to fail here).
405
+ console.warn(
406
+ `[btp-copilot] syncAllEntities: skipping "${entityName}" — ${e.message}`,
407
+ );
408
+ }
409
+ }
410
+ }
411
+ return totalSynced;
412
+ }
413
+
414
+
415
+ /**
416
+ * Push an entity-level error as a context document to the chatbot backend.
417
+ *
418
+ * Call this whenever a CDS handler fails to read an entity (e.g. a broken
419
+ * remote service expand). The chatbot receives a document explaining that the
420
+ * entity is currently unavailable and why, so it can give the user a
421
+ * meaningful answer instead of an unexpected 500 / empty result.
422
+ *
423
+ * @param {object} cfg - { backendUrl, appId, token }
424
+ * @param {string} entityName - The entity that failed (e.g. "Farms")
425
+ * @param {string} reason - The error message from the caught exception
426
+ * @param {string} [hint] - Optional developer hint (e.g. "check FarmService expand config")
427
+ */
428
+ async function sendEntityError(cfg, entityName, reason, hint) {
429
+ if (!cfg.backendUrl || !cfg.appId) return;
430
+
431
+ const lines = [
432
+ `Entity: ${entityName}`,
433
+ `Status: UNAVAILABLE`,
434
+ `Reason: ${reason ?? "unknown error"}`,
435
+ `Recorded at: ${new Date().toISOString()}`,
436
+ ];
437
+ if (hint) lines.push(`Hint: ${hint}`);
438
+ lines.push(
439
+ "",
440
+ "The chatbot cannot fetch live data for this entity right now.",
441
+ "It will use any previously cached data where available.",
442
+ );
443
+
444
+ const title = `Entity error: ${entityName}`;
445
+ const content = lines.join("\n");
446
+
447
+ const q = _getQueue(cfg);
448
+ q.docs.set(title, content);
449
+ if (q.timer) clearTimeout(q.timer);
450
+ q.timer = setTimeout(() => _flush(cfg.appId), DEBOUNCE_MS);
451
+ }
452
+
453
+ /**
454
+ * Flush all pending debounce queues immediately.
455
+ * Call this on graceful shutdown to ensure no queued data is lost.
456
+ */
457
+ async function flushAll() {
458
+ const pending = [..._queues.keys()];
459
+ await Promise.all(
460
+ pending.map(async (appId) => {
461
+ const q = _queues.get(appId);
462
+ if (!q) return;
463
+ if (q.timer) { clearTimeout(q.timer); q.timer = null; }
464
+ await _flush(appId);
465
+ }),
466
+ );
467
+ }
468
+
469
+ module.exports = { sendEntityData, sendEntityError, watchEntity, syncAllEntities, flushAll };
@@ -19,8 +19,8 @@
19
19
  const fs = require("fs");
20
20
  const path = require("path");
21
21
 
22
- const MAX_LINES = 300; // max content lines per document
23
- const MAX_HANDLER_LINES = 150; // handlers are trimmed more aggressively
22
+ const MAX_LINES = 400;
23
+ const MAX_HANDLER_LINES = 250;
24
24
  const SKIP_DIRS = new Set([
25
25
  "node_modules",
26
26
  ".git",
@@ -121,8 +121,8 @@ function scan(projectRoot, opts = {}) {
121
121
  const xmlFiles = _findFiles(appDir, [".xml"], 4).filter(
122
122
  (f) => !f.includes("node_modules"),
123
123
  );
124
- for (const filePath of xmlFiles.slice(0, 10)) {
125
- // cap at 10 XML files
124
+ for (const filePath of xmlFiles.slice(0, 20)) {
125
+ // cap at 20 XML files
126
126
  const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
127
127
  const content = _readTrimmed(filePath, MAX_LINES);
128
128
  if (content) {
@@ -131,7 +131,6 @@ function scan(projectRoot, opts = {}) {
131
131
  }
132
132
  }
133
133
 
134
- // i18n / properties files (list of labels)
135
134
  const i18nFiles = _findFilesByName(appDir, "i18n.properties", 3).concat(
136
135
  _findFilesByName(appDir, "i18n_en.properties", 3),
137
136
  );
@@ -142,6 +141,19 @@ function scan(projectRoot, opts = {}) {
142
141
  docs.push({ title: `UI labels (i18n): ${rel}`, content });
143
142
  }
144
143
  }
144
+
145
+ if (includeHandlers) {
146
+ const controllerFiles = _findFiles(appDir, [".js", ".ts", ".mjs"], 4).filter(
147
+ (f) => !f.includes("node_modules") && !f.includes("test"),
148
+ );
149
+ for (const filePath of controllerFiles.slice(0, 15)) {
150
+ const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
151
+ const content = _readTrimmed(filePath, MAX_HANDLER_LINES);
152
+ if (content) {
153
+ docs.push({ title: `UI controller: ${rel}`, content });
154
+ }
155
+ }
156
+ }
145
157
  }
146
158
 
147
159
  return docs;