@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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,99 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 25.2.0 — 2026-10-03
|
|
11
|
+
|
|
12
|
+
Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
|
|
13
|
+
especializada num cliente, num relatório ou num pedido passa a ler o recurso, conferir o acesso,
|
|
14
|
+
saber o procedimento da IA e escolher o dado ao modelo a partir de uma declaração só, no contrato:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
ai: true,
|
|
18
|
+
assistant: {
|
|
19
|
+
resource: 'customer',
|
|
20
|
+
skill: 'review-customer',
|
|
21
|
+
label: 'Assistente do cliente',
|
|
22
|
+
title: 'name',
|
|
23
|
+
context: ['kind', 'stage', 'leads.totalCount'],
|
|
24
|
+
},
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A action que declara passa a ser a action dona daquele tipo de recurso. Ela precisa ser `view`,
|
|
28
|
+
estar publicada para a IA, declarar `personalData` e receber só `{ id }`.
|
|
29
|
+
|
|
30
|
+
- `runtime.readResource(resource, id, base, { context })` executa a action dona pelo mesmo
|
|
31
|
+
`execute` de qualquer chamada, com a autorização, a auditoria e a marca de dado pessoal dela, e
|
|
32
|
+
devolve o descritor (`kind`, `id`, `title`, `label`, `skill`) e, com `context: true`, a projeção
|
|
33
|
+
limitada do dado. Um tipo sem dona devolve `runtime.assistant_resource_unknown` sem executar nada.
|
|
34
|
+
- `runtime.assistants()` lista as declarações com a action dona, para o produto montar rotas sem
|
|
35
|
+
lista paralela e conferir num teste que cada `skill` existe no registro dele.
|
|
36
|
+
- `projectAssistantContext(assistant, output)` devolve só os caminhos declarados e, acima do teto
|
|
37
|
+
(4.000 caracteres por padrão, de 1.000 a 16.000 em `maxContextChars`), corta por etapas até
|
|
38
|
+
caber, marcando `truncated` e listando o que saiu em `omitted`. Caminho ausente na saída é
|
|
39
|
+
omitido com o motivo `missing` e gera aviso no log, sem falhar a leitura.
|
|
40
|
+
- `resourceDataBlock`, de `@softize/opus/ai`, monta o bloco delimitado que o host entrega ao modelo
|
|
41
|
+
numa mensagem de dado, com os escapes que impedem um valor de fechar o delimitador.
|
|
42
|
+
- `runtime.aiTools()` leva `metadata.assistant`, e o `ListTools` do MCP publica
|
|
43
|
+
`com.softize.opus/assistant` em `_meta`.
|
|
44
|
+
- O registro recusa a declaração inválida (`runtime.assistant_declaration_invalid`), o teto fora do
|
|
45
|
+
intervalo (`runtime.assistant_context_limit_invalid`) e duas donas do mesmo recurso
|
|
46
|
+
(`runtime.assistant_duplicate_resource`). O `opus check` ganha a regra `assistant-declaration`, o
|
|
47
|
+
`opus gen` confere `title`, `context` e a entrada contra os schemas, e o `opus copy` inventaria
|
|
48
|
+
`assistant.label`.
|
|
49
|
+
- `@softize/opus/assistant-schema`, com tipos, exporta `assistantContextLeaves(output, context)`,
|
|
50
|
+
que lista, a partir do JSON Schema da saída no manifest, os campos folha que cada caminho de
|
|
51
|
+
`context` leva ao modelo. Um caminho que termina num objeto ou numa lista leva tudo abaixo dele; use a função num
|
|
52
|
+
teste para garantir que nenhum campo pessoal chega ao modelo por esse caminho.
|
|
53
|
+
|
|
54
|
+
O manifest passa a projetar, em toda action, a publicação para a IA em `ai` (`enabled`,
|
|
55
|
+
`description`, `destructive`, `requiresConfirmation`) e, na action dona, `assistant`. Se o seu
|
|
56
|
+
projeto versiona o manifest, rode `opus gen` depois da atualização: sem isso, a conferência de
|
|
57
|
+
frescor do manifest acusa a diferença. Nada muda para quem não declara assistente.
|
|
58
|
+
|
|
59
|
+
## 25.1.0 — 2026-10-03
|
|
60
|
+
|
|
61
|
+
`FloatingLayer`, de `@softize/opus/ui/react`, é uma camada que continua utilizável por cima de
|
|
62
|
+
qualquer `Dialog` ou `Drawer` modal, como o botão e o painel de um assistente (ADR 0052). Quem está
|
|
63
|
+
preenchendo um formulário em um modal pode usar a camada sem fechar o modal nem perder o que digitou.
|
|
64
|
+
Os filhos vão para um hospedeiro fixo no `body`, `div[data-slot="floating-layer"]`, e cada um se
|
|
65
|
+
posiciona contra a janela. Com um `Dialog` ou `Drawer` aberto:
|
|
66
|
+
|
|
67
|
+
- clicar na camada não fecha o modal, mesmo que o elemento clicado suma no próprio clique, e o véu
|
|
68
|
+
continua fechando, respeitando `closeDisabled`;
|
|
69
|
+
- o foco pode ir para a camada por clique ou por código sem ser puxado de volta, e o Tab passa do
|
|
70
|
+
último elemento do modal para a camada e dela de volta ao primeiro; quando o elemento focado da
|
|
71
|
+
camada some, o foco volta ao campo do modal em que a pessoa estava, e um modal que fecha com a
|
|
72
|
+
pessoa na camada não leva o foco ao gatilho dele;
|
|
73
|
+
- o Esc age onde a pessoa está: na camada, nenhum modal fecha e `onEscapeKeyDown` é chamado uma vez
|
|
74
|
+
por tecla; no modal, o modal fecha;
|
|
75
|
+
- a camada rola e continua visível ao leitor de tela, enquanto a página atrás segue travada e
|
|
76
|
+
escondida.
|
|
77
|
+
|
|
78
|
+
O hospedeiro publica o `Dialog` ou `Drawer` do topo em `data-modal` (`none`, `dialog` ou `drawer`) e o
|
|
79
|
+
lado do Drawer em `data-modal-side`, para o consumidor tirar o painel de cima do modal. O Opus não
|
|
80
|
+
desloca nem redimensiona modais. Os patterns construídos sobre `Dialog` e `Drawer`, como
|
|
81
|
+
`ActionFormDialog`, `ActionListDialog`, `Presentation` e `CommandDialog`, convivem com a camada sem
|
|
82
|
+
mudança.
|
|
83
|
+
|
|
84
|
+
Outras mudanças:
|
|
85
|
+
|
|
86
|
+
- Popover, Menu, Tooltip, Select, Dialog e Drawer abertos de dentro de uma `FloatingLayer` vão para o
|
|
87
|
+
hospedeiro dela, e não para o `body`; fora da camada, nada muda;
|
|
88
|
+
- `Dialog mode="alert"`, que também serve às confirmações do `DialogHost`, passa à faixa `z-70`,
|
|
89
|
+
acima da camada (`z-60`) e dos demais modais (`z-50`), e cobre a camada até a resposta, inclusive
|
|
90
|
+
enquanto um `Dialog` aberto de dentro dele estiver aberto. Popover, Menu e Tooltip do Opus abertos
|
|
91
|
+
de dentro de um alerta acompanham a faixa dele. Dois efeitos para conferir no seu código: um
|
|
92
|
+
elemento próprio com `z-index` entre 51 e 69, que antes ficava acima do alerta, agora fica abaixo
|
|
93
|
+
dele; e uma sobreposição de outra biblioteca com `z-50`, aberta de dentro de um alerta, fica
|
|
94
|
+
abaixo do véu dele. Durante a animação de saída, o alerta deixa de receber clique. A escala está
|
|
95
|
+
em `docs/stacking-scale.md`;
|
|
96
|
+
- `DialogPortal` e `MenuPortal` sem `container` usam o hospedeiro da camada quando abertos de
|
|
97
|
+
dentro dela;
|
|
98
|
+
- a `ref` de `DialogContent` e `DrawerContent` continua recebendo a limpeza das callback refs do
|
|
99
|
+
React 19, agora composta com a ref interna da camada;
|
|
100
|
+
- o pacote ganha testes em navegador real (Chromium, pelo modo navegador do Vitest com Playwright)
|
|
101
|
+
para o foco da camada: `pnpm test:browser` roda à parte do `pnpm test` e entra no gate de release.
|
|
102
|
+
|
|
10
103
|
## 25.0.0 — 2026-10-02
|
|
11
104
|
|
|
12
105
|
A exclusão lógica passa a declarar quando o registro excluído sai de vez (ADR 0051). `softDelete`
|
package/bin/cli.mjs
CHANGED
|
@@ -426,6 +426,11 @@ Regras das actions:
|
|
|
426
426
|
binding (o join contrato↔bind é cross-file)
|
|
427
427
|
ignored-by-kind — \`emits\` ou \`background\` em action list/view: o runtime
|
|
428
428
|
e o manifest só os aplicam em simple e form
|
|
429
|
+
assistant-declaration — o assistente do recurso (ADR 0053): só em view, com \`ai\`
|
|
430
|
+
habilitado, \`personalData\` declarado, \`resource\` em camelCase,
|
|
431
|
+
\`skill\` em minúsculas com hífen, \`label\`, \`title\` e
|
|
432
|
+
\`context\` presentes e \`maxContextChars\`, quando literal,
|
|
433
|
+
de 1.000 a 16.000; o mesmo \`resource\` em duas actions reprova
|
|
429
434
|
|
|
430
435
|
Regras da UI (@softize/opus/ui):
|
|
431
436
|
ui-structure — anatomia pública das superfícies compostas (ADR 0005):
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipos de `@softize/opus/assistant-schema` (ADR 0053): a conferência do assistente do recurso
|
|
3
|
+
* contra o JSON Schema da action, como o manifest o traz. A implementação é `assistant-schema.mjs`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Um JSON Schema como o `opus gen` o projeta no manifest. */
|
|
7
|
+
export type JsonSchema = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
/** Os campos que um caminho de `context` leva ao modelo. */
|
|
10
|
+
export interface AssistantContextLeaves {
|
|
11
|
+
/** O caminho declarado em `context`. */
|
|
12
|
+
path: string
|
|
13
|
+
/** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
|
|
14
|
+
leaves: string[]
|
|
15
|
+
/**
|
|
16
|
+
* Pontos em que o schema não declara forma ou volta a si mesmo por `$ref`: qualquer campo abaixo
|
|
17
|
+
* deles pode ir ao modelo, e a conferência não alcança.
|
|
18
|
+
*/
|
|
19
|
+
open: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Os campos folha que cada caminho de `context` leva ao modelo, a partir do JSON Schema da saída.
|
|
24
|
+
* Serve a um teste do consumidor que reprova campo pessoal alcançado por um caminho que termina
|
|
25
|
+
* num objeto ou numa lista.
|
|
26
|
+
*/
|
|
27
|
+
export function assistantContextLeaves(
|
|
28
|
+
output: JsonSchema,
|
|
29
|
+
context: readonly string[],
|
|
30
|
+
root?: JsonSchema,
|
|
31
|
+
): AssistantContextLeaves[]
|
|
32
|
+
|
|
33
|
+
/** O caminho existe em algum ramo não nulo do schema; listas são atravessadas. */
|
|
34
|
+
export function schemaHasPath(schema: JsonSchema, segments: readonly string[], root?: JsonSchema): boolean
|
|
35
|
+
|
|
36
|
+
/** O título resolve para texto ou número, fora de lista, em algum ramo não nulo. */
|
|
37
|
+
export function schemaTitleIsText(schema: JsonSchema, segments: readonly string[], root?: JsonSchema): boolean
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Os problemas do assistente diante dos schemas de entrada e de saída, como frases. Lista vazia
|
|
41
|
+
* quando a declaração confere.
|
|
42
|
+
*/
|
|
43
|
+
export function assistantSchemaIssues(args: {
|
|
44
|
+
assistant: { title?: unknown; context?: unknown }
|
|
45
|
+
input: unknown
|
|
46
|
+
output: unknown
|
|
47
|
+
}): string[]
|
|
48
|
+
|
|
49
|
+
/** O `ai` de uma action normalizado como o manifest o projeta. */
|
|
50
|
+
export function serializeAiConfig(ai: unknown): {
|
|
51
|
+
enabled: boolean
|
|
52
|
+
description: string | null
|
|
53
|
+
destructive: boolean
|
|
54
|
+
requiresConfirmation: boolean
|
|
55
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assistant-schema — confere o assistente do recurso (ADR 0053) contra o JSON Schema da action,
|
|
3
|
+
* no `opus gen`: `title` e cada caminho de `context` precisam existir na saída, e a entrada precisa
|
|
4
|
+
* ser um objeto em que `id`, do tipo texto, é o único campo obrigatório.
|
|
5
|
+
*
|
|
6
|
+
* Num campo anulável ou numa união (`anyOf`, `oneOf`, `allOf`), o caminho vale se existir em algum
|
|
7
|
+
* ramo que não seja `null`. Uma lista é atravessada: o caminho continua nos itens. Um schema sem
|
|
8
|
+
* forma declarada (`{}`, de `z.unknown()` ou `z.any()`) aceita qualquer caminho abaixo dele, porque
|
|
9
|
+
* não há o que conferir.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const COMBINATORS = ["anyOf", "oneOf", "allOf"];
|
|
13
|
+
const STRUCTURE_KEYS = ["type", "properties", "items", "additionalProperties", "enum", "const"];
|
|
14
|
+
const MAX_DEPTH = 64;
|
|
15
|
+
// Segmentos que mexem no protótipo de um objeto JavaScript; o registro do runtime também os recusa.
|
|
16
|
+
const RESERVED_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
17
|
+
|
|
18
|
+
function reservedSegments(path) {
|
|
19
|
+
return typeof path === "string" ? path.split(".").filter((segment) => RESERVED_SEGMENTS.has(segment)) : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveRef(root, ref) {
|
|
23
|
+
if (!ref.startsWith("#/")) return undefined;
|
|
24
|
+
let node = root;
|
|
25
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
26
|
+
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
27
|
+
if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) return undefined;
|
|
28
|
+
node = node[key];
|
|
29
|
+
}
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isNullOnly(schema) {
|
|
34
|
+
if (schema.type === "null") return true;
|
|
35
|
+
if (Array.isArray(schema.type) && schema.type.every((type) => type === "null")) return true;
|
|
36
|
+
// O alvo OpenAPI 3.0 do zod-to-json-schema escreve o ramo nulo como `{ enum: ['null'], nullable: true }`.
|
|
37
|
+
return (
|
|
38
|
+
Array.isArray(schema.enum) &&
|
|
39
|
+
schema.enum.length > 0 &&
|
|
40
|
+
schema.enum.every((value) => value === null || value === "null") &&
|
|
41
|
+
(schema.nullable === true || schema.enum.includes(null))
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NO_REFS = new Set();
|
|
46
|
+
/** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
|
|
47
|
+
const CYCLE = Object.freeze({ cycle: true });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
|
|
51
|
+
* os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
|
|
52
|
+
* expandir de novo.
|
|
53
|
+
*/
|
|
54
|
+
function expand(schema, root, active = NO_REFS, depth = 0) {
|
|
55
|
+
if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
|
|
56
|
+
if (typeof schema.$ref === "string") {
|
|
57
|
+
if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
|
|
58
|
+
const target = resolveRef(root, schema.$ref);
|
|
59
|
+
return target === undefined ? [] : expand(target, root, new Set([...active, schema.$ref]), depth + 1);
|
|
60
|
+
}
|
|
61
|
+
const nested = COMBINATORS.flatMap((key) =>
|
|
62
|
+
Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
|
|
63
|
+
);
|
|
64
|
+
const hasOwnStructure = STRUCTURE_KEYS.some((key) => Object.hasOwn(schema, key));
|
|
65
|
+
const combined = COMBINATORS.some((key) => Array.isArray(schema[key]));
|
|
66
|
+
if (combined && !hasOwnStructure) return nested;
|
|
67
|
+
if (isNullOnly(schema)) return nested;
|
|
68
|
+
return [{ schema, active }, ...nested];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Os ramos sem os ciclos, para quem só olha o tipo do próprio nó. */
|
|
72
|
+
function branches(schema, root) {
|
|
73
|
+
return expand(schema, root).flatMap(({ schema: branch }) => (branch === CYCLE ? [] : [branch]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isOpen(schema) {
|
|
77
|
+
return (
|
|
78
|
+
!Object.hasOwn(schema, "$ref") &&
|
|
79
|
+
!STRUCTURE_KEYS.some((key) => Object.hasOwn(schema, key)) &&
|
|
80
|
+
!COMBINATORS.some((key) => Object.hasOwn(schema, key))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isArraySchema(schema) {
|
|
85
|
+
return schema.type === "array" || (Array.isArray(schema.type) && schema.type.includes("array")) || Object.hasOwn(schema, "items");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
|
|
90
|
+
* qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
|
|
91
|
+
* ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
|
|
92
|
+
* por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
|
|
93
|
+
*/
|
|
94
|
+
function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
|
|
95
|
+
if (depth > MAX_DEPTH) return;
|
|
96
|
+
if (segments.length === 0) {
|
|
97
|
+
out.push(schema);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
101
|
+
if (branch === CYCLE) continue;
|
|
102
|
+
if (isOpen(branch)) {
|
|
103
|
+
out.push(branch);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (isArraySchema(branch)) {
|
|
107
|
+
if (!throughLists) continue;
|
|
108
|
+
const items = Array.isArray(branch.items) ? branch.items : [branch.items];
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
if (item !== undefined) collectTargets(item, segments, root, throughLists, out, refs, depth + 1);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const [head, ...rest] = segments;
|
|
115
|
+
const properties = branch.properties;
|
|
116
|
+
if (properties !== null && typeof properties === "object" && Object.hasOwn(properties, head)) {
|
|
117
|
+
collectTargets(properties[head], rest, root, throughLists, out, NO_REFS, depth + 1);
|
|
118
|
+
}
|
|
119
|
+
const additional = branch.additionalProperties;
|
|
120
|
+
if (additional !== null && typeof additional === "object") {
|
|
121
|
+
collectTargets(additional, rest, root, throughLists, out, NO_REFS, depth + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** O caminho `segments` existe em algum ramo não nulo de `schema`. */
|
|
127
|
+
export function schemaHasPath(schema, segments, root = schema) {
|
|
128
|
+
const targets = [];
|
|
129
|
+
collectTargets(schema, segments, root, true, targets);
|
|
130
|
+
return targets.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const TITLE_TYPES = new Set(["string", "number", "integer"]);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* O título resolve para texto ou número em algum ramo não nulo, sem atravessar lista: é o que o
|
|
137
|
+
* runtime consegue mostrar como nome do recurso.
|
|
138
|
+
*/
|
|
139
|
+
export function schemaTitleIsText(schema, segments, root = schema) {
|
|
140
|
+
const targets = [];
|
|
141
|
+
collectTargets(schema, segments, root, false, targets);
|
|
142
|
+
return targets.some((target) =>
|
|
143
|
+
isOpen(target) ||
|
|
144
|
+
branches(target, root).some((branch) => {
|
|
145
|
+
if (isOpen(branch)) return true;
|
|
146
|
+
const types = Array.isArray(branch.type) ? branch.type : [branch.type];
|
|
147
|
+
return types.some((type) => TITLE_TYPES.has(type));
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* As folhas abaixo de `schema`. Um `$ref` que volta a um schema já no caminho atual (uma estrutura
|
|
154
|
+
* recursiva) entra em `open` naquele ponto: abaixo dele, qualquer profundidade pode chegar ao modelo.
|
|
155
|
+
*/
|
|
156
|
+
function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
|
|
157
|
+
if (depth > MAX_DEPTH) {
|
|
158
|
+
open.add(prefix);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
162
|
+
if (branch === CYCLE || isOpen(branch)) {
|
|
163
|
+
open.add(prefix);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (isArraySchema(branch)) {
|
|
167
|
+
const items = Array.isArray(branch.items) ? branch.items : [branch.items];
|
|
168
|
+
for (const item of items) if (item !== undefined) collectLeaves(item, prefix, root, leaves, open, refs, depth + 1);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const properties = branch.properties;
|
|
172
|
+
const hasProperties = properties !== null && typeof properties === "object" && Object.keys(properties).length > 0;
|
|
173
|
+
if (hasProperties) {
|
|
174
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
175
|
+
collectLeaves(child, `${prefix}.${key}`, root, leaves, open, refs, depth + 1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const additional = branch.additionalProperties;
|
|
179
|
+
if (additional !== null && typeof additional === "object") {
|
|
180
|
+
collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
|
|
181
|
+
}
|
|
182
|
+
if (!hasProperties && !(additional !== null && typeof additional === "object")) leaves.add(prefix);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Os campos folha que cada caminho de `context` leva ao modelo, segundo o JSON Schema da saída
|
|
188
|
+
* (ADR 0053). Um caminho que termina num objeto ou numa lista projeta tudo abaixo dele; esta função
|
|
189
|
+
* lista o que é, para o consumidor conferir num teste, por exemplo, que nenhum campo pessoal chega
|
|
190
|
+
* ao modelo por um caminho que termina num contêiner.
|
|
191
|
+
*
|
|
192
|
+
* - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
|
|
193
|
+
* - `open`: pontos em que o schema não declara forma (`z.unknown()`, `z.any()`) ou volta a si mesmo
|
|
194
|
+
* por `$ref`: qualquer campo abaixo deles pode ir ao modelo, e a conferência não alcança.
|
|
195
|
+
*/
|
|
196
|
+
export function assistantContextLeaves(output, context, root = output) {
|
|
197
|
+
return context.map((path) => {
|
|
198
|
+
const targets = [];
|
|
199
|
+
collectTargets(output, path.split("."), root, true, targets);
|
|
200
|
+
const leaves = new Set();
|
|
201
|
+
const open = new Set();
|
|
202
|
+
for (const target of targets) collectLeaves(target, path, root, leaves, open);
|
|
203
|
+
return { path, leaves: [...leaves].sort(), open: [...open].sort() };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isText(schema, root) {
|
|
208
|
+
return branches(schema, root).some(
|
|
209
|
+
(branch) => branch.type === "string" || (Array.isArray(branch.type) && branch.type.includes("string")),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function unserializable(schema) {
|
|
214
|
+
return schema === null || schema === undefined || schema.__unserializable === true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Os problemas do assistente de uma action diante dos JSON Schemas de entrada e de saída, como
|
|
219
|
+
* frases. Lista vazia quando a declaração cabe nos schemas.
|
|
220
|
+
*/
|
|
221
|
+
export function assistantSchemaIssues({ assistant, input, output }) {
|
|
222
|
+
const issues = [];
|
|
223
|
+
if (unserializable(input)) {
|
|
224
|
+
issues.push("o schema de entrada não pôde ser convertido em JSON Schema para conferir o `id`");
|
|
225
|
+
} else {
|
|
226
|
+
const objects = branches(input, input).filter(
|
|
227
|
+
(branch) => branch.type === "object" || Object.hasOwn(branch, "properties"),
|
|
228
|
+
);
|
|
229
|
+
if (objects.length !== 1) {
|
|
230
|
+
issues.push("a entrada precisa ser um objeto com `id`, do tipo texto, como único campo obrigatório");
|
|
231
|
+
} else {
|
|
232
|
+
const [object] = objects;
|
|
233
|
+
const required = Array.isArray(object.required) ? object.required : [];
|
|
234
|
+
const id = object.properties?.id;
|
|
235
|
+
if (id === undefined || !required.includes("id")) {
|
|
236
|
+
issues.push("a entrada precisa ter `id` obrigatório");
|
|
237
|
+
} else if (!isText(id, input)) {
|
|
238
|
+
issues.push("o `id` da entrada precisa ser do tipo texto");
|
|
239
|
+
}
|
|
240
|
+
const others = required.filter((key) => key !== "id");
|
|
241
|
+
if (others.length > 0) {
|
|
242
|
+
issues.push(
|
|
243
|
+
`a entrada só pode exigir \`id\`, mas também exige ${others.map((key) => `\`${key}\``).join(", ")}; dê um valor padrão a esses campos ou torne-os opcionais`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const reserved = [assistant.title, ...(Array.isArray(assistant.context) ? assistant.context : [])].filter(
|
|
249
|
+
(path) => reservedSegments(path).length > 0,
|
|
250
|
+
);
|
|
251
|
+
if (reserved.length > 0) {
|
|
252
|
+
issues.push(
|
|
253
|
+
`${reserved.map((path) => `\`${path}\``).join(", ")} usa __proto__, constructor ou prototype, que não são campos da saída`,
|
|
254
|
+
);
|
|
255
|
+
return issues;
|
|
256
|
+
}
|
|
257
|
+
if (unserializable(output)) {
|
|
258
|
+
issues.push("o schema de saída não pôde ser convertido em JSON Schema para conferir `title` e `context`");
|
|
259
|
+
return issues;
|
|
260
|
+
}
|
|
261
|
+
const missing = (path) => typeof path !== "string" || !schemaHasPath(output, path.split("."));
|
|
262
|
+
if (missing(assistant.title)) {
|
|
263
|
+
issues.push(`\`${assistant.title}\`, de \`title\`, não existe na saída`);
|
|
264
|
+
} else if (!schemaTitleIsText(output, assistant.title.split("."))) {
|
|
265
|
+
issues.push(
|
|
266
|
+
`\`${assistant.title}\`, de \`title\`, precisa ser texto ou número, fora de lista, para nomear o recurso`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const absent = (Array.isArray(assistant.context) ? assistant.context : []).filter(missing);
|
|
270
|
+
if (absent.length > 0) {
|
|
271
|
+
const list = absent.map((path) => `\`${path}\``).join(", ");
|
|
272
|
+
issues.push(`${list}, de \`context\`, ${absent.length === 1 ? "não existe" : "não existem"} na saída`);
|
|
273
|
+
}
|
|
274
|
+
return issues;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** O `ai` da action normalizado para o manifest. */
|
|
278
|
+
export function serializeAiConfig(ai) {
|
|
279
|
+
const config = ai === true ? { enabled: true } : ai !== null && typeof ai === "object" ? ai : null;
|
|
280
|
+
return {
|
|
281
|
+
enabled: config !== null && config.enabled !== false,
|
|
282
|
+
description: typeof config?.description === "string" && config.description.length > 0 ? config.description : null,
|
|
283
|
+
destructive: config?.destructive === true,
|
|
284
|
+
requiresConfirmation: config?.requiresConfirmation === true,
|
|
285
|
+
};
|
|
286
|
+
}
|