@genesislcap/ai-assistant 15.34.1 → 15.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Criteria composition for the Genesis read tools.
3
+ *
4
+ * @remarks
5
+ * **The model never writes a criteria expression, and this is the reason.** Criteria are parsed
6
+ * as Groovy on the server, and the platform composes an app's row-level restriction into the
7
+ * same string: `"($userCriteria) && ($criteriaTemplate)"`. A criteria string authored from model
8
+ * output could therefore `||` its way past the restriction that decides which rows a user is
9
+ * allowed to see. So the tool takes STRUCTURED filters — field, op, value — validated against
10
+ * the fields the platform itself reports as filterable, and this module builds the expression.
11
+ * Anything it cannot build is refused outright: never a partial expression, never a silent drop.
12
+ *
13
+ * Do not "simplify" this into a free-text criteria parameter.
14
+ *
15
+ * The grammar and escaping match the platform's other structured-filter caller, grid-pro's
16
+ * `filter.utils.ts`, so the bridge is not a second dialect of the same expression language.
17
+ */
18
+ /**
19
+ * Genesis field types, EXACTLY matched — copied from
20
+ * `foundation-ui/src/ai-criteria-search/validation/schema-validator.ts`, which puts the reason
21
+ * plainly: "exact match to avoid false positives (e.g. STATUS containing INT)". A prefix test
22
+ * also mistakes `NANO_TIMESTAMP` and `LOCALDATETIME` for text, which quotes a string against a
23
+ * timestamp column and silently returns nothing. The sets are module-private there, so they are
24
+ * copied rather than imported, and pinned by a test.
25
+ */
26
+ const DATE_TYPES = new Set(['DATE', 'LOCALDATE']);
27
+ const DATETIME_TYPES = new Set(['DATETIME', 'NANO_TIMESTAMP', 'LOCALDATETIME', 'TIMESTAMP']);
28
+ const NUMBER_TYPES = new Set([
29
+ 'INT',
30
+ 'INTEGER',
31
+ 'LONG',
32
+ 'SHORT',
33
+ 'DOUBLE',
34
+ 'BIGDECIMAL',
35
+ 'DECIMAL',
36
+ ]);
37
+ /** `STRING(256)` and `BIGDECIMAL(10,3)` carry their parameters; the set holds the bare name. */
38
+ const normaliseType = (fieldType) => String(fieldType !== null && fieldType !== void 0 ? fieldType : '')
39
+ .toUpperCase()
40
+ .trim()
41
+ .split('(')[0]
42
+ .trim();
43
+ const OPS_BY_KIND = {
44
+ string: ['equals', 'not_equals', 'contains', 'is_blank', 'is_not_blank'],
45
+ number: [
46
+ 'equals',
47
+ 'not_equals',
48
+ 'greater_than',
49
+ 'greater_or_equal',
50
+ 'less_than',
51
+ 'less_or_equal',
52
+ 'is_blank',
53
+ 'is_not_blank',
54
+ ],
55
+ boolean: ['equals', 'not_equals', 'is_blank', 'is_not_blank'],
56
+ date: [
57
+ 'equals',
58
+ 'greater_than',
59
+ 'greater_or_equal',
60
+ 'less_than',
61
+ 'less_or_equal',
62
+ 'is_blank',
63
+ 'is_not_blank',
64
+ ],
65
+ // No equality: a timestamp is almost never exactly equal to a value a model would write, and
66
+ // the platform's own datetime operator list offers none either (foundation-ui operator-map).
67
+ datetime: ['greater_or_equal', 'less_or_equal', 'is_blank', 'is_not_blank'],
68
+ };
69
+ /** @internal */
70
+ export function fieldKindOf(type) {
71
+ const normalised = normaliseType(type);
72
+ if (DATE_TYPES.has(normalised))
73
+ return 'date';
74
+ if (DATETIME_TYPES.has(normalised))
75
+ return 'datetime';
76
+ if (NUMBER_TYPES.has(normalised))
77
+ return 'number';
78
+ if (normalised === 'BOOLEAN')
79
+ return 'boolean';
80
+ return 'string';
81
+ }
82
+ /**
83
+ * Escapes a value for a criteria string literal: backslashes first, then quotes. An unescaped
84
+ * quote would close the literal early — which is the injection this module exists to prevent —
85
+ * and a trailing backslash would escape the closing quote instead.
86
+ */
87
+ const escape = (value) => String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
88
+ /**
89
+ * The inclusive range each part of a date or time bound may take. A part outside it reaches the
90
+ * server as a criteria it cannot evaluate, and the read path deliberately hides the compiler's
91
+ * answer from the model — so the explanation is produced here or nowhere.
92
+ */
93
+ const BOUND_LIMITS = {
94
+ month: { min: 1, max: 12 },
95
+ day: { min: 1, max: 31 },
96
+ hour: { min: 0, max: 23 },
97
+ minute: { min: 0, max: 59 },
98
+ second: { min: 0, max: 59 },
99
+ };
100
+ /** Whether a captured part is within its limits. An absent part is not out of range. */
101
+ const inRange = (text, part) => {
102
+ if (text === undefined)
103
+ return true;
104
+ const { min, max } = BOUND_LIMITS[part];
105
+ const value = Number(text);
106
+ return value >= min && value <= max;
107
+ };
108
+ /**
109
+ * `2026-09-21` to the `yyyyMMdd` form the platform's `Expr.dateIs*` helpers take. Anchored at
110
+ * both ends: trailing characters mean the model meant something this does not handle, and
111
+ * silently keeping the first eight digits would filter on a date nobody asked for.
112
+ *
113
+ * The month and day are range-checked because an impossible one reaches the server as a criteria
114
+ * it cannot evaluate, and the model is then told "that filter was not accepted by the app" with
115
+ * no way to tell which part of it was wrong — the read path deliberately hides the compiler's
116
+ * answer, so the explanation has to be produced here or nowhere.
117
+ */
118
+ function toPlatformDate(value) {
119
+ const match = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(String(value).trim());
120
+ if (!match || !inRange(match[2], 'month') || !inRange(match[3], 'day'))
121
+ return undefined;
122
+ return `${match[1]}${match[2]}${match[3]}`;
123
+ }
124
+ /**
125
+ * The `yyyyMMdd-HH:mm:ss` bound the `Expr.dateTimeIs*` helpers take, filled out to the END of
126
+ * whatever precision the model expressed for an upper bound and to its START for a lower one.
127
+ *
128
+ * @remarks
129
+ * **Defaulting both bounds to `00:00` loses a whole day, silently.** `CREATED_AT less_or_equal
130
+ * '2026-09-21'` composed `Expr.dateTimeIsLessEqual(CREATED_AT, '20260921-00:00')`, which is
131
+ * valid Groovy, compiles, and matches only a row stored exactly at midnight — so "on or before
132
+ * the 21st" returned almost nothing and reported it as an empty result. Reviewer catch on #2543.
133
+ *
134
+ * One rule covers every precision rather than special-casing the bare date: an inclusive upper
135
+ * bound runs to the end of the unit the model named (a day, a minute, a second) and a lower
136
+ * bound to its start. A supplied time is never widened — `'2026-09-21T14:30'` as an upper bound
137
+ * means through 14:30:59, not through the end of the day.
138
+ *
139
+ * **Seconds, not milliseconds.** `foundation-ui`'s own date filter emits `'20211013-00:00:00'`
140
+ * into these helpers (`filter.ts`, `toISOString().slice(0, 19)`), so seconds are the precision
141
+ * proven to work; nothing in the monorepo sends millis, and a bound shape the parser does not
142
+ * take fails a real user's read rather than a test. The residual gap is therefore under one
143
+ * second at the top of a range, and closing it needs one live capture of a `.SSS` bound.
144
+ *
145
+ * Milliseconds on the way IN are refused rather than truncated: dropping them narrows an upper
146
+ * bound and widens a lower one, which is the same silent-day bug one unit down.
147
+ */
148
+ function toPlatformDateTime(value, bound) {
149
+ const match = /^(\d{4})-?(\d{2})-?(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(String(value).trim());
150
+ if (!match)
151
+ return undefined;
152
+ const [, year, month, day, hour, minute, second] = match;
153
+ if (!inRange(month, 'month') || !inRange(day, 'day'))
154
+ return undefined;
155
+ if (!inRange(hour, 'hour') || !inRange(minute, 'minute') || !inRange(second, 'second')) {
156
+ return undefined;
157
+ }
158
+ const fill = bound === 'end';
159
+ return (`${year}${month}${day}-${hour !== null && hour !== void 0 ? hour : (fill ? '23' : '00')}` +
160
+ `:${minute !== null && minute !== void 0 ? minute : (fill ? '59' : '00')}:${second !== null && second !== void 0 ? second : (fill ? '59' : '00')}`);
161
+ }
162
+ /**
163
+ * Each fragment builder answers only for the ops it knows, and `undefined` otherwise.
164
+ *
165
+ * @remarks
166
+ * These used to end in a `default:` that returned one operator — `containsIgnoreCase` for a
167
+ * string, `dateIsLessEqual` for a date — so an op added to {@link OPS_BY_KIND} without a fragment
168
+ * would have been composed silently as that one, filtering on something nobody asked for. The
169
+ * allow-list makes it unreachable today; this makes it impossible tomorrow, which is the same
170
+ * argument the operator Map is built on. Exported so that property can be tested at all — the
171
+ * allow-list is what stops `composeCriteria` reaching it.
172
+ *
173
+ * @internal
174
+ */
175
+ export function stringFragment(field, op, value) {
176
+ switch (op) {
177
+ case 'equals':
178
+ return `${field} == '${escape(value)}'`;
179
+ case 'not_equals':
180
+ return `${field} != '${escape(value)}'`;
181
+ case 'contains':
182
+ return `Expr.containsIgnoreCase(${field}, '${escape(value)}')`;
183
+ default:
184
+ return undefined;
185
+ }
186
+ }
187
+ // A Map, not an object: this is the one position where model input chooses an OPERATOR, and a
188
+ // bare lookup would find `constructor` and friends on Object.prototype. The op allow-list above
189
+ // already blocks that; this makes it impossible rather than merely unreachable.
190
+ const NUMBER_OPERATORS = new Map([
191
+ ['equals', '=='],
192
+ ['not_equals', '!='],
193
+ ['greater_than', '>'],
194
+ ['greater_or_equal', '>='],
195
+ ['less_than', '<'],
196
+ ['less_or_equal', '<='],
197
+ ]);
198
+ export function dateFragment(field, op, date) {
199
+ switch (op) {
200
+ case 'equals':
201
+ return `Expr.dateIsEqual(${field}, '${date}')`;
202
+ case 'greater_than':
203
+ return `Expr.dateIsAfter(${field}, '${date}')`;
204
+ case 'less_than':
205
+ return `Expr.dateIsBefore(${field}, '${date}')`;
206
+ case 'greater_or_equal':
207
+ return `Expr.dateIsGreaterEqual(${field}, '${date}')`;
208
+ case 'less_or_equal':
209
+ return `Expr.dateIsLessEqual(${field}, '${date}')`;
210
+ default:
211
+ return undefined;
212
+ }
213
+ }
214
+ /**
215
+ * Timestamp columns get the dateTime helpers, which take a `yyyyMMdd-HH:mm:ss` bound.
216
+ *
217
+ * @remarks
218
+ * There is no strictly-before helper to reach for: `dateTimeIsBefore` maps to
219
+ * `dateTimeIsLessEqual` (foundation-ui's operator-map), and a negated
220
+ * `!Expr.dateTimeIsGreaterEqual(...)` would pull rows with a NULL timestamp INTO the match set,
221
+ * because the comparison is false for null. So the bound itself carries the inclusivity — see
222
+ * {@link toPlatformDateTime}.
223
+ */
224
+ export function dateTimeFragment(field, op, bound) {
225
+ if (op === 'greater_or_equal')
226
+ return `Expr.dateTimeIsGreaterEqual(${field}, '${bound}')`;
227
+ return op === 'less_or_equal' ? `Expr.dateTimeIsLessEqual(${field}, '${bound}')` : undefined;
228
+ }
229
+ /** A value being absent is not the same as being empty, except for strings, where both count. */
230
+ function blankFragment(field, kind, negated) {
231
+ if (kind !== 'string')
232
+ return `${field} ${negated ? '!=' : '=='} null`;
233
+ return negated ? `(${field} != '' && ${field} != null)` : `(${field} == '' || ${field} == null)`;
234
+ }
235
+ /**
236
+ * What one filter value, and one whole expression, may cost.
237
+ *
238
+ * @remarks
239
+ * Bounding the NUMBER of filters is not the bound that matters: one filter with a 200,000
240
+ * character value composes a 200KB expression, and the number path accepts it by design — the
241
+ * digits-only pattern carries them through verbatim precisely so a BIGDECIMAL beyond double
242
+ * precision is not rounded, and `'9'.repeat(200000)` is all digits.
243
+ *
244
+ * The expensive part is not the server's. A rejection reason echoes the offending value, so a
245
+ * 200KB value produced a 200KB reason, which lands in the tool result, is stringified into the
246
+ * transcript, and is billed again as input on every later turn of the conversation — from the
247
+ * path that is supposed to be the safe one. Rows are stamped untrusted because other users
248
+ * wrote them, so a row carrying text that steers the model into copying a long string is a real
249
+ * route in. Cost and stability rather than privilege: the grammar holds either way.
250
+ *
251
+ * So the length is bounded, and checked BEFORE any rejection that interpolates the value.
252
+ * Reviewer catch on #2543, with a different quantity: his `maxItems` bounds the count, which
253
+ * permits ten times the expression he demonstrated.
254
+ */
255
+ const MAX_VALUE_LENGTH = 512;
256
+ const MAX_CRITERIA_LENGTH = 4096;
257
+ /** Everything below SPACE, and DEL: the characters an expression cannot carry. */
258
+ const FIRST_PRINTABLE_CODE = 0x20;
259
+ const DELETE_CODE = 0x7f;
260
+ /** A line break or other control character — checked by code point, not by a control regex. */
261
+ function hasControlCharacter(text) {
262
+ for (const character of text) {
263
+ const code = character.codePointAt(0);
264
+ if (code !== undefined && (code < FIRST_PRINTABLE_CODE || code === DELETE_CODE))
265
+ return true;
266
+ }
267
+ return false;
268
+ }
269
+ /**
270
+ * A fragment, or a refusal if the builder had no expression for that op — which can only happen
271
+ * if an op joins {@link OPS_BY_KIND} without one, and is a bug here rather than a bad filter.
272
+ */
273
+ function composed(field, op, criteria) {
274
+ return criteria
275
+ ? { status: 'ok', criteria }
276
+ : { status: 'rejected', reason: `'${op}' cannot be applied to ${field.name}.` };
277
+ }
278
+ function fragmentFor(filter, field) {
279
+ var _a;
280
+ const kind = fieldKindOf(field.type);
281
+ const allowed = OPS_BY_KIND[kind];
282
+ if (!allowed.includes(filter.op)) {
283
+ return {
284
+ status: 'rejected',
285
+ reason: `${field.name} is a ${kind} field, so '${filter.op}' does not apply to it. Use one of: ${allowed.join(', ')}.`,
286
+ };
287
+ }
288
+ if (filter.op === 'is_blank' || filter.op === 'is_not_blank') {
289
+ return {
290
+ status: 'ok',
291
+ criteria: blankFragment(field.name, kind, filter.op === 'is_not_blank'),
292
+ };
293
+ }
294
+ if (filter.value === undefined || filter.value === null || filter.value === '') {
295
+ return { status: 'rejected', reason: `the filter on ${field.name} has no value.` };
296
+ }
297
+ // Before the checks below, every one of which interpolates the value into its reason: a guard
298
+ // placed after them leaves the amplification it exists to prevent wide open. The reason here
299
+ // names the LENGTH and never the value, for the same reason.
300
+ const length = String(filter.value).length;
301
+ if (length > MAX_VALUE_LENGTH) {
302
+ return {
303
+ status: 'rejected',
304
+ reason: `the value for ${field.name} is ${length} characters. A filter value may be up to ${MAX_VALUE_LENGTH}.`,
305
+ };
306
+ }
307
+ if ((_a = field.validValues) === null || _a === void 0 ? void 0 : _a.length) {
308
+ const value = String(filter.value);
309
+ if (!field.validValues.includes(value)) {
310
+ return {
311
+ status: 'rejected',
312
+ reason: `${value} is not a value ${field.name} can hold. It is one of: ${field.validValues.join(', ')}.`,
313
+ };
314
+ }
315
+ }
316
+ // A newline or other control character escapes cleanly but leaves an expression the server's
317
+ // Groovy parser rejects, so the read fails there instead of here, with a compiler error the
318
+ // model cannot act on. Refused with a reason it can.
319
+ if (typeof filter.value === 'string' && hasControlCharacter(filter.value)) {
320
+ return {
321
+ status: 'rejected',
322
+ reason: `the value for ${field.name} contains a line break or control character.`,
323
+ };
324
+ }
325
+ switch (kind) {
326
+ case 'number': {
327
+ // The digits are carried through verbatim rather than via Number(): a LONG or BIGDECIMAL
328
+ // beyond double precision would otherwise be rounded INTO the expression, and the model
329
+ // would be told about rows that were never matched. The pattern is the validation — only
330
+ // digits, one optional sign and one optional point reach the expression.
331
+ const text = String(filter.value).trim();
332
+ if (!/^[-+]?\d+(?:\.\d+)?$/.test(text)) {
333
+ return {
334
+ status: 'rejected',
335
+ reason: `${field.name} is a number, and '${String(filter.value)}' is not one.`,
336
+ };
337
+ }
338
+ return { status: 'ok', criteria: `${field.name} ${NUMBER_OPERATORS.get(filter.op)} ${text}` };
339
+ }
340
+ case 'boolean': {
341
+ const text = String(filter.value).toLowerCase();
342
+ if (text !== 'true' && text !== 'false') {
343
+ return {
344
+ status: 'rejected',
345
+ reason: `${field.name} is true or false, and '${String(filter.value)}' is neither.`,
346
+ };
347
+ }
348
+ return {
349
+ status: 'ok',
350
+ criteria: `${field.name} ${filter.op === 'equals' ? '==' : '!='} ${text}`,
351
+ };
352
+ }
353
+ case 'datetime': {
354
+ // An inclusive upper bound runs to the end of the unit the model named; a lower bound to
355
+ // its start. `less_or_equal` is the only upper-bound op a timestamp takes.
356
+ const bound = toPlatformDateTime(filter.value, filter.op === 'less_or_equal' ? 'end' : 'start');
357
+ if (!bound) {
358
+ return {
359
+ status: 'rejected',
360
+ reason: `${field.name} is a timestamp, and '${String(filter.value)}' is not one. Use YYYY-MM-DD, YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS.`,
361
+ };
362
+ }
363
+ return composed(field, filter.op, dateTimeFragment(field.name, filter.op, bound));
364
+ }
365
+ case 'date': {
366
+ const date = toPlatformDate(filter.value);
367
+ if (!date) {
368
+ return {
369
+ status: 'rejected',
370
+ reason: `${field.name} is a date, and '${String(filter.value)}' is not one. Use YYYY-MM-DD.`,
371
+ };
372
+ }
373
+ return composed(field, filter.op, dateFragment(field.name, filter.op, date));
374
+ }
375
+ default:
376
+ return composed(field, filter.op, stringFragment(field.name, filter.op, filter.value));
377
+ }
378
+ }
379
+ /**
380
+ * Builds one `CRITERIA_MATCH` expression from structured filters, or refuses.
381
+ *
382
+ * @remarks
383
+ * Fragments are parenthesised before they are joined with `&&`, because `&&` binds tighter than
384
+ * `||`: an unparenthesised `a == '' || a == null` fragment would otherwise let rows through that
385
+ * the following filters exclude.
386
+ *
387
+ * @internal
388
+ */
389
+ export function composeCriteria(filters, fields) {
390
+ if (filters === undefined || filters === null)
391
+ return { status: 'ok' };
392
+ if (!Array.isArray(filters)) {
393
+ return { status: 'rejected', reason: 'filters must be a list of {field, op, value} entries.' };
394
+ }
395
+ const fragments = [];
396
+ for (const entry of filters) {
397
+ const filter = entry;
398
+ if (!filter || typeof filter.field !== 'string') {
399
+ return { status: 'rejected', reason: 'every filter needs a field name.' };
400
+ }
401
+ const field = fields.get(filter.field);
402
+ if (!field) {
403
+ const known = [...fields.keys()];
404
+ return {
405
+ status: 'rejected',
406
+ reason: known.length
407
+ ? `${filter.field} cannot be filtered on. The fields that can are: ${known.join(', ')}.`
408
+ : `${filter.field} cannot be filtered on: this resource reports no filterable fields.`,
409
+ };
410
+ }
411
+ const fragment = fragmentFor(filter, field);
412
+ if (fragment.status === 'rejected')
413
+ return fragment;
414
+ fragments.push(`(${fragment.criteria})`);
415
+ }
416
+ const criteria = fragments.length ? fragments.join(' && ') : undefined;
417
+ // The whole expression, not just each value: a hundred filters inside the per-value limit
418
+ // still compose something no read should send.
419
+ if (criteria && criteria.length > MAX_CRITERIA_LENGTH) {
420
+ return {
421
+ status: 'rejected',
422
+ reason: `those ${fragments.length} filters compose an expression of ${criteria.length} characters, and ${MAX_CRITERIA_LENGTH} is the most a read may send. Use fewer conditions.`,
423
+ };
424
+ }
425
+ return { status: 'ok', criteria };
426
+ }
@@ -0,0 +1,124 @@
1
+ import { __awaiter } from "tslib";
2
+ import { logger } from '../utils/logger';
3
+ import { withTimeout } from '../utils/with-timeout';
4
+ /**
5
+ * A metadata request that never settles would pin the tool list for the page: Connect drops
6
+ * pending handlers on reconnect (`cleanMessages`) without rejecting them, so the await would
7
+ * hang forever and the assistant would silently stop listing tools. A timeout turns that into
8
+ * an ordinary transient failure, retried on the next turn.
9
+ */
10
+ const METADATA_TIMEOUT_MS = 20000;
11
+ /**
12
+ * An enum's values. A live 8.15 request server sends them as an array
13
+ * (`VALID_VALUES: ["SELL", "BUY"]`), while grid-pro's enum filter reads a pipe-separated string,
14
+ * so both shapes are in use and both are accepted. Getting this wrong is silent: the model is
15
+ * simply never told what the field can hold.
16
+ */
17
+ const validValuesOf = (detail) => {
18
+ const values = detail.VALID_VALUES;
19
+ if (Array.isArray(values)) {
20
+ const strings = values.filter((v) => typeof v === 'string' || typeof v === 'number');
21
+ return strings.length ? strings.map(String) : undefined;
22
+ }
23
+ return typeof values === 'string' && values.length ? values.split('|') : undefined;
24
+ };
25
+ /**
26
+ * The metadata name of a resource: `REQ_TRADE` is read as `REQ_TRADE`, but its metadata and
27
+ * schema are published under `TRADE`. Asking for the prefixed name answers
28
+ * "Resource REQ_TRADE is not mapped to a service" (404), which would leave every tool
29
+ * unfilterable — verified against a live 8.15 router.
30
+ *
31
+ * @internal
32
+ */
33
+ export const metadataNameOf = (resourceName) => resourceName.replace(/^REQ_/, '');
34
+ /** @internal */
35
+ export function filterFieldsFrom(metadata) {
36
+ var _a, _b, _c;
37
+ const details = new Map();
38
+ for (const detail of [...((_a = metadata.REPLY_FIELD) !== null && _a !== void 0 ? _a : []), ...((_b = metadata.FIELD) !== null && _b !== void 0 ? _b : [])]) {
39
+ if ((detail === null || detail === void 0 ? void 0 : detail.NAME) && !details.has(detail.NAME))
40
+ details.set(detail.NAME, detail);
41
+ }
42
+ // CRITERIA_FIELDS when the resource publishes one. A request server usually does not — it
43
+ // sends REPLY_FIELD only — and criteria there are evaluated against the row, so every reply
44
+ // field can be filtered on. (Live check: a criteria on a reply field that is not a request
45
+ // field filtered correctly, and an undeclared name came back as
46
+ // "Variable NOPE is not allowed in criteria expression".)
47
+ const criteriaFields = Array.isArray(metadata.CRITERIA_FIELDS) && metadata.CRITERIA_FIELDS.length
48
+ ? metadata.CRITERIA_FIELDS
49
+ : [...details.keys()];
50
+ const fields = new Map();
51
+ const undescribed = [];
52
+ for (const name of criteriaFields) {
53
+ const detail = details.get(name);
54
+ // A criteria field the reply describes nowhere is LEFT OUT rather than guessed at. Calling
55
+ // it STRING was the guess, and it is wrong in both directions, silently: `AMOUNT == '100'`
56
+ // against a numeric column quotes a string at a number and matches nothing (this module's
57
+ // own header says the same about a timestamp), while `greater_than` on it is refused with a
58
+ // confident, wrong explanation — "AMOUNT is a string field" — that the model will act on by
59
+ // rewording the query for ever.
60
+ //
61
+ // The cost is real: a criteria-only request server publishes CRITERIA_FIELDS for every
62
+ // column of the table, so a field its reply does not carry is ordinary rather than exotic,
63
+ // and dropping it means the model reads more rows and narrows them itself. That is a slower
64
+ // right answer, and the alternative is a fast wrong one with nothing to notice it by.
65
+ if (!detail) {
66
+ undescribed.push(name);
67
+ continue;
68
+ }
69
+ fields.set(name, { name, type: (_c = detail.TYPE) !== null && _c !== void 0 ? _c : 'STRING', validValues: validValuesOf(detail) });
70
+ }
71
+ return { fields, undescribed };
72
+ }
73
+ /**
74
+ * Fetches a resource's filterable fields. Never throws.
75
+ *
76
+ * @remarks
77
+ * `transient` says whether asking again can help: a request that failed while the app was
78
+ * disconnected can, but a reply that came back unusable cannot — Connect caches any non-empty
79
+ * metadata reply for the session, so the same answer would come back without reaching the server.
80
+ *
81
+ * @internal
82
+ */
83
+ export function loadFilterFields(connect_1, resourceName_1) {
84
+ return __awaiter(this, arguments, void 0, function* (connect, resourceName, timeoutMs = METADATA_TIMEOUT_MS) {
85
+ let metadata;
86
+ try {
87
+ metadata = yield withTimeout(connect.getMetadata(metadataNameOf(resourceName)), timeoutMs);
88
+ }
89
+ catch (error) {
90
+ // Classified by OUTCOME, not by the connection state before the await: a request that was
91
+ // sent while connected and failed mid-flight is exactly the case worth retrying.
92
+ return {
93
+ status: 'failed',
94
+ reason: `the metadata request failed (${String(error)})`,
95
+ transient: true,
96
+ };
97
+ }
98
+ if (!metadata || typeof metadata !== 'object' || metadata.ERROR) {
99
+ // A reply came back and was unusable. Connect caches any non-empty metadata reply for the
100
+ // session, errors included, so asking again returns the same answer without reaching the
101
+ // server: settle rather than retry.
102
+ return {
103
+ status: 'failed',
104
+ reason: (metadata === null || metadata === void 0 ? void 0 : metadata.ERROR)
105
+ ? `the app refused its metadata (${metadata.ERROR})`
106
+ : 'the app returned no usable metadata for it',
107
+ transient: !metadata,
108
+ };
109
+ }
110
+ const { fields, undescribed } = filterFieldsFrom(metadata);
111
+ if (undescribed.length) {
112
+ logger.warn(`Genesis assistant: ${resourceName} lists ${undescribed.join(', ')} as criteria fields but ` +
113
+ 'describes neither type, so they are not offered as filters. Publish them in the ' +
114
+ "resource's reply fields to make them filterable.");
115
+ }
116
+ // Whether the reply DESCRIBED any fields at all, so the caller can tell "this resource has
117
+ // nothing to filter on" from "we asked in the wrong place, or the platform sent nothing".
118
+ // Presence of the keys, not their length: a resource that answers with empty lists HAS
119
+ // described itself — it has nothing to filter on — while a reply that mentions none of them
120
+ // has described nothing, which is a different thing to tell the host.
121
+ const published = ['CRITERIA_FIELDS', 'REPLY_FIELD', 'FIELD'].some((key) => key in metadata);
122
+ return { status: 'ok', fields, published };
123
+ });
124
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The Genesis bridge for the AI assistant (`@genesislcap/ai-assistant/genesis`).
3
+ *
4
+ * Its own entry point, deliberately not re-exported from the package root: it depends on
5
+ * `@genesislcap/foundation-comms`, and this package declares no `sideEffects`, so anything
6
+ * exported from the root lands in every consumer's bundle — including hosts that are not
7
+ * Genesis apps. foundation-comms is an optional peer dependency, so the bridge always resolves
8
+ * the app's own `Connect` rather than a second copy with a different DI token.
9
+ *
10
+ * @packageDocumentation
11
+ * @beta
12
+ */
13
+ export { GENESIS_AGENT_NAME, registerGenesisAssistant } from './register-genesis-assistant';
14
+ export { createGenesisResourceTools, GENESIS_DATA_SOURCE } from './resource-tools';
15
+ export { GENESIS_EVENT_OPS } from './types';