@softize/opus 25.1.0 → 25.3.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 +66 -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 +4 -0
- package/bin/lib/gen-runner.mjs +31 -2
- package/bin/lib/gen-stubs.mjs +3 -0
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
- package/docs/protocol.md +59 -1
- package/package.json +5 -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/actions.ts +5 -2
- package/src/core/assistant.ts +545 -0
- package/src/core/contracts.ts +2 -0
- package/src/core/index.ts +23 -0
- package/src/core/list-grouping.ts +45 -0
- package/src/core/runtime.ts +138 -0
- package/src/core/types.ts +39 -0
- package/src/mcp/index.ts +3 -0
- package/src/ui/components/patterns/grouped-list.ts +88 -0
- package/src/ui/components/patterns/list.tsx +395 -61
- package/src/ui/docs/content/action-list.md +42 -9
- 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/mcp.md +3 -1
- package/src/ui/drivers/context.tsx +50 -0
- package/src/ui/drivers/react.tsx +9 -69
- package/src/ui/meta.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ 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.3.0 — 2026-10-05
|
|
11
|
+
|
|
12
|
+
`ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
|
|
13
|
+
Cada dimensão referencia um filtro `select` com até 12 opções estáticas ou de dicionário, e a
|
|
14
|
+
action precisa usar `paginate: 'offset'` e devolver `total`. A pessoa escolhe o agrupamento em
|
|
15
|
+
**Exibição**; cada grupo consulta o servidor separadamente, pode ser recolhido e carrega páginas
|
|
16
|
+
adicionais sem descartar as anteriores. O agrupamento não altera views alternativas nem o modo de
|
|
17
|
+
composição por `children`.
|
|
18
|
+
|
|
19
|
+
O manifest e os stubs passam a projetar `grouping`. `ActionListState` e os helpers de URL preservam
|
|
20
|
+
a dimensão em `group`; dimensões desconhecidas são ignoradas. A implementação das consultas por
|
|
21
|
+
grupo permanece interna à `ActionList`, sem introduzir um hook público adicional.
|
|
22
|
+
|
|
23
|
+
`useListAction(action, input, { enabled })` pode suspender a consulta declarativa sem desmontar o
|
|
24
|
+
hook. O padrão é `true`; a `ActionList` usa essa opção internamente ao alternar entre a consulta
|
|
25
|
+
plana e as consultas agrupadas.
|
|
26
|
+
|
|
27
|
+
## 25.2.0 — 2026-10-03
|
|
28
|
+
|
|
29
|
+
Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
|
|
30
|
+
especializada num cliente, num relatório ou num pedido passa a ler o recurso, conferir o acesso,
|
|
31
|
+
saber o procedimento da IA e escolher o dado ao modelo a partir de uma declaração só, no contrato:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
ai: true,
|
|
35
|
+
assistant: {
|
|
36
|
+
resource: 'customer',
|
|
37
|
+
skill: 'review-customer',
|
|
38
|
+
label: 'Assistente do cliente',
|
|
39
|
+
title: 'name',
|
|
40
|
+
context: ['kind', 'stage', 'leads.totalCount'],
|
|
41
|
+
},
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A action que declara passa a ser a action dona daquele tipo de recurso. Ela precisa ser `view`,
|
|
45
|
+
estar publicada para a IA, declarar `personalData` e receber só `{ id }`.
|
|
46
|
+
|
|
47
|
+
- `runtime.readResource(resource, id, base, { context })` executa a action dona pelo mesmo
|
|
48
|
+
`execute` de qualquer chamada, com a autorização, a auditoria e a marca de dado pessoal dela, e
|
|
49
|
+
devolve o descritor (`kind`, `id`, `title`, `label`, `skill`) e, com `context: true`, a projeção
|
|
50
|
+
limitada do dado. Um tipo sem dona devolve `runtime.assistant_resource_unknown` sem executar nada.
|
|
51
|
+
- `runtime.assistants()` lista as declarações com a action dona, para o produto montar rotas sem
|
|
52
|
+
lista paralela e conferir num teste que cada `skill` existe no registro dele.
|
|
53
|
+
- `projectAssistantContext(assistant, output)` devolve só os caminhos declarados e, acima do teto
|
|
54
|
+
(4.000 caracteres por padrão, de 1.000 a 16.000 em `maxContextChars`), corta por etapas até
|
|
55
|
+
caber, marcando `truncated` e listando o que saiu em `omitted`. Caminho ausente na saída é
|
|
56
|
+
omitido com o motivo `missing` e gera aviso no log, sem falhar a leitura.
|
|
57
|
+
- `resourceDataBlock`, de `@softize/opus/ai`, monta o bloco delimitado que o host entrega ao modelo
|
|
58
|
+
numa mensagem de dado, com os escapes que impedem um valor de fechar o delimitador.
|
|
59
|
+
- `runtime.aiTools()` leva `metadata.assistant`, e o `ListTools` do MCP publica
|
|
60
|
+
`com.softize.opus/assistant` em `_meta`.
|
|
61
|
+
- O registro recusa a declaração inválida (`runtime.assistant_declaration_invalid`), o teto fora do
|
|
62
|
+
intervalo (`runtime.assistant_context_limit_invalid`) e duas donas do mesmo recurso
|
|
63
|
+
(`runtime.assistant_duplicate_resource`). O `opus check` ganha a regra `assistant-declaration`, o
|
|
64
|
+
`opus gen` confere `title`, `context` e a entrada contra os schemas, e o `opus copy` inventaria
|
|
65
|
+
`assistant.label`.
|
|
66
|
+
- `@softize/opus/assistant-schema`, com tipos, exporta `assistantContextLeaves(output, context)`,
|
|
67
|
+
que lista, a partir do JSON Schema da saída no manifest, os campos folha que cada caminho de
|
|
68
|
+
`context` leva ao modelo. Um caminho que termina num objeto ou numa lista leva tudo abaixo dele; use a função num
|
|
69
|
+
teste para garantir que nenhum campo pessoal chega ao modelo por esse caminho.
|
|
70
|
+
|
|
71
|
+
O manifest passa a projetar, em toda action, a publicação para a IA em `ai` (`enabled`,
|
|
72
|
+
`description`, `destructive`, `requiresConfirmation`) e, na action dona, `assistant`. Se o seu
|
|
73
|
+
projeto versiona o manifest, rode `opus gen` depois da atualização: sem isso, a conferência de
|
|
74
|
+
frescor do manifest acusa a diferença. Nada muda para quem não declara assistente.
|
|
75
|
+
|
|
10
76
|
## 25.1.0 — 2026-10-03
|
|
11
77
|
|
|
12
78
|
`FloatingLayer`, de `@softize/opus/ui/react`, é uma camada que continua utilizável por cima de
|
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
|
+
}
|
package/bin/lib/check.mjs
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* identificador exportado; um `entities` ou `purges` que o check não
|
|
25
25
|
* consegue ler (spread, variável) suspende a regra de ausência
|
|
26
26
|
* correspondente, sem falso positivo.
|
|
27
|
+
* assistant-declaration — o assistente do recurso (ADR 0053): só em action `view`, com `ai`
|
|
28
|
+
* habilitado, `personalData` declarado, `resource` em camelCase, `skill`
|
|
29
|
+
* em minúsculas com hífen, `label`, `title` e `context` presentes e
|
|
30
|
+
* `maxContextChars` literal de 1.000 a 16.000. O
|
|
31
|
+
* mesmo `resource` literal em duas actions, entre arquivos, reprova. Um
|
|
32
|
+
* `assistant` ou `ai` que não é literal suspende o que depende dele.
|
|
27
33
|
*
|
|
28
34
|
* A checagem cross-file de registro no runtime (graph) fica pra v2 (introspecção/MCP).
|
|
29
35
|
* Saída: findings + exit ≠ 0 se houver erro. Zero actions num diretório NÃO-marcado
|
|
@@ -73,6 +79,7 @@ const FIELD_GROUP = {
|
|
|
73
79
|
rateLimit: 5,
|
|
74
80
|
idempotency: 5,
|
|
75
81
|
ai: 6,
|
|
82
|
+
assistant: 6,
|
|
76
83
|
automatable: 6,
|
|
77
84
|
internal: 6,
|
|
78
85
|
};
|
|
@@ -82,6 +89,17 @@ const FIELD_GROUP = {
|
|
|
82
89
|
const SEG = "[a-z][a-z0-9]*(-[a-z0-9]+)*";
|
|
83
90
|
const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`);
|
|
84
91
|
const KINDS = new Set(["simple", "form", "list", "view"]);
|
|
92
|
+
// O assistente do recurso (ADR 0053): o mesmo formato que o registro do runtime confere.
|
|
93
|
+
const ASSISTANT_RESOURCE_RE = /^[a-z][a-zA-Z0-9]*$/;
|
|
94
|
+
const ASSISTANT_SKILL_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
95
|
+
const ASSISTANT_CONTEXT_MIN = 1000;
|
|
96
|
+
const ASSISTANT_RESERVED_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
97
|
+
const ASSISTANT_CONTEXT_MAX = 16000;
|
|
98
|
+
const ASSISTANT_REQUIRED = {
|
|
99
|
+
label: "`label`, o nome do assistente para pessoas",
|
|
100
|
+
title: "`title`, o caminho do campo que nomeia o recurso",
|
|
101
|
+
context: "`context`, os caminhos dos campos que vão ao modelo",
|
|
102
|
+
};
|
|
85
103
|
// O runtime e o manifest só aplicam estes campos em `simple` e `form`; o tipo do binding os aceita
|
|
86
104
|
// em qualquer kind, então a declaração num list/view compila e não tem efeito.
|
|
87
105
|
const EXECUTION_ONLY_FIELDS = ["emits", "background"];
|
|
@@ -977,6 +995,73 @@ export function parseActions(fileName, sourceText) {
|
|
|
977
995
|
.filter(Boolean);
|
|
978
996
|
}
|
|
979
997
|
|
|
998
|
+
function prop(obj, key) {
|
|
999
|
+
return obj.properties.find(
|
|
1000
|
+
(pr) =>
|
|
1001
|
+
ts.isPropertyAssignment(pr) && pr.name && pr.name.getText(sf) === key,
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** `ai` literal → "enabled" | "disabled"; ausente → null; não literal → "unknown". */
|
|
1006
|
+
function aiState(obj) {
|
|
1007
|
+
const p = prop(obj, "ai");
|
|
1008
|
+
if (!p) return obj.properties.some((pr) => pr.name?.getText(sf) === "ai") ? "unknown" : null;
|
|
1009
|
+
const value = p.initializer;
|
|
1010
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) return "enabled";
|
|
1011
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) return "disabled";
|
|
1012
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
1013
|
+
const enabled = prop(value, "enabled");
|
|
1014
|
+
if (!enabled) return "enabled";
|
|
1015
|
+
if (enabled.initializer.kind === ts.SyntaxKind.FalseKeyword) return "disabled";
|
|
1016
|
+
if (enabled.initializer.kind === ts.SyntaxKind.TrueKeyword) return "enabled";
|
|
1017
|
+
}
|
|
1018
|
+
return "unknown";
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** O `assistant` literal → { readable, resource, skill, keys }; ausente → null. */
|
|
1022
|
+
function assistantDeclaration(obj) {
|
|
1023
|
+
const p = prop(obj, "assistant");
|
|
1024
|
+
if (!p) return null;
|
|
1025
|
+
if (!ts.isObjectLiteralExpression(p.initializer))
|
|
1026
|
+
return { readable: false, line: lineOf(p) };
|
|
1027
|
+
const value = p.initializer;
|
|
1028
|
+
const literal = (key) => {
|
|
1029
|
+
const found = prop(value, key);
|
|
1030
|
+
if (!found) return { present: objKeys(value).includes(key), text: null };
|
|
1031
|
+
return {
|
|
1032
|
+
present: true,
|
|
1033
|
+
text: ts.isStringLiteralLike(found.initializer) ? found.initializer.text : null,
|
|
1034
|
+
};
|
|
1035
|
+
};
|
|
1036
|
+
const limit = prop(value, "maxContextChars");
|
|
1037
|
+
let maxContextChars = null;
|
|
1038
|
+
if (limit) {
|
|
1039
|
+
const node = limit.initializer;
|
|
1040
|
+
if (ts.isNumericLiteral(node)) maxContextChars = Number(node.text.replace(/_/g, ""));
|
|
1041
|
+
else if (
|
|
1042
|
+
ts.isPrefixUnaryExpression(node) &&
|
|
1043
|
+
node.operator === ts.SyntaxKind.MinusToken &&
|
|
1044
|
+
ts.isNumericLiteral(node.operand)
|
|
1045
|
+
)
|
|
1046
|
+
maxContextChars = -Number(node.operand.text.replace(/_/g, ""));
|
|
1047
|
+
}
|
|
1048
|
+
const contextNode = prop(value, "context");
|
|
1049
|
+
const contextPaths =
|
|
1050
|
+
contextNode && ts.isArrayLiteralExpression(contextNode.initializer)
|
|
1051
|
+
? contextNode.initializer.elements.filter(ts.isStringLiteralLike).map((element) => element.text)
|
|
1052
|
+
: [];
|
|
1053
|
+
return {
|
|
1054
|
+
readable: true,
|
|
1055
|
+
line: lineOf(p),
|
|
1056
|
+
resource: literal("resource"),
|
|
1057
|
+
skill: literal("skill"),
|
|
1058
|
+
title: literal("title"),
|
|
1059
|
+
contextPaths,
|
|
1060
|
+
maxContextChars,
|
|
1061
|
+
keys: objKeys(value),
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
980
1065
|
function visit(node) {
|
|
981
1066
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
982
1067
|
const callee = node.expression.text;
|
|
@@ -992,6 +1077,8 @@ export function parseActions(fileName, sourceText) {
|
|
|
992
1077
|
name: strProp(obj, "name"),
|
|
993
1078
|
kind: strProp(obj, "kind"),
|
|
994
1079
|
effect: strProp(obj, "effect"),
|
|
1080
|
+
ai: aiState(obj),
|
|
1081
|
+
assistant: assistantDeclaration(obj),
|
|
995
1082
|
keys: objKeys(obj),
|
|
996
1083
|
ident: constIdent(node),
|
|
997
1084
|
exported: isExported(node),
|
|
@@ -1023,6 +1110,61 @@ export function parseActions(fileName, sourceText) {
|
|
|
1023
1110
|
return actions;
|
|
1024
1111
|
}
|
|
1025
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Regra `assistant-declaration` (ADR 0053) sobre um item: a forma do assistente do recurso. O
|
|
1115
|
+
* `resource` repetido entre actions é conferido no `checkProject`.
|
|
1116
|
+
*/
|
|
1117
|
+
function assistantMessages(a) {
|
|
1118
|
+
const assistant = a.assistant;
|
|
1119
|
+
if (assistant === null || assistant === undefined) return [];
|
|
1120
|
+
const out = [];
|
|
1121
|
+
if (a.kind !== null && a.kind !== "view")
|
|
1122
|
+
out.push(
|
|
1123
|
+
`Só uma action view declara assistente, e esta é ${a.kind}. A leitura de um recurso identificado é o papel da view.`,
|
|
1124
|
+
);
|
|
1125
|
+
if (a.ai === null || a.ai === "disabled")
|
|
1126
|
+
out.push(
|
|
1127
|
+
"A action dona precisa estar publicada para a IA: declare `ai: true` ou `ai` sem `enabled: false`.",
|
|
1128
|
+
);
|
|
1129
|
+
if (!a.keys.includes("personalData"))
|
|
1130
|
+
out.push(
|
|
1131
|
+
"A action dona precisa declarar `personalData`, `true` ou `false`: a declaração decide como o host trata o dado que entrega ao modelo.",
|
|
1132
|
+
);
|
|
1133
|
+
if (!assistant.readable) return out;
|
|
1134
|
+
const { resource, skill } = assistant;
|
|
1135
|
+
if (!resource.present)
|
|
1136
|
+
out.push("O assistente precisa declarar `resource`, o tipo de recurso de que a action é dona.");
|
|
1137
|
+
else if (resource.text !== null && !ASSISTANT_RESOURCE_RE.test(resource.text))
|
|
1138
|
+
out.push(`\`resource: "${resource.text}"\` precisa estar em camelCase, como \`customer\` ou \`aftersaleOpportunity\`.`);
|
|
1139
|
+
if (!skill.present)
|
|
1140
|
+
out.push("O assistente precisa declarar `skill`, a chave do procedimento que a IA segue.");
|
|
1141
|
+
else if (skill.text !== null && !ASSISTANT_SKILL_RE.test(skill.text))
|
|
1142
|
+
out.push(`\`skill: "${skill.text}"\` precisa estar em minúsculas com hífen, como \`review-customer\`.`);
|
|
1143
|
+
const reserved = [assistant.title.text, ...assistant.contextPaths].filter(
|
|
1144
|
+
(path) =>
|
|
1145
|
+
typeof path === "string" &&
|
|
1146
|
+
path.split(".").some((segment) => ASSISTANT_RESERVED_SEGMENTS.has(segment)),
|
|
1147
|
+
);
|
|
1148
|
+
if (reserved.length > 0)
|
|
1149
|
+
out.push(
|
|
1150
|
+
`${reserved.map((path) => `\`${path}\``).join(", ")} usa __proto__, constructor ou prototype, que não são campos da saída.`,
|
|
1151
|
+
);
|
|
1152
|
+
const limit = assistant.maxContextChars;
|
|
1153
|
+
if (
|
|
1154
|
+
limit !== null &&
|
|
1155
|
+
(!Number.isInteger(limit) || limit < ASSISTANT_CONTEXT_MIN || limit > ASSISTANT_CONTEXT_MAX)
|
|
1156
|
+
)
|
|
1157
|
+
out.push(
|
|
1158
|
+
`\`maxContextChars: ${limit}\` precisa ser um inteiro de 1.000 a 16.000; sem ele, o teto é de 4.000 caracteres.`,
|
|
1159
|
+
);
|
|
1160
|
+
const missing = Object.keys(ASSISTANT_REQUIRED).filter((key) => !assistant.keys.includes(key));
|
|
1161
|
+
if (missing.length > 0)
|
|
1162
|
+
out.push(
|
|
1163
|
+
`O assistente precisa declarar ${missing.map((key) => ASSISTANT_REQUIRED[key]).join("; ")}.`,
|
|
1164
|
+
);
|
|
1165
|
+
return out;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1026
1168
|
/** Aplica as regras locais (por arquivo) a um item → findings. */
|
|
1027
1169
|
export function lintAction(a) {
|
|
1028
1170
|
const out = [];
|
|
@@ -1088,6 +1230,15 @@ export function lintAction(a) {
|
|
|
1088
1230
|
const ignored = ignoredByKindMessage(a.kind, a.keys);
|
|
1089
1231
|
if (ignored !== null) at("ignored-by-kind", ignored);
|
|
1090
1232
|
|
|
1233
|
+
for (const message of assistantMessages(a))
|
|
1234
|
+
out.push({
|
|
1235
|
+
rule: "assistant-declaration",
|
|
1236
|
+
level: "error",
|
|
1237
|
+
action: label,
|
|
1238
|
+
line: a.assistant.line,
|
|
1239
|
+
message,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1091
1242
|
if (
|
|
1092
1243
|
a.form === "defineAction" &&
|
|
1093
1244
|
a.keys.includes("requires") &&
|
|
@@ -1126,6 +1277,7 @@ export function checkProject(sources) {
|
|
|
1126
1277
|
const entityNames = new Set();
|
|
1127
1278
|
const actionNames = new Set();
|
|
1128
1279
|
const retention = [];
|
|
1280
|
+
const assistantResources = new Map(); // resource literal → [{ item, file }]
|
|
1129
1281
|
// Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
|
|
1130
1282
|
let missingEffect = 0;
|
|
1131
1283
|
let missingPersonalData = 0;
|
|
@@ -1159,6 +1311,12 @@ export function checkProject(sources) {
|
|
|
1159
1311
|
if (item.name !== null) actionNames.add(item.name);
|
|
1160
1312
|
}
|
|
1161
1313
|
for (const f of lintAction(item)) findings.push({ ...f, file });
|
|
1314
|
+
const resource = item.assistant?.resource?.text ?? null;
|
|
1315
|
+
if (resource !== null) {
|
|
1316
|
+
const owners = assistantResources.get(resource) ?? [];
|
|
1317
|
+
owners.push({ item, file });
|
|
1318
|
+
assistantResources.set(resource, owners);
|
|
1319
|
+
}
|
|
1162
1320
|
if (item.form !== "bindAction") {
|
|
1163
1321
|
const writesByGet = item.kind === "list" || item.kind === "view";
|
|
1164
1322
|
if (!writesByGet && !item.keys.includes("effect")) missingEffect += 1;
|
|
@@ -1265,6 +1423,21 @@ export function checkProject(sources) {
|
|
|
1265
1423
|
}
|
|
1266
1424
|
}
|
|
1267
1425
|
|
|
1426
|
+
for (const [resource, owners] of assistantResources) {
|
|
1427
|
+
if (owners.length < 2) continue;
|
|
1428
|
+
const names = owners.map(({ item }) => item.name ?? item.ident ?? "(sem name)");
|
|
1429
|
+
for (const { item, file } of owners) {
|
|
1430
|
+
findings.push({
|
|
1431
|
+
rule: "assistant-declaration",
|
|
1432
|
+
level: "error",
|
|
1433
|
+
action: item.name ?? item.ident ?? "(sem name)",
|
|
1434
|
+
line: item.assistant.line,
|
|
1435
|
+
file,
|
|
1436
|
+
message: `O recurso "${resource}" tem mais de uma action dona (${names.join(", ")}). Cada tipo de recurso tem uma única action dona.`,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1268
1441
|
findings.push(...checkRetention(retention));
|
|
1269
1442
|
|
|
1270
1443
|
return {
|
package/bin/lib/copy.mjs
CHANGED
|
@@ -1540,6 +1540,8 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1540
1540
|
addProperty(example, 'description', 'description', prefix)
|
|
1541
1541
|
})
|
|
1542
1542
|
nestedObject(object, 'errors', (error, prefix) => addProperty(error, 'description', 'error', prefix))
|
|
1543
|
+
// O nome do assistente do recurso (ADR 0053), que a interface mostra como título do chat.
|
|
1544
|
+
nestedObject(object, 'assistant', (assistant, prefix) => addProperty(assistant, 'label', 'title', prefix))
|
|
1543
1545
|
}
|
|
1544
1546
|
|
|
1545
1547
|
function extractPresentation(object) {
|