@softize/opus 25.1.0 → 25.2.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,545 @@
1
+ /**
2
+ * O assistente do recurso declarado na action de leitura (ADR 0053): a validação da declaração e a
3
+ * projeção limitada do dado do recurso que o host entrega ao modelo.
4
+ *
5
+ * Tudo aqui é puro e sem dependência externa. O runtime usa estas funções no registro e em
6
+ * `readResource`; um host que recebe a saída da action por outro canal, como o MCP, aplica a mesma
7
+ * projeção com `projectAssistantContext`.
8
+ */
9
+
10
+ import { error } from './errors.ts'
11
+ import type { ActionAssistant, ActionDef, ActionError, ResultMeta } from './types.ts'
12
+
13
+ /** Teto padrão, em caracteres, do JSON de `context`. */
14
+ export const ASSISTANT_CONTEXT_DEFAULT_CHARS = 4_000
15
+ /** Menor teto aceito em `maxContextChars`. */
16
+ export const ASSISTANT_CONTEXT_MIN_CHARS = 1_000
17
+ /** Maior teto aceito em `maxContextChars`. */
18
+ export const ASSISTANT_CONTEXT_MAX_CHARS = 16_000
19
+ /** Tamanho máximo do título do recurso, `…` incluído. Fica fora da conta do teto. */
20
+ export const ASSISTANT_TITLE_MAX_CHARS = 200
21
+ /** Um texto é encurtado pela metade até este mínimo, `…` incluído. */
22
+ export const ASSISTANT_TEXT_MIN_CHARS = 200
23
+
24
+ const RESOURCE_RE = /^[a-z][a-zA-Z0-9]*$/
25
+ const SKILL_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
26
+ const ELLIPSIS = '…'
27
+
28
+ /**
29
+ * Um corte ou uma ausência na projeção. `path` é o caminho na saída, sem índice de lista. Para
30
+ * `removed` e `missing`, é o caminho declarado (ou o título); para `items` e `shortened`, pode
31
+ * ficar abaixo do declarado quando o caminho declarado termina num objeto ou numa lista.
32
+ *
33
+ * - `items`: itens removidos do fim da lista em `path`; `count` diz quantos.
34
+ * - `shortened`: um texto em `path` foi encurtado, com `…` no fim.
35
+ * - `removed`: o campo declarado em `path` saiu inteiro.
36
+ * - `missing`: o caminho declarado não existe na saída desta execução; no título, também quando o
37
+ * valor não é texto nem número.
38
+ */
39
+ export interface AssistantOmission {
40
+ path: string
41
+ reason: 'items' | 'shortened' | 'removed' | 'missing'
42
+ count?: number
43
+ }
44
+
45
+ /** O resultado de `projectAssistantContext`. */
46
+ export interface AssistantProjection {
47
+ /** O título do recurso, com no máximo 200 caracteres; `null` quando a saída não o traz. */
48
+ title: string | null
49
+ /** Somente os caminhos declarados em `context`, com a mesma estrutura da saída. */
50
+ data: Record<string, unknown>
51
+ /** `true` quando o teto obrigou a cortar `data`. */
52
+ truncated: boolean
53
+ /** O que saiu ou faltou, na ordem em que aconteceu. */
54
+ omitted: AssistantOmission[]
55
+ }
56
+
57
+ /** A declaração pública do assistente, com o teto resolvido. É o que o manifest, as ferramentas de
58
+ * IA e o MCP projetam. */
59
+ export interface AssistantDeclaration {
60
+ resource: string
61
+ skill: string
62
+ label: string
63
+ title: string
64
+ context: string[]
65
+ maxContextChars: number
66
+ }
67
+
68
+ /** Uma declaração registrada no runtime, com a action dona. */
69
+ export interface RegisteredAssistant extends AssistantDeclaration {
70
+ /** O nome da action dona. */
71
+ action: string
72
+ /** Repete a declaração `personalData` da action dona. */
73
+ personalData: boolean
74
+ }
75
+
76
+ /** O teto declarado ou o padrão. */
77
+ export function resolveAssistantContextLimit(assistant: Pick<ActionAssistant, 'maxContextChars'>): number {
78
+ return assistant.maxContextChars ?? ASSISTANT_CONTEXT_DEFAULT_CHARS
79
+ }
80
+
81
+ /** A declaração pública, sem referência compartilhada com o contrato. */
82
+ export function publicAssistant(assistant: ActionAssistant): AssistantDeclaration {
83
+ return {
84
+ resource: assistant.resource,
85
+ skill: assistant.skill,
86
+ label: assistant.label,
87
+ title: assistant.title,
88
+ context: [...assistant.context],
89
+ maxContextChars: resolveAssistantContextLimit(assistant),
90
+ }
91
+ }
92
+
93
+ // Segmentos que mexem no protótipo de um objeto JavaScript não são campos de saída.
94
+ const RESERVED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
95
+
96
+ function isValidPath(path: unknown): path is string {
97
+ return (
98
+ typeof path === 'string' &&
99
+ path.split('.').every((segment) => segment.length > 0 && !/\s/.test(segment) && !RESERVED_SEGMENTS.has(segment))
100
+ )
101
+ }
102
+
103
+ function aiEnabled(ai: ActionDef['ai']): boolean {
104
+ if (ai === undefined || ai === false) return false
105
+ if (ai === true) return true
106
+ return ai.enabled !== false
107
+ }
108
+
109
+ /**
110
+ * Confere a declaração de uma action no registro. Lança `runtime.assistant_declaration_invalid`
111
+ * para a forma e `runtime.assistant_context_limit_invalid` para o teto fora do intervalo. A entrada
112
+ * com `id` como único campo obrigatório é conferida pelo `opus gen`, que enxerga o JSON Schema.
113
+ */
114
+ export function assertAssistantDeclaration(action: ActionDef): void {
115
+ const declared = (action as { assistant?: unknown }).assistant
116
+ if (declared === undefined) return
117
+ const problems: string[] = []
118
+ if (action.kind !== 'view') problems.push(`só uma action view declara assistente, e esta é ${action.kind}`)
119
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) {
120
+ problems.push('assistant precisa ser um objeto')
121
+ } else {
122
+ const assistant = declared as Partial<Record<keyof ActionAssistant, unknown>>
123
+ if (!aiEnabled(action.ai)) problems.push('a action precisa estar publicada para a IA (ai habilitado)')
124
+ if (typeof action.personalData !== 'boolean') problems.push('a action precisa declarar personalData, true ou false')
125
+ if (typeof assistant.resource !== 'string' || !RESOURCE_RE.test(assistant.resource))
126
+ problems.push('resource precisa ser um identificador em camelCase, como customer')
127
+ if (typeof assistant.skill !== 'string' || !SKILL_RE.test(assistant.skill))
128
+ problems.push('skill precisa estar em minúsculas com hífen, como review-customer')
129
+ if (typeof assistant.label !== 'string' || assistant.label.trim().length === 0)
130
+ problems.push('label precisa ser um texto não vazio')
131
+ if (!isValidPath(assistant.title)) problems.push('title precisa ser um caminho da saída, como name')
132
+ if (!Array.isArray(assistant.context) || !assistant.context.every(isValidPath))
133
+ problems.push(
134
+ 'context precisa ser uma lista de caminhos da saída, como leads.totalCount, sem __proto__, constructor nem prototype',
135
+ )
136
+ }
137
+ if (problems.length > 0) {
138
+ throw error({
139
+ code: 'runtime.assistant_declaration_invalid',
140
+ category: 'internal',
141
+ message: `O assistente da action "${action.name}" é inválido: ${problems.join('; ')} (ADR 0053).`,
142
+ })
143
+ }
144
+ assertContextLimit(action.name, (declared as ActionAssistant).maxContextChars)
145
+ }
146
+
147
+ function assertContextLimit(owner: string, limit: unknown): void {
148
+ if (limit === undefined) return
149
+ if (
150
+ typeof limit === 'number' &&
151
+ Number.isInteger(limit) &&
152
+ limit >= ASSISTANT_CONTEXT_MIN_CHARS &&
153
+ limit <= ASSISTANT_CONTEXT_MAX_CHARS
154
+ )
155
+ return
156
+ throw error({
157
+ code: 'runtime.assistant_context_limit_invalid',
158
+ category: 'internal',
159
+ message:
160
+ `O maxContextChars do assistente de "${owner}" precisa ser um inteiro de ` +
161
+ `${ASSISTANT_CONTEXT_MIN_CHARS} a ${ASSISTANT_CONTEXT_MAX_CHARS} (ADR 0053).`,
162
+ })
163
+ }
164
+
165
+ // =============================================================================
166
+ // Projeção
167
+ // =============================================================================
168
+
169
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
170
+ type JsonObject = { [key: string]: Json }
171
+
172
+ function isObject(value: unknown): value is JsonObject {
173
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
174
+ }
175
+
176
+ /** Grava como propriedade própria, inclusive `__proto__`, que uma atribuição trataria como protótipo. */
177
+ function setOwn(target: JsonObject, key: string, value: Json): void {
178
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
179
+ }
180
+
181
+ /** A saída como o transporte a veria: datas viram texto e `undefined` some. */
182
+ function toJson(value: unknown): Json | undefined {
183
+ if (value === undefined) return undefined
184
+ const text = JSON.stringify(value)
185
+ return text === undefined ? undefined : (JSON.parse(text) as Json)
186
+ }
187
+
188
+ function size(value: Json): number {
189
+ return JSON.stringify(value).length
190
+ }
191
+
192
+ /** Corta um texto em `max` caracteres com `…` no fim, sem partir um par substituto. */
193
+ function shorten(text: string, max: number): string {
194
+ let end = max - ELLIPSIS.length
195
+ const code = text.charCodeAt(end - 1)
196
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1
197
+ return `${text.slice(0, end)}${ELLIPSIS}`
198
+ }
199
+
200
+ /** Remove caminhos repetidos e os cobertos por outro declarado, que já leva o valor inteiro. */
201
+ function normalizePaths(paths: readonly string[]): string[] {
202
+ const unique = [...new Set(paths)]
203
+ return unique.filter(
204
+ (path) => !unique.some((other) => other !== path && path.startsWith(`${other}.`)),
205
+ )
206
+ }
207
+
208
+ class Omissions {
209
+ readonly list: AssistantOmission[] = []
210
+ private readonly byKey = new Map<string, AssistantOmission>()
211
+
212
+ add(path: string, reason: AssistantOmission['reason']): AssistantOmission {
213
+ const key = `${reason}\u0000${path}`
214
+ const found = this.byKey.get(key)
215
+ if (found !== undefined) return found
216
+ const entry: AssistantOmission = { path, reason }
217
+ this.byKey.set(key, entry)
218
+ this.list.push(entry)
219
+ return entry
220
+ }
221
+
222
+ removedItems(path: string, count: number): void {
223
+ const entry = this.add(path, 'items')
224
+ entry.count = (entry.count ?? 0) + count
225
+ }
226
+ }
227
+
228
+ const join = (prefix: string, key: string): string => (prefix === '' ? key : `${prefix}.${key}`)
229
+
230
+ /** Copia de `node` somente os caminhos (relativos) em `paths`. `undefined` = nada a levar. */
231
+ function pick(node: Json, paths: string[][], prefix: string, omitted: Omissions): Json | undefined {
232
+ if (paths.some((path) => path.length === 0)) return node
233
+ if (node === null) return null
234
+ if (Array.isArray(node)) {
235
+ const items: Json[] = []
236
+ for (const item of node) {
237
+ const picked = pick(item, paths, prefix, omitted)
238
+ if (picked !== undefined) items.push(picked)
239
+ }
240
+ return items
241
+ }
242
+ if (!isObject(node)) {
243
+ for (const path of paths) omitted.add(join(prefix, path.join('.')), 'missing')
244
+ return undefined
245
+ }
246
+ const groups = new Map<string, string[][]>()
247
+ for (const [key, ...rest] of paths) {
248
+ const group = groups.get(key!) ?? []
249
+ group.push(rest)
250
+ groups.set(key!, group)
251
+ }
252
+ const out: JsonObject = {}
253
+ for (const [key, rests] of groups) {
254
+ const full = join(prefix, key)
255
+ if (!Object.hasOwn(node, key)) {
256
+ for (const rest of rests) omitted.add(rest.length === 0 ? full : join(full, rest.join('.')), 'missing')
257
+ continue
258
+ }
259
+ const picked = pick(node[key]!, rests, full, omitted)
260
+ if (picked !== undefined) setOwn(out, key, picked)
261
+ }
262
+ return out
263
+ }
264
+
265
+ /**
266
+ * Fila de prioridade mínima por `before`: as etapas de corte escolhem o maior candidato sem varrer
267
+ * a projeção inteira a cada passo, o que deixaria o corte quadrático em listas longas.
268
+ */
269
+ class Heap<T> {
270
+ private readonly items: T[] = []
271
+ private readonly before: (a: T, b: T) => boolean
272
+
273
+ constructor(before: (a: T, b: T) => boolean) {
274
+ this.before = before
275
+ }
276
+
277
+ push(item: T): void {
278
+ const items = this.items
279
+ items.push(item)
280
+ let index = items.length - 1
281
+ while (index > 0) {
282
+ const parent = (index - 1) >> 1
283
+ if (!this.before(items[index]!, items[parent]!)) break
284
+ ;[items[index], items[parent]] = [items[parent]!, items[index]!]
285
+ index = parent
286
+ }
287
+ }
288
+
289
+ pop(): T | undefined {
290
+ const items = this.items
291
+ const top = items[0]
292
+ const last = items.pop()
293
+ if (items.length > 0 && last !== undefined) {
294
+ items[0] = last
295
+ let index = 0
296
+ for (;;) {
297
+ const left = index * 2 + 1
298
+ const right = left + 1
299
+ let best = index
300
+ if (left < items.length && this.before(items[left]!, items[best]!)) best = left
301
+ if (right < items.length && this.before(items[right]!, items[best]!)) best = right
302
+ if (best === index) break
303
+ ;[items[index], items[best]] = [items[best]!, items[index]!]
304
+ index = best
305
+ }
306
+ }
307
+ return top
308
+ }
309
+ }
310
+
311
+ interface ListRef {
312
+ list: Json[]
313
+ /** Caminho na saída, sem índice de lista. */
314
+ path: string
315
+ /** Ordem na projeção: no empate, a lista que aparece primeiro perde o item. */
316
+ order: number
317
+ }
318
+
319
+ function collectLists(node: Json, path: string, out: ListRef[]): void {
320
+ if (Array.isArray(node)) {
321
+ out.push({ list: node, path, order: out.length })
322
+ for (const item of node) collectLists(item, path, out)
323
+ } else if (isObject(node)) {
324
+ for (const [key, value] of Object.entries(node)) collectLists(value, join(path, key), out)
325
+ }
326
+ }
327
+
328
+ function collectNestedLists(node: Json, into: WeakSet<Json[]>): void {
329
+ if (Array.isArray(node)) {
330
+ into.add(node)
331
+ for (const item of node) collectNestedLists(item, into)
332
+ } else if (isObject(node)) {
333
+ for (const value of Object.values(node)) collectNestedLists(value, into)
334
+ }
335
+ }
336
+
337
+ interface TextRef {
338
+ parent: JsonObject | Json[]
339
+ key: string | number
340
+ /** Caminho na saída, sem índice de lista. */
341
+ path: string
342
+ order: number
343
+ }
344
+
345
+ function collectTexts(node: Json, path: string, out: TextRef[]): void {
346
+ const visit = (parent: JsonObject | Json[], key: string | number, value: Json, at: string): void => {
347
+ if (typeof value === 'string') {
348
+ if (value.length > ASSISTANT_TEXT_MIN_CHARS) out.push({ parent, key, path: at, order: out.length })
349
+ } else if (Array.isArray(value)) {
350
+ value.forEach((item, index) => visit(value, index, item, at))
351
+ } else if (isObject(value)) {
352
+ for (const [childKey, child] of Object.entries(value)) visit(value, childKey, child, join(at, childKey))
353
+ }
354
+ }
355
+ if (isObject(node)) for (const [key, value] of Object.entries(node)) visit(node, key, value, join(path, key))
356
+ }
357
+
358
+ const textAt = (ref: TextRef): string => (ref.parent as Record<string | number, Json>)[ref.key] as string
359
+
360
+ /** Tira de `node` o caminho `segments`, preservando o que outro caminho restante ainda usa. */
361
+ function removePath(node: Json, segments: string[], depth: number, prefix: string, keep: Set<string>): boolean {
362
+ if (Array.isArray(node)) {
363
+ let removed = false
364
+ for (const item of node) removed = removePath(item, segments, depth, prefix, keep) || removed
365
+ return removed
366
+ }
367
+ if (!isObject(node)) return false
368
+ const key = segments[depth]!
369
+ if (!Object.hasOwn(node, key)) return false
370
+ const full = join(prefix, key)
371
+ if (!keep.has(full)) {
372
+ delete node[key]
373
+ return true
374
+ }
375
+ return removePath(node[key]!, segments, depth + 1, full, keep)
376
+ }
377
+
378
+ function prefixesOf(paths: string[]): Set<string> {
379
+ const out = new Set<string>()
380
+ for (const path of paths) {
381
+ const segments = path.split('.')
382
+ for (let index = 1; index <= segments.length; index += 1) out.add(segments.slice(0, index).join('.'))
383
+ }
384
+ return out
385
+ }
386
+
387
+ /**
388
+ * O título do recurso: o texto, ou o número como texto, no caminho de `title`, encurtado a 200
389
+ * caracteres. Ausente, nulo ou de outro tipo vira `null` e entra em `omitted` como `missing`, porque
390
+ * o recurso fica sem nome para pessoas e para o modelo. Lê a saída sem serializá-la inteira, mas
391
+ * aplica `toJSON` no caminho, como a serialização faria.
392
+ */
393
+ export function resolveAssistantTitle(
394
+ assistant: Pick<ActionAssistant, 'title'>,
395
+ output: unknown,
396
+ ): { title: string | null; omitted: AssistantOmission[] } {
397
+ // Aplica `toJSON` como o JSON faria, para o título sair igual com e sem a projeção: uma data, por
398
+ // exemplo, vira o texto ISO nos dois caminhos.
399
+ const asJson = (value: unknown, key: string): unknown =>
400
+ typeof value === 'object' && value !== null && typeof (value as { toJSON?: unknown }).toJSON === 'function'
401
+ ? (value as { toJSON: (key: string) => unknown }).toJSON(key)
402
+ : value
403
+ let node: unknown = asJson(output, '')
404
+ for (const segment of assistant.title.split('.')) {
405
+ if (typeof node !== 'object' || node === null || Array.isArray(node) || !Object.hasOwn(node, segment)) {
406
+ node = undefined
407
+ break
408
+ }
409
+ node = asJson((node as Record<string, unknown>)[segment], segment)
410
+ }
411
+ const text = typeof node === 'string' ? node : typeof node === 'number' && Number.isFinite(node) ? String(node) : null
412
+ if (text === null) return { title: null, omitted: [{ path: assistant.title, reason: 'missing' }] }
413
+ if (text.length <= ASSISTANT_TITLE_MAX_CHARS) return { title: text, omitted: [] }
414
+ return { title: shorten(text, ASSISTANT_TITLE_MAX_CHARS), omitted: [{ path: assistant.title, reason: 'shortened' }] }
415
+ }
416
+
417
+ /**
418
+ * Projeta a saída da action dona no dado que o host entrega ao modelo (ADR 0053, seção 3).
419
+ *
420
+ * `data` leva só os caminhos de `context`. Um intermediário `null` vira `null` naquele ponto; um
421
+ * caminho ausente na saída é omitido e registrado com o motivo `missing`, sem falhar. Acima do
422
+ * teto, a projeção corta por etapas até caber: remove o último item da maior lista; com as listas
423
+ * vazias, encurta pela metade o texto mais longo, até 200 caracteres; e, por fim, remove campos
424
+ * inteiros, do último declarado para o primeiro. Cada etapa tira alguma coisa e a última chega a um
425
+ * objeto vazio, então o corte sempre termina; as duas primeiras escolhem o candidato por fila de
426
+ * prioridade, e o custo cresce com o tamanho da saída, não com o quadrado dele. O título fica fora
427
+ * do teto e é encurtado a 200 caracteres.
428
+ *
429
+ * Em `omitted`, o caminho é o da saída, sem índice de lista: para um caminho declarado que termina
430
+ * num objeto ou numa lista, a lista cortada ou o texto encurtado podem estar abaixo dele.
431
+ *
432
+ * Função pura: não altera a saída recebida nem registra log. Quem a chama decide o aviso sobre o que
433
+ * faltou. Lança se a saída não puder ser serializada em JSON (`BigInt`, referência circular).
434
+ */
435
+ export function projectAssistantContext(assistant: ActionAssistant, output: unknown): AssistantProjection {
436
+ assertContextLimit(assistant.resource, assistant.maxContextChars)
437
+ const limit = resolveAssistantContextLimit(assistant)
438
+ const omitted = new Omissions()
439
+ const json = toJson(output)
440
+ const resolved = resolveAssistantTitle(assistant, json)
441
+ for (const entry of resolved.omitted) omitted.add(entry.path, entry.reason)
442
+ const paths = normalizePaths(assistant.context)
443
+
444
+ let data: JsonObject = {}
445
+ if (isObject(json)) {
446
+ const picked = pick(json, paths.map((path) => path.split('.')), '', omitted)
447
+ if (isObject(picked)) data = picked
448
+ } else {
449
+ for (const path of paths) omitted.add(path, 'missing')
450
+ }
451
+
452
+ let current = size(data)
453
+ if (current <= limit) return { title: resolved.title, data, truncated: false, omitted: omitted.list }
454
+
455
+ // 1. O último item da lista com mais itens, até as listas esvaziarem.
456
+ const lists: ListRef[] = []
457
+ collectLists(data, '', lists)
458
+ const detached = new WeakSet<Json[]>()
459
+ type ListEntry = { ref: ListRef; length: number }
460
+ const byLength = new Heap<ListEntry>((a, b) =>
461
+ a.length !== b.length ? a.length > b.length : a.ref.order < b.ref.order,
462
+ )
463
+ for (const ref of lists) if (ref.list.length > 0) byLength.push({ ref, length: ref.list.length })
464
+ while (current > limit) {
465
+ const entry = byLength.pop()
466
+ if (entry === undefined) break
467
+ const { ref } = entry
468
+ // Entrada vencida: a lista mudou de tamanho depois de entrar na fila, ou saiu junto com o item
469
+ // de outra lista.
470
+ if (detached.has(ref.list) || ref.list.length !== entry.length || entry.length === 0) continue
471
+ const item = ref.list.pop()!
472
+ current -= size(item) + (ref.list.length > 0 ? 1 : 0)
473
+ collectNestedLists(item, detached)
474
+ omitted.removedItems(ref.path, 1)
475
+ if (ref.list.length > 0) byLength.push({ ref, length: ref.list.length })
476
+ }
477
+
478
+ // 2. O texto mais longo pela metade, até o mínimo. Com as listas vazias, os textos que restam
479
+ // estão em objetos e não mudam de lugar.
480
+ const texts: TextRef[] = []
481
+ if (current > limit) collectTexts(data, '', texts)
482
+ type TextEntry = { ref: TextRef; length: number }
483
+ const byText = new Heap<TextEntry>((a, b) =>
484
+ a.length !== b.length ? a.length > b.length : a.ref.order < b.ref.order,
485
+ )
486
+ for (const ref of texts) byText.push({ ref, length: textAt(ref).length })
487
+ while (current > limit) {
488
+ const entry = byText.pop()
489
+ if (entry === undefined) break
490
+ const text = textAt(entry.ref)
491
+ const shorter = shorten(text, Math.max(ASSISTANT_TEXT_MIN_CHARS, Math.floor(text.length / 2)))
492
+ current -= size(text) - size(shorter)
493
+ ;(entry.ref.parent as Record<string | number, Json>)[entry.ref.key] = shorter
494
+ omitted.add(entry.ref.path, 'shortened')
495
+ if (shorter.length > ASSISTANT_TEXT_MIN_CHARS) byText.push({ ref: entry.ref, length: shorter.length })
496
+ }
497
+
498
+ // 3. Campos inteiros, do último declarado para o primeiro.
499
+ for (let index = paths.length - 1; index >= 0 && current > limit; index -= 1) {
500
+ const path = paths[index]!
501
+ if (removePath(data, path.split('.'), 0, '', prefixesOf(paths.slice(0, index)))) {
502
+ omitted.add(path, 'removed')
503
+ current = size(data)
504
+ }
505
+ }
506
+
507
+ return { title: resolved.title, data, truncated: true, omitted: omitted.list }
508
+ }
509
+
510
+ // =============================================================================
511
+ // Leitura do recurso
512
+ // =============================================================================
513
+
514
+ /** O recurso lido pela action dona, como o host o mostra e o entrega ao modelo. */
515
+ export interface ResourceDescriptor {
516
+ /** O tipo de recurso (`assistant.resource`). */
517
+ kind: string
518
+ id: string
519
+ /** O valor do campo declarado em `assistant.title`, encurtado a 200 caracteres. */
520
+ title: string | null
521
+ /** O nome do assistente para pessoas. */
522
+ label: string
523
+ /** A chave do procedimento que a IA segue. */
524
+ skill: string
525
+ }
526
+
527
+ /** O dado do recurso que o host pode entregar ao modelo, já limitado pelo teto. */
528
+ export interface ResourceContext {
529
+ data: Record<string, unknown>
530
+ truncated: boolean
531
+ omitted: AssistantOmission[]
532
+ /** Repete a declaração da action dona, para o host marcar a conversa. */
533
+ personalData: boolean
534
+ }
535
+
536
+ /** Opções de `runtime.readResource`. */
537
+ export interface ReadResourceOptions {
538
+ /** Inclui a projeção `context`. Sem isto, a leitura devolve só o descritor. */
539
+ context?: boolean
540
+ }
541
+
542
+ /** O resultado de `runtime.readResource`: o descritor ou o erro da action dona. */
543
+ export type ResourceReadResult =
544
+ | { ok: true; resource: ResourceDescriptor; context?: ResourceContext; meta: ResultMeta }
545
+ | { ok: false; error: ActionError; meta?: ResultMeta }
package/src/core/index.ts CHANGED
@@ -53,6 +53,7 @@ export type {
53
53
  ConfirmSpec,
54
54
  RateLimitSpec,
55
55
  AIConfig,
56
+ ActionAssistant,
56
57
  Example,
57
58
  ActionMessages,
58
59
  OptionsSpec,
@@ -241,3 +242,25 @@ export type {
241
242
  ResolvedRuntimeConfig,
242
243
  } from './runtime.ts'
243
244
  export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
245
+
246
+ // — Assistente do recurso (ADR 0053) ————————————————————————————————————————————
247
+ export {
248
+ ASSISTANT_CONTEXT_DEFAULT_CHARS,
249
+ ASSISTANT_CONTEXT_MAX_CHARS,
250
+ ASSISTANT_CONTEXT_MIN_CHARS,
251
+ ASSISTANT_TEXT_MIN_CHARS,
252
+ ASSISTANT_TITLE_MAX_CHARS,
253
+ projectAssistantContext,
254
+ publicAssistant,
255
+ resolveAssistantTitle,
256
+ } from './assistant.ts'
257
+ export type {
258
+ AssistantDeclaration,
259
+ AssistantOmission,
260
+ AssistantProjection,
261
+ ReadResourceOptions,
262
+ RegisteredAssistant,
263
+ ResourceContext,
264
+ ResourceDescriptor,
265
+ ResourceReadResult,
266
+ } from './assistant.ts'