@ixo/oracle-runtime-workers 0.30.0 → 0.31.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.
Files changed (44) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/runtime-context.ts +3 -0
  15. package/src/delivery/plan.ts +3 -1
  16. package/src/do/contracts.ts +3 -0
  17. package/src/do/turn-attachments.ts +103 -0
  18. package/src/do/user-oracle-do.ts +142 -42
  19. package/src/hub-actions/client.ts +87 -7
  20. package/src/hub-actions/hub-text.ts +82 -0
  21. package/src/hub-actions/request.ts +7 -4
  22. package/src/hub-actions/store.ts +5 -1
  23. package/src/hub-actions/transfer.ts +23 -6
  24. package/src/index.ts +1 -0
  25. package/src/plugin-api/index.ts +1 -0
  26. package/src/plugin-api/types.ts +43 -2
  27. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  28. package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
  29. package/src/plugins/ixo-chain/blocksync.ts +6 -2
  30. package/src/plugins/ixo-chain/http.ts +16 -8
  31. package/src/plugins/ixo-chain/rest.ts +7 -1
  32. package/src/plugins/yoma/catalog.ts +780 -0
  33. package/src/plugins/yoma/clef.ts +98 -0
  34. package/src/plugins/yoma/drafts.ts +339 -0
  35. package/src/plugins/yoma/engine.ts +926 -0
  36. package/src/plugins/yoma/index.ts +13 -0
  37. package/src/plugins/yoma/judge.ts +358 -0
  38. package/src/plugins/yoma/permission.ts +62 -0
  39. package/src/plugins/yoma/reads.ts +195 -0
  40. package/src/plugins/yoma/submission.ts +534 -0
  41. package/src/plugins/yoma/text.ts +16 -0
  42. package/src/plugins/yoma/work.ts +190 -0
  43. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  44. package/src/sqlite/serialization.ts +2 -0
