@softize/opus 25.0.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.
- package/CHANGELOG.md +93 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/assistant-schema.d.mts +55 -0
- package/bin/lib/assistant-schema.mjs +286 -0
- package/bin/lib/check.mjs +173 -0
- package/bin/lib/copy.mjs +2 -0
- package/bin/lib/gen-manifest.mjs +3 -0
- package/bin/lib/gen-runner.mjs +29 -1
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
- package/docs/protocol.md +54 -0
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +10 -1
- package/registry/skills/create-opus-action/SKILL.md +8 -3
- package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
- package/registry/skills/create-opus-action/references/evaluations.md +3 -0
- package/registry/skills/test-opus-action/SKILL.md +4 -1
- package/registry/skills/test-opus-action/references/evaluations.md +2 -0
- package/registry/skills/test-opus-action/references/harness.md +14 -0
- package/src/ai/index.ts +3 -0
- package/src/ai/resource-context.ts +113 -0
- package/src/core/assistant.ts +545 -0
- package/src/core/index.ts +23 -0
- package/src/core/runtime.ts +136 -0
- package/src/core/types.ts +34 -0
- package/src/mcp/index.ts +3 -0
- package/src/ui/components/primitives/dialog.tsx +285 -26
- package/src/ui/components/primitives/drawer.tsx +104 -13
- package/src/ui/components/primitives/floating-layer.tsx +79 -0
- package/src/ui/components/primitives/menu.tsx +17 -2
- package/src/ui/components/primitives/popover.tsx +6 -1
- package/src/ui/components/primitives/tooltip.tsx +6 -1
- package/src/ui/docs/content/actions.md +97 -1
- package/src/ui/docs/content/ai.md +4 -2
- package/src/ui/docs/content/cli.md +2 -1
- package/src/ui/docs/content/dialog.md +3 -0
- package/src/ui/docs/content/drawer.md +3 -0
- package/src/ui/docs/content/floating-layer.md +178 -0
- package/src/ui/docs/content/mcp.md +3 -1
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/lib/dismiss-on-iframe.ts +4 -1
- package/src/ui/lib/modal-layers.ts +547 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +4 -0
|
@@ -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'
|