@@ -0,0 +1,534 @@
1
+ import {
2
+ submissionDisplayValue,
3
+ submissionSummaryIssue,
4
+ type BidSubmitOperation,
5
+ type ClaimSubmitOperation,
6
+ type SubmissionSummaryRow,
7
+ } from '@ixo/action-protocol';
8
+ import {
9
+ QuestionPanelDynamicModel,
10
+ type Model,
11
+ type Question,
12
+ } from 'survey-core';
13
+ import type { ChainCall } from '../ixo-chain/http';
14
+ import type { Draft, Evidence } from './drafts';
15
+ import {
16
+ clearHiddenAnswers,
17
+ questionAt,
18
+ submittableData,
19
+ visibleQuestions,
20
+ type FormQuestion,
21
+ } from './engine';
22
+ import { clip } from './text';
23
+
24
+ /**
25
+ * Turning a finished draft into the Auth Hub card the person approves
26
+ * (`@ixo/action-protocol` 0.7.0, Hub level 5): `ixo.bid.submit.v1` for an
27
+ * application, `ixo.claim.submit.v1` for a claim. The card carries what the
28
+ * person sees (title, and exactly one row per answer of `data`, which the
29
+ * Hub checks) and what the Hub files as them:
30
+ * the answers as JSON, and for a claim each file's gateway link, size and
31
+ * SHA-256. File questions are left out of the answers; the Hub uploads the
32
+ * files to the claims lane when the person confirms and puts the references
33
+ * in, then signs the answers, saves the claim and submits it on chain.
34
+ */
35
+
36
+ /** What a file question holds in the draft until submit, so the form's rules count it as answered. */
37
+ export const PENDING_FILE_PREFIX = 'pending:';
38
+
39
+ export function pendingFileValue(
40
+ evidence: Pick<Evidence, 'filename' | 'mimetype' | 'sha256'>,
41
+ ) {
42
+ return {
43
+ name: evidence.filename,
44
+ type: evidence.mimetype,
45
+ content: `${PENDING_FILE_PREFIX}${evidence.sha256}`,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * The card's summary limits (`@ixo/action-protocol` 0.7.0): 0–100 rows, one
51
+ * per answer of `data`; a label of 1–200 characters; a value of at most 2,000.
52
+ */
53
+ export const MAX_CARD_ROWS = 100;
54
+ export const MAX_CARD_VALUE = 2_000;
55
+ const MAX_LABEL = 200;
56
+
57
+ /** A string field of a stored answer object, if it has one. */
58
+ function field(value: unknown, key: string): string | undefined {
59
+ if (typeof value !== 'object' || value === null || !(key in value))
60
+ return undefined;
61
+ const found: unknown = Reflect.get(value, key);
62
+ return typeof found === 'string' ? found : undefined;
63
+ }
64
+
65
+ /** A stored choice value as its option's text; a value the list does not hold (an own answer) as itself. */
66
+ function choiceText(view: FormQuestion, value: unknown): string {
67
+ if (view.other && value === view.other.value) return view.other.text;
68
+ return (
69
+ view.options?.find((option) => option.value === value)?.text ??
70
+ String(value)
71
+ );
72
+ }
73
+
74
+ /** What a choice answer means in the form's words ("South Africa" for `ZA`). */
75
+ function choiceMeaning(view: FormQuestion, value: unknown): string {
76
+ return (Array.isArray(value) ? value : [value])
77
+ .map((item) => choiceText(view, item))
78
+ .join(', ');
79
+ }
80
+
81
+ /** A file answer as the person reads it back: icon and name, never the link. */
82
+ function fileText(value: unknown): string {
83
+ return (Array.isArray(value) ? value : [value])
84
+ .map((file) => {
85
+ const type = field(file, 'type') ?? '';
86
+ const icon = type.startsWith('image/')
87
+ ? '📷'
88
+ : type.startsWith('video/')
89
+ ? '🎬'
90
+ : '📄';
91
+ return `${icon} ${field(file, 'name') ?? 'file'}`;
92
+ })
93
+ .join(', ');
94
+ }
95
+
96
+ /** One row of the card: an answer's path in `data`, its label, and its display form. */
97
+ export type CardRow = SubmissionSummaryRow;
98
+
99
+ /** A read-back row: the card's row, and what a choice means when its stored value reads differently. */
100
+ export interface ReviewRow extends CardRow {
101
+ means?: string;
102
+ }
103
+
104
+ const plainObject = (value: unknown): value is Record<string, unknown> =>
105
+ typeof value === 'object' && value !== null && !Array.isArray(value);
106
+
107
+ /** Why the answers cannot go on one card. */
108
+ export type RowProblem = 'rows' | 'value' | 'key' | 'shape';
109
+
110
+ /**
111
+ * The answers of `data` as the protocol reads them
112
+ * (`submissionSummaryIssue`): objects walked key by key, arrays of objects
113
+ * index by index, every other value an answer at its dot path, shown as
114
+ * `submissionDisplayValue` gives it.
115
+ */
116
+ function answersOf(
117
+ data: Record<string, unknown>,
118
+ ):
119
+ | { ok: true; answers: Array<{ path: string[]; value: string }> }
120
+ | { ok: false; problem: RowProblem } {
121
+ const answers: Array<{ path: string[]; value: string }> = [];
122
+ const walk = (
123
+ node: Record<string, unknown>,
124
+ prefix: string[],
125
+ ): RowProblem | undefined => {
126
+ for (const [key, value] of Object.entries(node)) {
127
+ if (key === '' || key.includes('.')) return 'key';
128
+ const path = [...prefix, key];
129
+ if (plainObject(value)) {
130
+ const problem = walk(value, path);
131
+ if (problem) return problem;
132
+ } else if (
133
+ Array.isArray(value) &&
134
+ value.length &&
135
+ value.every(plainObject)
136
+ ) {
137
+ for (const [index, item] of value.entries()) {
138
+ const problem = walk(item, [...path, String(index)]);
139
+ if (problem) return problem;
140
+ }
141
+ } else {
142
+ const shown = submissionDisplayValue(value);
143
+ if (shown === undefined) return 'shape';
144
+ answers.push({ path, value: shown });
145
+ }
146
+ }
147
+ return undefined;
148
+ };
149
+ const problem = walk(data, []);
150
+ return problem ? { ok: false, problem } : { ok: true, answers };
151
+ }
152
+
153
+ /** Readable names for the parts of Jambo's map cell. */
154
+ const CELL_PARTS: Record<string, string> = {
155
+ geoId: 'grid cell',
156
+ gridPrecision: 'grid size',
157
+ placeName: 'place',
158
+ };
159
+
160
+ const titleOf = (question: Question) =>
161
+ question.processedTitle || question.title || question.name;
162
+
163
+ interface Container {
164
+ getQuestionByValueName(valueName: string): Question | null | undefined;
165
+ }
166
+
167
+ /**
168
+ * A row's label from the form: the question's title; a repeating section's
169
+ * item as the section's title and item number, then the question inside
170
+ * ("Participants 2 · Email"); a part of an object answer after its question
171
+ * ("Where · latitude", "Where 2 · place"); an own "Other" answer as
172
+ * "(own answer)".
173
+ */
174
+ function labelFor(
175
+ container: Container,
176
+ parts: string[],
177
+ suffix: string,
178
+ ): string {
179
+ const [head, ...rest] = parts;
180
+ if (head === undefined) return '';
181
+ const own = head.endsWith(suffix) ? head.slice(0, -suffix.length) : undefined;
182
+ const question =
183
+ container.getQuestionByValueName(head) ??
184
+ (own ? container.getQuestionByValueName(own) : undefined) ??
185
+ undefined;
186
+ const base = question
187
+ ? `${titleOf(question)}${own && question.getValueName() === own ? ' (own answer)' : ''}`
188
+ : head;
189
+ if (
190
+ question instanceof QuestionPanelDynamicModel &&
191
+ rest.length > 1 &&
192
+ /^\d+$/.test(rest[0]!)
193
+ ) {
194
+ const index = Number(rest[0]);
195
+ const panel = question.panels[index];
196
+ const inner = panel
197
+ ? labelFor(panel, rest.slice(1), suffix)
198
+ : rest.slice(1).join(' · ');
199
+ return `${base} ${index + 1} · ${inner}`;
200
+ }
201
+ const segments = [base];
202
+ for (const part of rest) {
203
+ if (/^\d+$/.test(part))
204
+ segments[segments.length - 1] += ` ${Number(part) + 1}`;
205
+ else segments.push(CELL_PARTS[part] ?? part);
206
+ }
207
+ return segments.join(' · ');
208
+ }
209
+
210
+ /** `shops.0.name` (a data path) as the question path `shops[0].name`, when it names one. */
211
+ function questionPath(parts: string[]): string | undefined {
212
+ if (parts.length === 1) return parts[0];
213
+ if (parts.length === 3 && /^\d+$/.test(parts[1]!))
214
+ return `${parts[0]}[${parts[1]}].${parts[2]}`;
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Exactly one row per answer of `data`, in its order: the row the card
220
+ * shows (`field` its path, `value` its display form, the label from the
221
+ * form), and for a choice what it means in the form's words. `problem`
222
+ * when they cannot go on one card: more than {@link MAX_CARD_ROWS}, a value
223
+ * past {@link MAX_CARD_VALUE}, a key the protocol refuses, a value with no
224
+ * display form.
225
+ */
226
+ export function rowsOf(
227
+ model: Model,
228
+ data: Record<string, unknown>,
229
+ ): { rows: ReviewRow[]; problem?: RowProblem } {
230
+ const read = answersOf(data);
231
+ if (!read.ok) return { rows: [], problem: read.problem };
232
+ const views = new Map(
233
+ visibleQuestions(model).map((view) => [view.path, view]),
234
+ );
235
+ const container: Container = {
236
+ getQuestionByValueName: (name) => model.getQuestionByValueName(name),
237
+ };
238
+ const rows = read.answers.map(({ path, value }): ReviewRow => {
239
+ const view = views.get(questionPath(path) ?? '');
240
+ const stored: unknown = view
241
+ ? questionAt(model, view.path)?.value
242
+ : undefined;
243
+ const means =
244
+ view &&
245
+ (view.input === 'pick-one' ||
246
+ view.input === 'pick-many' ||
247
+ view.input === 'yes-no')
248
+ ? choiceMeaning(view, stored)
249
+ : undefined;
250
+ return {
251
+ field: path.join('.'),
252
+ label:
253
+ clip(labelFor(container, path, model.commentSuffix), MAX_LABEL) ||
254
+ clip(path.join('.'), MAX_LABEL),
255
+ value,
256
+ ...(means !== undefined && means !== value ? { means } : {}),
257
+ };
258
+ });
259
+ const problem =
260
+ rows.length > MAX_CARD_ROWS
261
+ ? 'rows'
262
+ : rows.some((row) => row.value.length > MAX_CARD_VALUE)
263
+ ? 'value'
264
+ : undefined;
265
+ return { rows, ...(problem ? { problem } : {}) };
266
+ }
267
+
268
+ /** The file answers still on the form, as the person reads them back (the card shows the files themselves). */
269
+ function fileRows(model: Model): Array<{ label: string; value: string }> {
270
+ return visibleQuestions(model).flatMap((view) => {
271
+ if (view.input !== 'file') return [];
272
+ const question = questionAt(model, view.path);
273
+ if (!question || question.isEmpty()) return [];
274
+ return [
275
+ {
276
+ label: view.item
277
+ ? `${view.item.title} ${view.item.number} · ${view.title}`
278
+ : view.title,
279
+ value: fileText(question.value),
280
+ },
281
+ ];
282
+ });
283
+ }
284
+
285
+ /**
286
+ * What a finished draft sends, from its open model, in one pass: hidden
287
+ * answers cleared, the files listed, then the answers the Hub files (file
288
+ * questions left out) and their rows. The read-back, the card and the data
289
+ * all come from here. Changes the model.
290
+ */
291
+ export function readBack(model: Model) {
292
+ clearHiddenAnswers(model);
293
+ const files = fileRows(model);
294
+ const data = submittableData(model);
295
+ return { files, data, ...rowsOf(model, data) };
296
+ }
297
+
298
+ /** Rows for one review answer: pages of at most this many JSON characters each. */
299
+ export const REVIEW_PAGE_CHARS = 8_000;
300
+
301
+ /** The rows in pages that each fit {@link REVIEW_PAGE_CHARS}, nothing cut; at least one (maybe empty) page. */
302
+ export function reviewPages<T>(rows: T[]): T[][] {
303
+ const pages: T[][] = [[]];
304
+ let size = 0;
305
+ for (const row of rows) {
306
+ const rowSize = JSON.stringify(row).length + 1;
307
+ if (size + rowSize > REVIEW_PAGE_CHARS && pages[pages.length - 1]!.length) {
308
+ pages.push([]);
309
+ size = 0;
310
+ }
311
+ pages[pages.length - 1]!.push(row);
312
+ size += rowSize;
313
+ }
314
+ return pages;
315
+ }
316
+
317
+ type Network = ChainCall['network'];
318
+
319
+ export type HubNetwork = 'ixo mainnet' | 'ixo testnet' | 'ixo devnet';
320
+
321
+ export function networkLabel(network: Network): HubNetwork {
322
+ return `ixo ${network}`;
323
+ }
324
+
325
+ /** The card's `data` limit (`string(2, 60000)` in the protocol). */
326
+ export const MAX_CARD_DATA = 60_000;
327
+ /** The card's evidence limits: 30 files, a field of at most 200 characters. */
328
+ export const MAX_CARD_EVIDENCE = 30;
329
+ const MAX_EVIDENCE_FIELD = 200;
330
+
331
+ /** `shops[2].photo` as the card's evidence field: `shops.2.photo`. */
332
+ export function evidenceField(path: string): string {
333
+ return path.replace(/\[(\d+)\]\./g, '.$1.');
334
+ }
335
+
336
+ /**
337
+ * The files still answering a visible question: a file replaced since, or one
338
+ * whose question is now hidden, is left out.
339
+ */
340
+ export function liveEvidence(
341
+ model: Model,
342
+ draft: Pick<Draft, 'evidence'>,
343
+ ): Evidence[] {
344
+ const paths = new Set(
345
+ visibleQuestions(model)
346
+ .filter((view) => view.input === 'file')
347
+ .map((view) => view.path),
348
+ );
349
+ return draft.evidence.filter((item) => {
350
+ if (!paths.has(item.path)) return false;
351
+ const value: unknown = questionAt(model, item.path)?.value;
352
+ return (Array.isArray(value) ? value : []).some(
353
+ (file) =>
354
+ field(file, 'content') === `${PENDING_FILE_PREFIX}${item.sha256}`,
355
+ );
356
+ });
357
+ }
358
+
359
+ /**
360
+ * How long a file's gateway link must still be valid when a card is sent:
361
+ * the card's review window (15 minutes) and a margin for the Hub's upload.
362
+ */
363
+ export const EVIDENCE_LINK_MARGIN_S = 60 * 60;
364
+
365
+ /**
366
+ * Files whose gateway link (`<origin>/media/<binding>/<id>?exp=<unix s>&sig=…`,
367
+ * valid 30 days) lapses before the Hub could fetch it; a link without `exp`
368
+ * is left to the Hub's own check.
369
+ */
370
+ export function lapsedEvidence(
371
+ evidence: Evidence[],
372
+ nowMs: number,
373
+ ): Evidence[] {
374
+ return evidence.filter((item) => {
375
+ let exp: string | null;
376
+ try {
377
+ exp = new URL(item.url).searchParams.get('exp');
378
+ } catch {
379
+ return true;
380
+ }
381
+ if (exp === null) return false;
382
+ const at = Number(exp);
383
+ return !Number.isFinite(at) || at < nowMs / 1000 + EVIDENCE_LINK_MARGIN_S;
384
+ });
385
+ }
386
+
387
+ export type Built<T> =
388
+ | { ok: true; operation: T; rows: ReviewRow[] }
389
+ | { ok: false; why: RowProblem | 'files' | 'data' };
390
+
391
+ const cardRows = (rows: ReviewRow[]): CardRow[] =>
392
+ rows.map(({ field: path, label, value }) => ({ field: path, label, value }));
393
+
394
+ /**
395
+ * The card, checked as the Hub checks it (`submissionSummaryIssue`): a
396
+ * mismatch is a bug here, refused rather than sent.
397
+ */
398
+ function checked<T extends BidSubmitOperation | ClaimSubmitOperation>(
399
+ operation: T,
400
+ rows: ReviewRow[],
401
+ ): Built<T> {
402
+ const issue = submissionSummaryIssue(operation);
403
+ if (issue) {
404
+ console.warn(`[yoma] card rows refused: ${issue.slice(0, 120)}`);
405
+ return { ok: false, why: 'shape' };
406
+ }
407
+ return { ok: true, operation, rows };
408
+ }
409
+
410
+ /** The application card for a finished application draft. */
411
+ export function buildApplicationOperation(input: {
412
+ network: HubNetwork;
413
+ model: Model;
414
+ draft: Pick<Draft, 'collectionId' | 'title'>;
415
+ }): Built<BidSubmitOperation> {
416
+ const { data, rows, problem } = readBack(input.model);
417
+ if (problem) return { ok: false, why: problem };
418
+ const json = JSON.stringify(data);
419
+ if (json.length > MAX_CARD_DATA) return { ok: false, why: 'data' };
420
+ return checked(
421
+ {
422
+ kind: 'ixo.bid.submit.v1',
423
+ network: input.network,
424
+ collectionId: input.draft.collectionId,
425
+ title: clip(input.draft.title, 120),
426
+ role: 'SA',
427
+ summary: cardRows(rows),
428
+ data: json,
429
+ },
430
+ rows,
431
+ );
432
+ }
433
+
434
+ /** The claim card for a finished claim draft, with one evidence entry per file still answering a visible question. */
435
+ export function buildClaimOperation(input: {
436
+ network: HubNetwork;
437
+ model: Model;
438
+ draft: Pick<Draft, 'collectionId' | 'title' | 'evidence'>;
439
+ }): Built<ClaimSubmitOperation> {
440
+ clearHiddenAnswers(input.model);
441
+ const evidence = liveEvidence(input.model, input.draft).map((item) => ({
442
+ field: evidenceField(item.path),
443
+ url: item.url,
444
+ filename: item.filename,
445
+ mimetype: item.mimetype,
446
+ size: item.size,
447
+ sha256: item.sha256,
448
+ }));
449
+ if (
450
+ evidence.length > MAX_CARD_EVIDENCE ||
451
+ evidence.some((item) => item.field.length > MAX_EVIDENCE_FIELD)
452
+ )
453
+ return { ok: false, why: 'files' };
454
+ const { data, rows, problem } = readBack(input.model);
455
+ if (problem) return { ok: false, why: problem };
456
+ const json = JSON.stringify(data);
457
+ if (json.length > MAX_CARD_DATA) return { ok: false, why: 'data' };
458
+ return checked(
459
+ {
460
+ kind: 'ixo.claim.submit.v1',
461
+ network: input.network,
462
+ collectionId: input.draft.collectionId,
463
+ title: clip(input.draft.title, 120),
464
+ summary: cardRows(rows),
465
+ data: json,
466
+ evidence,
467
+ },
468
+ rows,
469
+ );
470
+ }
471
+
472
+ /**
473
+ * The Hub's idempotency key for one send: the run and tool call that sent
474
+ * it, the draft's `cardAttempt` (bumped after a send fails, and when a card
475
+ * is cleared) and the owner, hashed to 64 hex characters (the Hub takes
476
+ * 1–120; its keys are unique per oracle account, so two people never share
477
+ * one). Every send gets a key of its own: the runtime binds a key to the run
478
+ * that reserved it, so a retry in a later run needs a new one. What stops a
479
+ * second card is the draft's `card`, set as soon as one was made.
480
+ */
481
+ export async function cardRequestKey(input: {
482
+ ownerDid: string;
483
+ runId: string;
484
+ toolCallId: string;
485
+ cardAttempt: number;
486
+ }): Promise<string> {
487
+ const bytes = new Uint8Array(
488
+ await crypto.subtle.digest(
489
+ 'SHA-256',
490
+ new TextEncoder().encode(
491
+ JSON.stringify([
492
+ 'yoma-card',
493
+ input.ownerDid,
494
+ input.runId,
495
+ input.toolCallId,
496
+ input.cardAttempt,
497
+ ]),
498
+ ),
499
+ ),
500
+ );
501
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
502
+ }
503
+
504
+ /** Known reward tokens, so pay reads as an amount, never as a token name. */
505
+ const DENOMS: Record<
506
+ string,
507
+ { label: string; decimals: number; prefix?: string }
508
+ > = {
509
+ // USDC on ixo (Noble, via IBC), as Jambo's wallet shows it.
510
+ 'ibc/6BBE9BD4246F8E04948D5A4EEE7164B2630263B9EBB5E7DC5F0A46C62A2FF97B': {
511
+ label: 'USD',
512
+ decimals: 6,
513
+ prefix: '$',
514
+ },
515
+ uixo: { label: 'IXO', decimals: 6 },
516
+ };
517
+
518
+ /** What a job pays, in words: "$1.27", or undefined when it lists no reward. */
519
+ export function describeReward(
520
+ reward: Array<{ denom: string; amount: string }>,
521
+ ): string | undefined {
522
+ const parts = reward.map((coin) => {
523
+ const known = DENOMS[coin.denom];
524
+ if (!known) return undefined;
525
+ const value = Number(coin.amount) / 10 ** known.decimals;
526
+ const amount = value.toLocaleString('en-US', { maximumFractionDigits: 2 });
527
+ return known.prefix
528
+ ? `${known.prefix}${amount}`
529
+ : `${amount} ${known.label}`;
530
+ });
531
+ if (!parts.length || parts.some((part) => part === undefined))
532
+ return parts.length ? 'a reward' : undefined;
533
+ return parts.join(' + ');
534
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Upstream text (entity profiles, form titles and questions, programme
3
+ * descriptions) is written by whoever made the entity or form: it is
4
+ * clipped before the model sees it and the tools say it is data, never
5
+ * instructions.
6
+ */
7
+ export function clip(text: string, max: number): string {
8
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
9
+ }
10
+
11
+ /** A trimmed, clipped string, or undefined for anything else or an empty one. */
12
+ export function clippedText(value: unknown, max: number): string | undefined {
13
+ if (typeof value !== 'string') return undefined;
14
+ const trimmed = value.trim();
15
+ return trimmed ? clip(trimmed, max) : undefined;
16
+ }