@softize/opus 15.1.0 → 15.2.1
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 +27 -0
- package/README.md +3 -3
- package/bin/lib/check.mjs +92 -2
- package/bin/lib/copy.mjs +11 -2
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +37 -0
- package/bin/lib/introspect.mjs +16 -4
- package/docs/adr/0012-data-products-are-first-class-declarations.md +72 -0
- package/docs/data-products.md +66 -0
- package/docs/protocol.md +12 -0
- package/package.json +15 -14
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +0 -0
- package/src/core/data-product.ts +121 -0
- package/src/core/domain.ts +56 -2
- package/src/core/index.ts +9 -0
- package/src/core/runtime.ts +27 -2
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/primitives/chat.tsx +58 -14
- package/src/ui/docs/content/chat.md +6 -0
- package/src/ui/react.tsx +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ 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
|
+
## 15.2.1 — 2026-09-11
|
|
11
|
+
|
|
12
|
+
`opus copy` agora poda diretórios cobertos por um glob recursivo de `copy.exclude`, em vez de
|
|
13
|
+
percorrer toda a árvore para só então descartar seus arquivos. Isso evita custo desnecessário e
|
|
14
|
+
travamentos aparentes em repositórios que mantêm worktrees ou artefatos volumosos dentro da raiz.
|
|
15
|
+
|
|
16
|
+
**Migração:** nenhuma. Projetos com diretórios locais volumosos devem declará-los em
|
|
17
|
+
`copy.exclude` com um glob recursivo, como `.worktrees/**`.
|
|
18
|
+
|
|
19
|
+
## 15.2.0 — 2026-09-11
|
|
20
|
+
|
|
21
|
+
Produtos de Dados passam a ser declarações de primeira classe com `defineDataProduct` e registro
|
|
22
|
+
em `defineDomain({ dataProducts })`. Identidade, versão, responsável, grão, classificação,
|
|
23
|
+
natureza, Fontes, entities, acesso descritivo, interfaces e ciclo de vida seguem para o manifest.
|
|
24
|
+
O domínio e `opus check` detectam IDs duplicados e referências inexistentes. Actions oferecidas à
|
|
25
|
+
IA carregam os produtos que expõem; MCP projeta a relação em
|
|
26
|
+
`_meta['com.softize.opus/data-products']`. Autorização e recorte continuam obrigatoriamente nas
|
|
27
|
+
Actions — metadata de produto não abre acesso.
|
|
28
|
+
|
|
29
|
+
O `<Chat>` aceita `id` e `createdAt` nos itens, gera esses campos para mensagens que administra e
|
|
30
|
+
oferece `renderMessageActions`. O slot aparece em hover ou foco e permite que o app carregue ações
|
|
31
|
+
e detalhes contextuais sem acoplar o componente a uma implementação de observabilidade.
|
|
32
|
+
|
|
33
|
+
**Migração:** nenhuma para declarações existentes. Para catalogar um produto, declare-o, registre-o
|
|
34
|
+
no domínio e mantenha todas as Actions citadas em `interfaces` protegidas. Históricos antigos sem
|
|
35
|
+
`id` ou `createdAt` continuam renderizando.
|
|
36
|
+
|
|
10
37
|
## 15.1.0 — 2026-09-10
|
|
11
38
|
|
|
12
39
|
`PageShell` passa a coordenar o chrome persistente quando shell e rota conhecem partes diferentes da
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Protocolo de actions de ponta a ponta para TypeScript. Declare uma vez — input
|
|
|
15
15
|
- **Contrato, não feature.** Tudo que entra no core padroniza forma; tudo que faz trabalho usa lib externa.
|
|
16
16
|
- **Fail-closed por default.** Action sem `authorize` é negada. Audit default-on.
|
|
17
17
|
- **Adapters plugáveis.** Cada categoria é uma superfície (subpath) com interface fechada e drivers trocáveis.
|
|
18
|
-
- **Declaração é a fonte; doc é projeção.** As declarações (`defineContract`, `bindAction`, `defineEntity`) são a fonte dos contratos; manifest, OpenAPI e a doc gerada são projeções. `docs/protocol.md` descreve o protocolo em 16 seções (15 + glossário).
|
|
18
|
+
- **Declaração é a fonte; doc é projeção.** As declarações (`defineContract`, `bindAction`, `defineEntity`, `defineDataProduct`) são a fonte dos contratos; manifest, OpenAPI e a doc gerada são projeções. `docs/protocol.md` descreve o protocolo em 16 seções (15 + glossário).
|
|
19
19
|
|
|
20
20
|
## Superfícies
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
|
|
|
23
23
|
|
|
24
24
|
| Superfície | Drivers | Propósito |
|
|
25
25
|
|---|---|---|
|
|
26
|
-
| `@softize/opus` (core) | — | Protocolo,
|
|
26
|
+
| `@softize/opus` (core) | — | Protocolo, Actions, entities, Produtos de Dados e runtime |
|
|
27
27
|
| `@softize/opus/schema` | `/zod`, `/openapi` | Tipos lógicos (`t.*`) e gerador de OpenAPI |
|
|
28
28
|
| `@softize/opus/server` | `/fastify`, `/node` | Transporte HTTP e montagem de endpoints |
|
|
29
29
|
| `@softize/opus/client` | `/fetch` | Adapter de cliente para invocar actions |
|
|
@@ -95,7 +95,7 @@ registry/ # scaffolds + skills Opus — geração e conhecimento do SDK, não
|
|
|
95
95
|
bin/ # CLI (opus create/setup/gen/copy/check/db/seed/pre-push/introspect/mcp) + libs
|
|
96
96
|
docs/
|
|
97
97
|
protocol.md # contrato completo (16 seções)
|
|
98
|
-
data-layer.md · seeds.md · releasing.md · code-style.md
|
|
98
|
+
data-layer.md · data-products.md · seeds.md · releasing.md · code-style.md
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## Seeds de projeto
|
package/bin/lib/check.mjs
CHANGED
|
@@ -323,6 +323,54 @@ const UI_LEGACY_VARIANTS = new Map([
|
|
|
323
323
|
|
|
324
324
|
/** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
|
|
325
325
|
export const ACTION_MARKERS = ["defineAction", "defineContract", "bindAction"];
|
|
326
|
+
export const DATA_PRODUCT_MARKERS = ["defineDataProduct", "defineEntity"];
|
|
327
|
+
|
|
328
|
+
/** Extrai Produtos de Dados e entities literais para validar a linhagem sem executar o app. */
|
|
329
|
+
export function parseDataProducts(fileName, sourceText) {
|
|
330
|
+
const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
331
|
+
const products = [];
|
|
332
|
+
const entities = [];
|
|
333
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
334
|
+
const prop = (obj, key) => obj.properties.find((item) =>
|
|
335
|
+
ts.isPropertyAssignment(item) && item.name?.getText(sf) === key)?.initializer;
|
|
336
|
+
const stringProp = (obj, key) => {
|
|
337
|
+
const value = prop(obj, key);
|
|
338
|
+
return value && ts.isStringLiteral(value) ? value.text : null;
|
|
339
|
+
};
|
|
340
|
+
const stringList = (obj, key) => {
|
|
341
|
+
const value = prop(obj, key);
|
|
342
|
+
if (!value || !ts.isArrayLiteralExpression(value)) return null;
|
|
343
|
+
if (!value.elements.every(ts.isStringLiteral)) return null;
|
|
344
|
+
return value.elements.map((item) => item.text);
|
|
345
|
+
};
|
|
346
|
+
const isExported = (call) => {
|
|
347
|
+
const declaration = call.parent;
|
|
348
|
+
const statement = ts.isVariableDeclaration(declaration) ? declaration.parent?.parent : undefined;
|
|
349
|
+
return statement !== undefined && ts.isVariableStatement(statement) && (statement.modifiers ?? []).some((item) => item.kind === ts.SyntaxKind.ExportKeyword);
|
|
350
|
+
};
|
|
351
|
+
const visit = (node) => {
|
|
352
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])) {
|
|
353
|
+
const obj = node.arguments[0];
|
|
354
|
+
if (node.expression.text === "defineDataProduct") {
|
|
355
|
+
const versionNode = prop(obj, "version");
|
|
356
|
+
products.push({
|
|
357
|
+
id: stringProp(obj, "id"),
|
|
358
|
+
version: versionNode && ts.isNumericLiteral(versionNode) ? Number(versionNode.text) : null,
|
|
359
|
+
interfaces: stringList(obj, "interfaces"),
|
|
360
|
+
entities: stringList(obj, "entities"),
|
|
361
|
+
exported: isExported(node),
|
|
362
|
+
line: lineOf(node),
|
|
363
|
+
});
|
|
364
|
+
} else if (node.expression.text === "defineEntity") {
|
|
365
|
+
const name = stringProp(obj, "name");
|
|
366
|
+
if (name !== null) entities.push(name);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
ts.forEachChild(node, visit);
|
|
370
|
+
};
|
|
371
|
+
visit(sf);
|
|
372
|
+
return { products, entities };
|
|
373
|
+
}
|
|
326
374
|
|
|
327
375
|
/**
|
|
328
376
|
* Extrai `defineAction`/`defineContract`/`bindAction` de um source. Puro/sintático —
|
|
@@ -516,14 +564,21 @@ export function checkProject(sources) {
|
|
|
516
564
|
const findings = [];
|
|
517
565
|
const contractsByIdent = new Map(); // ident → item | 'ambiguous'
|
|
518
566
|
const binds = []; // { item, file }
|
|
567
|
+
const productDeclarations = [];
|
|
568
|
+
const entityNames = new Set();
|
|
569
|
+
const actionNames = new Set();
|
|
519
570
|
|
|
520
571
|
for (const { file, text } of sources) {
|
|
572
|
+
const declarations = parseDataProducts(file, text);
|
|
573
|
+
for (const entity of declarations.entities) entityNames.add(entity);
|
|
574
|
+
for (const product of declarations.products) productDeclarations.push({ product, file });
|
|
521
575
|
for (const item of parseActions(file, text)) {
|
|
522
576
|
if (item.form === "bindAction") {
|
|
523
577
|
actions += 1;
|
|
524
578
|
binds.push({ item, file });
|
|
525
579
|
} else if (item.form === "defineContract") {
|
|
526
580
|
contracts += 1;
|
|
581
|
+
if (item.name !== null) actionNames.add(item.name);
|
|
527
582
|
if (item.ident !== null) {
|
|
528
583
|
contractsByIdent.set(
|
|
529
584
|
item.ident,
|
|
@@ -532,11 +587,45 @@ export function checkProject(sources) {
|
|
|
532
587
|
}
|
|
533
588
|
} else {
|
|
534
589
|
actions += 1;
|
|
590
|
+
if (item.name !== null) actionNames.add(item.name);
|
|
535
591
|
}
|
|
536
592
|
for (const f of lintAction(item)) findings.push({ ...f, file });
|
|
537
593
|
}
|
|
538
594
|
}
|
|
539
595
|
|
|
596
|
+
const productIds = new Set();
|
|
597
|
+
for (const { product, file } of productDeclarations) {
|
|
598
|
+
const finding = (rule, message) => findings.push({
|
|
599
|
+
rule, level: "error", action: product.id ?? "(Produto de Dados sem id)",
|
|
600
|
+
line: product.line, file, message,
|
|
601
|
+
});
|
|
602
|
+
if (!product.exported) finding("data-product-export", "defineDataProduct precisa ser `export const` para compor o domínio e o catálogo.");
|
|
603
|
+
if (product.id === null || !ACTION_NAME_RE.test(product.id)) {
|
|
604
|
+
finding("data-product-id", "Produto de Dados precisa de `id` namespaced literal, como `sales.leads`.");
|
|
605
|
+
} else if (productIds.has(product.id)) {
|
|
606
|
+
finding("data-product-duplicate", `Produto de Dados "${product.id}" está declarado mais de uma vez.`);
|
|
607
|
+
} else {
|
|
608
|
+
productIds.add(product.id);
|
|
609
|
+
}
|
|
610
|
+
if (product.version === null || !Number.isInteger(product.version) || product.version < 1) {
|
|
611
|
+
finding("data-product-version", "Produto de Dados precisa de `version` literal inteira e positiva.");
|
|
612
|
+
}
|
|
613
|
+
if (product.interfaces === null || product.interfaces.length === 0) {
|
|
614
|
+
finding("data-product-interfaces", "Produto de Dados precisa declarar ao menos uma Action literal em `interfaces`.");
|
|
615
|
+
} else {
|
|
616
|
+
for (const action of product.interfaces) {
|
|
617
|
+
if (!actionNames.has(action)) finding("data-product-interface", `A interface "${action}" não corresponde a uma Action ou contrato declarado no projeto.`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (product.entities === null) {
|
|
621
|
+
finding("data-product-entities", "Produto de Dados precisa declarar `entities` como lista literal.");
|
|
622
|
+
} else {
|
|
623
|
+
for (const entity of product.entities) {
|
|
624
|
+
if (!entityNames.has(entity)) finding("data-product-entity", `A Entity "${entity}" não foi declarada no projeto.`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
540
629
|
for (const { item, file } of binds) {
|
|
541
630
|
const contract = contractsByIdent.get(item.contractRef);
|
|
542
631
|
if (contract === undefined || contract === "ambiguous") continue;
|
|
@@ -555,7 +644,7 @@ export function checkProject(sources) {
|
|
|
555
644
|
}
|
|
556
645
|
}
|
|
557
646
|
|
|
558
|
-
return { actions, contracts, findings };
|
|
647
|
+
return { actions, contracts, dataProducts: productDeclarations.length, findings };
|
|
559
648
|
}
|
|
560
649
|
|
|
561
650
|
/** Migrações de UI que falhariam apenas visualmente. Linhas que são só comentário não
|
|
@@ -1408,7 +1497,7 @@ export async function scanDir(rootDir) {
|
|
|
1408
1497
|
const sources = [];
|
|
1409
1498
|
for (const file of files) {
|
|
1410
1499
|
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
|
|
1411
|
-
if (!ACTION_MARKERS.some((m) => text.includes(m))) continue;
|
|
1500
|
+
if (![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) => text.includes(m))) continue;
|
|
1412
1501
|
sources.push({ file: path.relative(rootDir, file), text });
|
|
1413
1502
|
}
|
|
1414
1503
|
const checked = checkProject(sources);
|
|
@@ -1424,6 +1513,7 @@ export async function scanDir(rootDir) {
|
|
|
1424
1513
|
files: files.length,
|
|
1425
1514
|
actions: checked.actions,
|
|
1426
1515
|
contracts: checked.contracts,
|
|
1516
|
+
dataProducts: checked.dataProducts,
|
|
1427
1517
|
findings: [...checked.findings, ...uiFindings],
|
|
1428
1518
|
hasMarker,
|
|
1429
1519
|
};
|
package/bin/lib/copy.mjs
CHANGED
|
@@ -2146,6 +2146,14 @@ function globRegex(pattern) {
|
|
|
2146
2146
|
function sourceFiles(root, excluded = []) {
|
|
2147
2147
|
const output = []
|
|
2148
2148
|
const diagnostics = []
|
|
2149
|
+
const excludedPatterns = excluded.map((pattern) => ({
|
|
2150
|
+
regex: globRegex(pattern),
|
|
2151
|
+
subtree: pattern.endsWith('/**') ? globRegex(pattern.slice(0, -3)) : null,
|
|
2152
|
+
}))
|
|
2153
|
+
const isExcluded = (source) => excludedPatterns.some(({ regex }) => regex.test(source))
|
|
2154
|
+
const isExcludedDirectory = (source) => excludedPatterns.some(({ regex, subtree }) =>
|
|
2155
|
+
regex.test(source) || subtree?.test(source),
|
|
2156
|
+
)
|
|
2149
2157
|
const walk = (directory = '.') => {
|
|
2150
2158
|
let listing
|
|
2151
2159
|
try {
|
|
@@ -2162,8 +2170,9 @@ function sourceFiles(root, excluded = []) {
|
|
|
2162
2170
|
const target = path.join(directory, item.name)
|
|
2163
2171
|
const source = portable(target === '.' ? item.name : target)
|
|
2164
2172
|
if (IGNORED_DIRECTORIES.has(item.name)) continue
|
|
2173
|
+
if (item.isDirectory() && isExcludedDirectory(source)) continue
|
|
2165
2174
|
if (item.isSymbolicLink()) {
|
|
2166
|
-
if (!
|
|
2175
|
+
if (!isExcluded(source) && !isExcludedDirectory(source)) {
|
|
2167
2176
|
diagnostics.push({
|
|
2168
2177
|
source,
|
|
2169
2178
|
line: 1,
|
|
@@ -2189,7 +2198,7 @@ function sourceFiles(root, excluded = []) {
|
|
|
2189
2198
|
item.isFile() &&
|
|
2190
2199
|
SOURCE_EXTENSIONS.has(path.extname(item.name)) &&
|
|
2191
2200
|
!IGNORED_FILE.test(item.name) &&
|
|
2192
|
-
!
|
|
2201
|
+
!isExcluded(source)
|
|
2193
2202
|
) {
|
|
2194
2203
|
try {
|
|
2195
2204
|
const inspected = safeProjectPath(root, target, { mustExist: true })
|
package/bin/lib/gen-manifest.mjs
CHANGED
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -178,6 +178,7 @@ function serializeDomain(domain, ctx) {
|
|
|
178
178
|
hasRepository: domain.repository !== undefined,
|
|
179
179
|
hasService: domain.service !== undefined,
|
|
180
180
|
entities: entityList,
|
|
181
|
+
dataProducts: serializeDataProducts(domain.dataProducts),
|
|
181
182
|
dicts: serializeDicts(domain.dicts),
|
|
182
183
|
actions: Array.from(iterateActions(domain.actions), (a) =>
|
|
183
184
|
serializeAction(a, ctx),
|
|
@@ -194,6 +195,42 @@ function serializeDomain(domain, ctx) {
|
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
function serializeDataProducts(source) {
|
|
199
|
+
if (source === undefined || source === null) return []
|
|
200
|
+
const products = Array.isArray(source)
|
|
201
|
+
? source
|
|
202
|
+
: typeof source === 'object'
|
|
203
|
+
? Object.values(source)
|
|
204
|
+
: []
|
|
205
|
+
return products.map((product) => ({
|
|
206
|
+
id: product.id,
|
|
207
|
+
version: product.version,
|
|
208
|
+
label: product.label,
|
|
209
|
+
description: product.description,
|
|
210
|
+
owner: product.owner,
|
|
211
|
+
grain: product.grain,
|
|
212
|
+
classification: product.classification,
|
|
213
|
+
nature: product.nature,
|
|
214
|
+
sources: Array.isArray(product.sources)
|
|
215
|
+
? product.sources.map((source) => ({
|
|
216
|
+
id: source.id,
|
|
217
|
+
label: source.label,
|
|
218
|
+
description: nullable(source.description),
|
|
219
|
+
}))
|
|
220
|
+
: [],
|
|
221
|
+
entities: Array.isArray(product.entities) ? product.entities : [],
|
|
222
|
+
access: {
|
|
223
|
+
contexts: Array.isArray(product.access?.contexts) ? product.access.contexts : [],
|
|
224
|
+
organizationalScopes: Array.isArray(product.access?.organizationalScopes)
|
|
225
|
+
? product.access.organizationalScopes
|
|
226
|
+
: [],
|
|
227
|
+
},
|
|
228
|
+
interfaces: Array.isArray(product.interfaces) ? product.interfaces : [],
|
|
229
|
+
status: product.status ?? 'active',
|
|
230
|
+
replacedBy: nullable(product.replacedBy),
|
|
231
|
+
}))
|
|
232
|
+
}
|
|
233
|
+
|
|
197
234
|
/** Serializa as entidades (`defineEntity`) com campos + docs — a fonte que vai pro
|
|
198
235
|
* manifest/lente. Ignora valores que não parecem EntityConfig (name+fields). */
|
|
199
236
|
function serializeEntities(src) {
|
package/bin/lib/introspect.mjs
CHANGED
|
@@ -28,10 +28,10 @@ function asStringArray(init) {
|
|
|
28
28
|
return []
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** Extrai actions/reactions/schedules de um source. Puro/sintático — testável. */
|
|
31
|
+
/** Extrai actions/reactions/schedules/Produtos de Dados de um source. Puro/sintático — testável. */
|
|
32
32
|
export function parseStructure(fileName, sourceText) {
|
|
33
33
|
const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
34
|
-
const out = { actions: [], reactions: [], schedules: [] }
|
|
34
|
+
const out = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
35
35
|
const line = (n) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1
|
|
36
36
|
|
|
37
37
|
function visit(node) {
|
|
@@ -64,6 +64,17 @@ export function parseStructure(fileName, sourceText) {
|
|
|
64
64
|
every: asString(prop(obj, 'every', sf)),
|
|
65
65
|
line: line(node),
|
|
66
66
|
})
|
|
67
|
+
} else if (fn === 'defineDataProduct') {
|
|
68
|
+
out.dataProducts.push({
|
|
69
|
+
id: asString(prop(obj, 'id', sf)),
|
|
70
|
+
version: (() => {
|
|
71
|
+
const value = prop(obj, 'version', sf)
|
|
72
|
+
return value && ts.isNumericLiteral(value) ? Number(value.text) : null
|
|
73
|
+
})(),
|
|
74
|
+
entities: asStringArray(prop(obj, 'entities', sf)),
|
|
75
|
+
interfaces: asStringArray(prop(obj, 'interfaces', sf)),
|
|
76
|
+
line: line(node),
|
|
77
|
+
})
|
|
67
78
|
}
|
|
68
79
|
}
|
|
69
80
|
ts.forEachChild(node, visit)
|
|
@@ -94,15 +105,16 @@ export function deriveWiring(model) {
|
|
|
94
105
|
export async function introspect(rootDir) {
|
|
95
106
|
rootDir = canonicalProjectDirectory(rootDir)
|
|
96
107
|
const files = await walkTsFiles(rootDir)
|
|
97
|
-
const model = { actions: [], reactions: [], schedules: [] }
|
|
108
|
+
const model = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
98
109
|
for (const file of files) {
|
|
99
110
|
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
|
|
100
|
-
if (!/define(Action|Reaction|Schedule)/.test(text)) continue
|
|
111
|
+
if (!/define(Action|Reaction|Schedule|DataProduct)/.test(text)) continue
|
|
101
112
|
const s = parseStructure(file, text)
|
|
102
113
|
const rel = path.relative(rootDir, file)
|
|
103
114
|
for (const a of s.actions) model.actions.push({ ...a, file: rel })
|
|
104
115
|
for (const r of s.reactions) model.reactions.push({ ...r, file: rel })
|
|
105
116
|
for (const sc of s.schedules) model.schedules.push({ ...sc, file: rel })
|
|
117
|
+
for (const product of s.dataProducts) model.dataProducts.push({ ...product, file: rel })
|
|
106
118
|
}
|
|
107
119
|
return { ...model, wiring: deriveWiring(model) }
|
|
108
120
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ADR 0012 — Produtos de Dados são declarações de primeira classe
|
|
2
|
+
|
|
3
|
+
- Status: aceita
|
|
4
|
+
- Data: 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Contexto e forças
|
|
7
|
+
|
|
8
|
+
Entities descrevem estrutura persistida e Actions descrevem interfaces autorizadas. Nenhuma das
|
|
9
|
+
duas declara, porém, qual conjunto governado existe para responder uma pergunta de negócio. Sem
|
|
10
|
+
esse artefato, relatórios, agentes e catálogos reconstroem a relação entre Fonte, Entity e Action
|
|
11
|
+
por nomes ou tabelas paralelas. A mesma análise pode então receber uma definição diferente a cada
|
|
12
|
+
novo consumidor.
|
|
13
|
+
|
|
14
|
+
Uma view de banco não resolve o problema: ela pode ser uma implementação eficiente, mas não
|
|
15
|
+
carrega por si só identidade pública, responsável, grão, classificação, linhagem, interfaces ou
|
|
16
|
+
ciclo de vida. Também não queremos que metadata descritiva contorne o pipeline de autorização da
|
|
17
|
+
Action.
|
|
18
|
+
|
|
19
|
+
## Decisão
|
|
20
|
+
|
|
21
|
+
O core expõe `defineDataProduct`. O produto tem identidade namespaced e estável, versão inteira,
|
|
22
|
+
nome e descrição humanas, responsável, grão, classificação, natureza, Fontes, entities, acesso
|
|
23
|
+
descritivo e Actions de interface. Produtos ativos podem ser descontinuados com `status:
|
|
24
|
+
'deprecated'` e `replacedBy`.
|
|
25
|
+
|
|
26
|
+
`defineDomain({ dataProducts })` registra os produtos. O domínio e `opus check` recusam IDs
|
|
27
|
+
duplicados e relações literais que apontam para Action ou Entity inexistente. A declaração não
|
|
28
|
+
executa consulta, não contém driver e não substitui uma Action.
|
|
29
|
+
|
|
30
|
+
A autorização continua sendo responsabilidade da Action. `access.contexts` e
|
|
31
|
+
`access.organizationalScopes` documentam o alcance esperado para catálogo, Lens e revisão, mas o
|
|
32
|
+
runtime não os converte em autorização implícita. Essa separação impede que uma descrição
|
|
33
|
+
incompleta abra dados.
|
|
34
|
+
|
|
35
|
+
O manifest projeta a declaração integral. Tools de IA recebem a lista de produtos que expõem em
|
|
36
|
+
metadata, e o servidor MCP publica essa lista em `_meta['com.softize.opus/data-products']`. A
|
|
37
|
+
camada que compõe MCPs pode persistir a linhagem das chamadas bem-sucedidas sem conhecer tabelas
|
|
38
|
+
ou inferir produtos pelo nome da tool.
|
|
39
|
+
|
|
40
|
+
## Alternativas consideradas
|
|
41
|
+
|
|
42
|
+
### Tratar Entity como Produto de Dados
|
|
43
|
+
|
|
44
|
+
Rejeitada. Uma Entity descreve estrutura e invariantes; um produto pode combinar várias entities,
|
|
45
|
+
ter outro grão e oferecer mais de uma interface.
|
|
46
|
+
|
|
47
|
+
### Usar view ou tabela consolidada como identidade
|
|
48
|
+
|
|
49
|
+
Rejeitada como contrato. Views continuam válidas como implementação de performance, mas trocar a
|
|
50
|
+
materialização não deve trocar a identidade consumida por relatório, agente ou interface.
|
|
51
|
+
|
|
52
|
+
### Manter um catálogo manual fora do domínio
|
|
53
|
+
|
|
54
|
+
Rejeitada. O catálogo voltaria a divergir das Actions e entities que realmente existem.
|
|
55
|
+
|
|
56
|
+
## Consequências
|
|
57
|
+
|
|
58
|
+
Um produto novo exige decisão explícita sobre semântica, acesso e linhagem antes de ser publicado.
|
|
59
|
+
O custo adicional é intencional. Mudança de forma observável incrementa `version`; substituição
|
|
60
|
+
preserva o ID antigo como descontinuado durante a migração dos consumidores.
|
|
61
|
+
|
|
62
|
+
Relatórios e agentes passam a depender da identidade do produto, enquanto cada Action permanece
|
|
63
|
+
a fronteira executável e autorizada. O Opus Lens pode apresentar a cadeia `Fonte/Entity → Produto
|
|
64
|
+
de Dados → Action` a partir do manifest.
|
|
65
|
+
|
|
66
|
+
## Verificação
|
|
67
|
+
|
|
68
|
+
- testes de construção e registro cobrem formato, duplicidade, ciclo de vida e referências;
|
|
69
|
+
- `opus check` cobre declarações literais, exportação e drift de Action/Entity;
|
|
70
|
+
- o manifest preserva todos os campos;
|
|
71
|
+
- runtime de IA e MCP projetam a identidade do produto por interface;
|
|
72
|
+
- Opus Lens lê a declaração e deriva a linhagem sem heurística.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Produtos de Dados
|
|
3
|
+
order: 7
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Produtos de Dados
|
|
7
|
+
|
|
8
|
+
Um Produto de Dados dá identidade governada ao conjunto que uma ou mais Actions disponibilizam.
|
|
9
|
+
Ele não é uma query, tabela ou view: essas são implementações possíveis. A fronteira de execução,
|
|
10
|
+
recorte e autorização continua sendo cada Action.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { defineDataProduct, defineDomain } from '@softize/opus'
|
|
14
|
+
|
|
15
|
+
export const salesLeads = defineDataProduct({
|
|
16
|
+
id: 'sales.leads',
|
|
17
|
+
version: 1,
|
|
18
|
+
label: 'Leads comerciais',
|
|
19
|
+
description: 'Leads e seus resultados comerciais.',
|
|
20
|
+
owner: 'Vendas',
|
|
21
|
+
grain: 'Um lead.',
|
|
22
|
+
classification: 'internal',
|
|
23
|
+
nature: 'real',
|
|
24
|
+
sources: [{ id: 'followize', label: 'Followize' }],
|
|
25
|
+
entities: ['Lead'],
|
|
26
|
+
access: {
|
|
27
|
+
contexts: ['sales'],
|
|
28
|
+
organizationalScopes: ['unit', 'team'],
|
|
29
|
+
},
|
|
30
|
+
interfaces: ['sale.list', 'sales.performance'],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const salesDomain = defineDomain({
|
|
34
|
+
name: 'sales',
|
|
35
|
+
entities: { Lead },
|
|
36
|
+
actions: { saleList, salesPerformance },
|
|
37
|
+
dataProducts: { salesLeads },
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`interfaces` e `entities` usam os nomes declarados, não nomes de arquivo ou tabela. O registro do
|
|
42
|
+
domínio e `opus check` detectam relações inexistentes. `sources` identifica sistemas ou conjuntos
|
|
43
|
+
upstream; uma Fonte não precisa ser uma Entity Opus.
|
|
44
|
+
|
|
45
|
+
## Acesso e segurança
|
|
46
|
+
|
|
47
|
+
`access` é documentação verificável do alcance esperado. Ele ajuda catálogo e revisão a perceber
|
|
48
|
+
que uma interface deveria respeitar, por exemplo, unidade e equipe. Não é RLS nem autorização
|
|
49
|
+
automática. Toda interface precisa aplicar seus próprios `requires`, `authorize` e recortes no
|
|
50
|
+
handler/repositório, inclusive quando for chamada por IA ou MCP.
|
|
51
|
+
|
|
52
|
+
## Projeções
|
|
53
|
+
|
|
54
|
+
`opus gen` publica os produtos no `.opus/manifest.json`. Actions expostas como tools carregam os
|
|
55
|
+
IDs em `metadata.dataProducts`; no MCP, a chave é
|
|
56
|
+
`_meta['com.softize.opus/data-products']`. Consumidores devem registrar apenas produtos associados
|
|
57
|
+
a chamadas concluídas com sucesso.
|
|
58
|
+
|
|
59
|
+
O Opus Lens usa o manifest para mostrar a linhagem declarada entre Fontes, entities, produtos e
|
|
60
|
+
Actions. Nenhuma dessas projeções é fonte autoritativa: corrija a declaração e gere novamente.
|
|
61
|
+
|
|
62
|
+
## Ciclo de vida
|
|
63
|
+
|
|
64
|
+
Incremente `version` quando a forma observável do produto mudar. Para substituí-lo, mantenha a
|
|
65
|
+
declaração anterior com `status: 'deprecated'` e `replacedBy` apontando para o novo ID durante a
|
|
66
|
+
migração. Produto ativo não declara `replacedBy`.
|
package/docs/protocol.md
CHANGED
|
@@ -2084,6 +2084,18 @@ export const crm = defineDomain({
|
|
|
2084
2084
|
|
|
2085
2085
|
---
|
|
2086
2086
|
|
|
2087
|
+
### 15.1 Produtos de Dados
|
|
2088
|
+
|
|
2089
|
+
`defineDataProduct` declara um conjunto governado consumível por pessoas, relatórios ou agentes.
|
|
2090
|
+
Ele liga Fontes e entities às Actions que formam sua interface, sem conter query, driver ou regra
|
|
2091
|
+
de autorização. O formato completo, o ciclo de vida e as projeções estão em
|
|
2092
|
+
[Produtos de Dados](data-products.md); a decisão arquitetural está na
|
|
2093
|
+
[ADR 0012](adr/0012-data-products-are-first-class-declarations.md).
|
|
2094
|
+
|
|
2095
|
+
A declaração de `access` é descritiva. O pipeline da Action permanece a única fronteira
|
|
2096
|
+
executável: `requires`, `authorize`, carregamentos e recortes continuam sendo aplicados em toda
|
|
2097
|
+
chamada, inclusive por IA e MCP.
|
|
2098
|
+
|
|
2087
2099
|
## 16. Glossário
|
|
2088
2100
|
|
|
2089
2101
|
- **Action** — unidade declarativa que flui pelo pipeline opus. Ver §2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "15.1
|
|
3
|
+
"version": "15.2.1",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -212,6 +212,17 @@
|
|
|
212
212
|
"bin": {
|
|
213
213
|
"opus": "bin/cli.mjs"
|
|
214
214
|
},
|
|
215
|
+
"scripts": {
|
|
216
|
+
"postinstall": "node ./bin/lib/postinstall.mjs",
|
|
217
|
+
"copy:check": "node ./bin/cli.mjs copy --check",
|
|
218
|
+
"typecheck": "tsc --noEmit",
|
|
219
|
+
"test": "vitest run",
|
|
220
|
+
"test:watch": "vitest",
|
|
221
|
+
"test:cov": "vitest run --coverage",
|
|
222
|
+
"registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
|
|
223
|
+
"release": "bash ./scripts/release.sh",
|
|
224
|
+
"release:local": "bash ./scripts/release.sh --local"
|
|
225
|
+
},
|
|
215
226
|
"dependencies": {
|
|
216
227
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
217
228
|
"@radix-ui/react-checkbox": "^1.1.3",
|
|
@@ -357,21 +368,11 @@
|
|
|
357
368
|
"vitest": "^2.1.0",
|
|
358
369
|
"zod": "^3.24.0"
|
|
359
370
|
},
|
|
371
|
+
"packageManager": "pnpm@9.0.0",
|
|
360
372
|
"repository": {
|
|
361
373
|
"type": "git",
|
|
362
374
|
"url": "git+https://github.com/softize-dev/opus.git",
|
|
363
375
|
"directory": "packages/opus"
|
|
364
376
|
},
|
|
365
|
-
"homepage": "https://opus.softize.com.br"
|
|
366
|
-
|
|
367
|
-
"postinstall": "node ./bin/lib/postinstall.mjs",
|
|
368
|
-
"copy:check": "node ./bin/cli.mjs copy --check",
|
|
369
|
-
"typecheck": "tsc --noEmit",
|
|
370
|
-
"test": "vitest run",
|
|
371
|
-
"test:watch": "vitest",
|
|
372
|
-
"test:cov": "vitest run --coverage",
|
|
373
|
-
"registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
|
|
374
|
-
"release": "bash ./scripts/release.sh",
|
|
375
|
-
"release:local": "bash ./scripts/release.sh --local"
|
|
376
|
-
}
|
|
377
|
-
}
|
|
377
|
+
"homepage": "https://opus.softize.com.br"
|
|
378
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Um Produto de Dados é um dataset governado e estável que uma ou mais Actions expõem.
|
|
3
|
+
* A declaração descreve o produto; autorização e recorte continuam pertencendo às Actions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DataProductStatus = 'active' | 'deprecated'
|
|
7
|
+
|
|
8
|
+
export interface DataProductSource {
|
|
9
|
+
/** Identificador técnico estável da Fonte. */
|
|
10
|
+
id: string
|
|
11
|
+
/** Nome humano projetado por catálogos e lentes. */
|
|
12
|
+
label: string
|
|
13
|
+
description?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DataProductAccess {
|
|
17
|
+
/** Contextos de dados normalmente exigidos pelas interfaces do produto. Descritivo. */
|
|
18
|
+
contexts: readonly string[]
|
|
19
|
+
/** Eixos organizacionais que as Actions precisam considerar, como unit e team. Descritivo. */
|
|
20
|
+
organizationalScopes: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DataProductConfig {
|
|
24
|
+
/** Identidade estável e namespaced, como `sales.leads`. */
|
|
25
|
+
id: string
|
|
26
|
+
/** Versão semântica inteira da forma observável do produto. */
|
|
27
|
+
version: number
|
|
28
|
+
label: string
|
|
29
|
+
description: string
|
|
30
|
+
/** Responsável técnico ou de negócio pelo produto. */
|
|
31
|
+
owner: string
|
|
32
|
+
/** O que uma linha ou observação representa. */
|
|
33
|
+
grain: string
|
|
34
|
+
/** Classificação definida pelo produto, como `internal` ou `personal`. */
|
|
35
|
+
classification: string
|
|
36
|
+
/** Natureza definida pelo produto, como `real`, `demo` ou `synthetic`. */
|
|
37
|
+
nature: string
|
|
38
|
+
sources: readonly DataProductSource[]
|
|
39
|
+
/** Entidades Opus que materializam ou estruturam o produto. */
|
|
40
|
+
entities: readonly string[]
|
|
41
|
+
access: DataProductAccess
|
|
42
|
+
/** Nomes de Actions que disponibilizam este produto. */
|
|
43
|
+
interfaces: readonly string[]
|
|
44
|
+
status?: DataProductStatus
|
|
45
|
+
/** Produto sucessor quando este estiver descontinuado. */
|
|
46
|
+
replacedBy?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ID_RE = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/
|
|
50
|
+
|
|
51
|
+
function nonEmpty(value: unknown, field: string): void {
|
|
52
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
53
|
+
throw new TypeError(`DataProduct "${field}" deve ser string não-vazia`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Declara e valida um Produto de Dados sem introduzir dependência de banco ou runtime. */
|
|
58
|
+
export function defineDataProduct<const T extends DataProductConfig>(config: T): T {
|
|
59
|
+
if (!ID_RE.test(config.id)) {
|
|
60
|
+
throw new TypeError(`DataProduct id "${config.id}" deve ser namespaced e casar com ${ID_RE.source}`)
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isInteger(config.version) || config.version < 1) {
|
|
63
|
+
throw new TypeError('DataProduct "version" deve ser inteiro positivo')
|
|
64
|
+
}
|
|
65
|
+
for (const field of ['label', 'description', 'owner', 'grain', 'classification', 'nature'] as const) {
|
|
66
|
+
nonEmpty(config[field], field)
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(config.sources) || config.sources.length === 0) {
|
|
69
|
+
throw new TypeError('DataProduct "sources" deve declarar ao menos uma Fonte')
|
|
70
|
+
}
|
|
71
|
+
const sourceIds = new Set<string>()
|
|
72
|
+
for (const source of config.sources) {
|
|
73
|
+
nonEmpty(source.id, 'sources[].id')
|
|
74
|
+
nonEmpty(source.label, 'sources[].label')
|
|
75
|
+
if (source.description !== undefined) nonEmpty(source.description, 'sources[].description')
|
|
76
|
+
if (sourceIds.has(source.id)) throw new TypeError(`DataProduct Fonte "${source.id}" duplicada`)
|
|
77
|
+
sourceIds.add(source.id)
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(config.entities)) {
|
|
80
|
+
throw new TypeError('DataProduct "entities" deve ser uma lista')
|
|
81
|
+
}
|
|
82
|
+
if (new Set(config.entities).size !== config.entities.length) {
|
|
83
|
+
throw new TypeError(`DataProduct "${config.id}" possui Entity duplicada`)
|
|
84
|
+
}
|
|
85
|
+
for (const entity of config.entities) nonEmpty(entity, 'entities[]')
|
|
86
|
+
if (!Array.isArray(config.interfaces) || config.interfaces.length === 0) {
|
|
87
|
+
throw new TypeError('DataProduct "interfaces" deve declarar ao menos uma Action')
|
|
88
|
+
}
|
|
89
|
+
if (new Set(config.interfaces).size !== config.interfaces.length) {
|
|
90
|
+
throw new TypeError(`DataProduct "${config.id}" possui interface duplicada`)
|
|
91
|
+
}
|
|
92
|
+
for (const action of config.interfaces) nonEmpty(action, 'interfaces[]')
|
|
93
|
+
if (!Array.isArray(config.access?.contexts) || !Array.isArray(config.access?.organizationalScopes)) {
|
|
94
|
+
throw new TypeError('DataProduct "access" deve declarar contexts e organizationalScopes')
|
|
95
|
+
}
|
|
96
|
+
for (const context of config.access.contexts) nonEmpty(context, 'access.contexts[]')
|
|
97
|
+
for (const scope of config.access.organizationalScopes) nonEmpty(scope, 'access.organizationalScopes[]')
|
|
98
|
+
if (config.status !== undefined && config.status !== 'active' && config.status !== 'deprecated') {
|
|
99
|
+
throw new TypeError('DataProduct "status" deve ser "active" ou "deprecated"')
|
|
100
|
+
}
|
|
101
|
+
if (config.status === 'deprecated') {
|
|
102
|
+
nonEmpty(config.replacedBy, 'replacedBy')
|
|
103
|
+
if (!ID_RE.test(config.replacedBy!)) {
|
|
104
|
+
throw new TypeError(`DataProduct replacedBy "${config.replacedBy}" deve ser namespaced e casar com ${ID_RE.source}`)
|
|
105
|
+
}
|
|
106
|
+
if (config.replacedBy === config.id) throw new TypeError('DataProduct não pode substituir a si mesmo')
|
|
107
|
+
} else if (config.replacedBy !== undefined) {
|
|
108
|
+
throw new TypeError('DataProduct ativo não pode declarar "replacedBy"')
|
|
109
|
+
}
|
|
110
|
+
return config
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isDataProduct(value: unknown): value is DataProductConfig {
|
|
114
|
+
if (typeof value !== 'object' || value === null) return false
|
|
115
|
+
try {
|
|
116
|
+
defineDataProduct(value as DataProductConfig)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/core/domain.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { ActionContract } from './contracts.ts'
|
|
25
|
+
import type { DataProductConfig } from './data-product.ts'
|
|
25
26
|
import { error } from './errors.ts'
|
|
26
27
|
import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
27
28
|
|
|
@@ -58,6 +59,9 @@ export interface DomainConfig {
|
|
|
58
59
|
* estrutural+negócio que vai pro manifest. */
|
|
59
60
|
entities?: Record<string, unknown>
|
|
60
61
|
|
|
62
|
+
/** Produtos de Dados governados disponibilizados pelas Actions deste domínio. */
|
|
63
|
+
dataProducts?: Record<string, DataProductConfig> | DataProductConfig[]
|
|
64
|
+
|
|
61
65
|
/** Repository class do domínio. */
|
|
62
66
|
repository?: unknown
|
|
63
67
|
|
|
@@ -90,6 +94,7 @@ export interface FlattenedDomain {
|
|
|
90
94
|
actions: ActionDef[]
|
|
91
95
|
reactions: Array<ReactionDef<any>>
|
|
92
96
|
schedules: ScheduleDef[]
|
|
97
|
+
dataProducts: DataProductConfig[]
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
// =============================================================================
|
|
@@ -150,6 +155,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
|
150
155
|
'subdomains' in v ||
|
|
151
156
|
'dicts' in v ||
|
|
152
157
|
'entities' in v ||
|
|
158
|
+
'dataProducts' in v ||
|
|
153
159
|
'repository' in v ||
|
|
154
160
|
'service' in v
|
|
155
161
|
)
|
|
@@ -160,7 +166,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
|
160
166
|
* pro `Runtime.register`.
|
|
161
167
|
*/
|
|
162
168
|
export function flattenDomain(domain: DomainConfig): FlattenedDomain {
|
|
163
|
-
const out: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
169
|
+
const out: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
164
170
|
collect(domain, out)
|
|
165
171
|
return out
|
|
166
172
|
}
|
|
@@ -179,6 +185,12 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
|
|
|
179
185
|
if (domain.schedules !== undefined) {
|
|
180
186
|
for (const schedule of domain.schedules) out.schedules.push(schedule)
|
|
181
187
|
}
|
|
188
|
+
if (domain.dataProducts !== undefined) {
|
|
189
|
+
const products = Array.isArray(domain.dataProducts)
|
|
190
|
+
? domain.dataProducts
|
|
191
|
+
: Object.values(domain.dataProducts)
|
|
192
|
+
for (const product of products) out.dataProducts.push(product)
|
|
193
|
+
}
|
|
182
194
|
if (domain.subdomains !== undefined) {
|
|
183
195
|
for (const sub of domain.subdomains) collect(sub, out)
|
|
184
196
|
}
|
|
@@ -257,7 +269,7 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
|
|
|
257
269
|
// — actions: nomes únicos no domínio (e subdomínios) —
|
|
258
270
|
// (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
|
|
259
271
|
// primeiro, antes de detectar duplicatas cruzadas.)
|
|
260
|
-
const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
272
|
+
const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
261
273
|
collect(domain, collected)
|
|
262
274
|
|
|
263
275
|
const actionNames = new Set<string>()
|
|
@@ -271,6 +283,48 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
|
|
|
271
283
|
}
|
|
272
284
|
actionNames.add(action.name)
|
|
273
285
|
}
|
|
286
|
+
|
|
287
|
+
const entityNames = new Set<string>()
|
|
288
|
+
const collectEntityNames = (current: DomainConfig): void => {
|
|
289
|
+
for (const [key, raw] of Object.entries(current.entities ?? {})) {
|
|
290
|
+
const declared = typeof raw === 'object' && raw !== null && 'name' in raw
|
|
291
|
+
? (raw as { name?: unknown }).name
|
|
292
|
+
: undefined
|
|
293
|
+
entityNames.add(typeof declared === 'string' && declared !== '' ? declared : key)
|
|
294
|
+
}
|
|
295
|
+
for (const subdomain of current.subdomains ?? []) collectEntityNames(subdomain)
|
|
296
|
+
}
|
|
297
|
+
collectEntityNames(domain)
|
|
298
|
+
|
|
299
|
+
const productIds = new Set<string>()
|
|
300
|
+
for (const product of collected.dataProducts) {
|
|
301
|
+
if (productIds.has(product.id)) {
|
|
302
|
+
throw error({
|
|
303
|
+
code: 'domain.duplicate_data_product',
|
|
304
|
+
category: 'internal',
|
|
305
|
+
message: `DataProduct "${product.id}" duplicado em "${fullPathStr}"`,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
productIds.add(product.id)
|
|
309
|
+
for (const actionName of product.interfaces) {
|
|
310
|
+
if (!actionNames.has(actionName)) {
|
|
311
|
+
throw error({
|
|
312
|
+
code: 'domain.data_product_interface_not_found',
|
|
313
|
+
category: 'internal',
|
|
314
|
+
message: `DataProduct "${product.id}" referencia a Action inexistente "${actionName}"`,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const entityName of product.entities) {
|
|
319
|
+
if (!entityNames.has(entityName)) {
|
|
320
|
+
throw error({
|
|
321
|
+
code: 'domain.data_product_entity_not_found',
|
|
322
|
+
category: 'internal',
|
|
323
|
+
message: `DataProduct "${product.id}" referencia a Entity inexistente "${entityName}"`,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
274
328
|
}
|
|
275
329
|
|
|
276
330
|
/**
|
package/src/core/index.ts
CHANGED
|
@@ -200,6 +200,15 @@ export { defineSchedule, isSchedule } from './schedules.ts'
|
|
|
200
200
|
export { defineDomain, flattenDomain, isDomainConfig } from './domain.ts'
|
|
201
201
|
export type { DomainConfig, FlattenedDomain } from './domain.ts'
|
|
202
202
|
|
|
203
|
+
// — Produtos de Dados ————————————————————————————————————————————————————————
|
|
204
|
+
export { defineDataProduct, isDataProduct } from './data-product.ts'
|
|
205
|
+
export type {
|
|
206
|
+
DataProductAccess,
|
|
207
|
+
DataProductConfig,
|
|
208
|
+
DataProductSource,
|
|
209
|
+
DataProductStatus,
|
|
210
|
+
} from './data-product.ts'
|
|
211
|
+
|
|
203
212
|
// — Audit —————————————————————————————————————————————————————————————————————
|
|
204
213
|
export { AuditEmitter } from './audit.ts'
|
|
205
214
|
|
package/src/core/runtime.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { isBackgroundAction } from './actions.ts'
|
|
|
16
16
|
import { isSchedule } from './schedules.ts'
|
|
17
17
|
import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.ts'
|
|
18
18
|
import type { DomainConfig } from './domain.ts'
|
|
19
|
+
import type { DataProductConfig } from './data-product.ts'
|
|
19
20
|
import { AuditEmitter } from './audit.ts'
|
|
20
21
|
import { error, isActionError, normalizeError } from './errors.ts'
|
|
21
22
|
import { normalizeTraceContext } from './trace.ts'
|
|
@@ -254,6 +255,8 @@ export class Runtime {
|
|
|
254
255
|
private readonly actions = new Map<string, ActionDef>()
|
|
255
256
|
private readonly reactions = new Map<string, ReactionDef<any>>()
|
|
256
257
|
private readonly schedules = new Map<string, ScheduleDef>()
|
|
258
|
+
private readonly dataProducts = new Map<string, DataProductConfig>()
|
|
259
|
+
private readonly actionDataProducts = new Map<string, Set<string>>()
|
|
257
260
|
|
|
258
261
|
/**
|
|
259
262
|
* Map entidade → resolver, usado pra carregar specs DSL de loads
|
|
@@ -315,8 +318,8 @@ export class Runtime {
|
|
|
315
318
|
for (const item of items) {
|
|
316
319
|
if (isDomainConfig(item)) {
|
|
317
320
|
collectDomainWarnings(item)
|
|
318
|
-
const { actions, reactions, schedules } = flattenDomain(item)
|
|
319
|
-
this.registerFlat(actions, reactions, schedules)
|
|
321
|
+
const { actions, reactions, schedules, dataProducts } = flattenDomain(item)
|
|
322
|
+
this.registerFlat(actions, reactions, schedules, dataProducts)
|
|
320
323
|
} else if (isSchedule(item)) {
|
|
321
324
|
this.registerSchedules([item])
|
|
322
325
|
} else if (isReaction(item)) {
|
|
@@ -356,10 +359,31 @@ export class Runtime {
|
|
|
356
359
|
actions: ActionDef[],
|
|
357
360
|
reactions: Array<ReactionDef<any>>,
|
|
358
361
|
schedules: ScheduleDef[],
|
|
362
|
+
dataProducts: DataProductConfig[],
|
|
359
363
|
): void {
|
|
360
364
|
this.registerActions(actions)
|
|
361
365
|
this.registerReactions(reactions)
|
|
362
366
|
this.registerSchedules(schedules)
|
|
367
|
+
this.registerDataProducts(dataProducts)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private registerDataProducts(products: DataProductConfig[]): void {
|
|
371
|
+
for (const product of products) {
|
|
372
|
+
if (this.dataProducts.has(product.id)) {
|
|
373
|
+
throw error({
|
|
374
|
+
code: 'runtime.duplicate_data_product',
|
|
375
|
+
category: 'internal',
|
|
376
|
+
message: `DataProduct "${product.id}" registrado duas vezes`,
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
this.dataProducts.set(product.id, product)
|
|
380
|
+
for (const action of product.interfaces) {
|
|
381
|
+
const related = this.actionDataProducts.get(action) ?? new Set<string>()
|
|
382
|
+
related.add(product.id)
|
|
383
|
+
this.actionDataProducts.set(action, related)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
this.aiToolsCache = null
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
private registerActions(actions: ActionDef[]): void {
|
|
@@ -795,6 +819,7 @@ export class Runtime {
|
|
|
795
819
|
name: action.name,
|
|
796
820
|
description: cfg.description ?? action.description ?? action.name,
|
|
797
821
|
inputSchema: action.input,
|
|
822
|
+
metadata: { dataProducts: [...(this.actionDataProducts.get(action.name) ?? [])].sort() },
|
|
798
823
|
})
|
|
799
824
|
}
|
|
800
825
|
this.aiToolsCache = tools
|
package/src/core/types.ts
CHANGED
|
@@ -1261,6 +1261,8 @@ export interface AiTool {
|
|
|
1261
1261
|
name: string
|
|
1262
1262
|
description: string
|
|
1263
1263
|
inputSchema: unknown
|
|
1264
|
+
/** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
|
|
1265
|
+
metadata?: { dataProducts?: string[] }
|
|
1264
1266
|
}
|
|
1265
1267
|
|
|
1266
1268
|
/** Turno de conversa (histórico multi-turn do chat). */
|
package/src/mcp/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
|
|
|
46
46
|
name: t.name,
|
|
47
47
|
description: t.description,
|
|
48
48
|
inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
|
|
49
|
+
_meta: { 'com.softize.opus/data-products': t.metadata?.dataProducts ?? [] },
|
|
49
50
|
})),
|
|
50
51
|
}))
|
|
51
52
|
|
|
@@ -5,6 +5,10 @@ import { Composer } from './composer.tsx'
|
|
|
5
5
|
import { Markdown } from './markdown.tsx'
|
|
6
6
|
|
|
7
7
|
export interface ChatMessage {
|
|
8
|
+
/** Identidade persistida pelo app. Opcional para históricos legados. */
|
|
9
|
+
id?: string
|
|
10
|
+
/** Instante ISO-8601 persistido pelo app. Opcional para históricos legados. */
|
|
11
|
+
createdAt?: string
|
|
8
12
|
role: 'user' | 'assistant'
|
|
9
13
|
content: string
|
|
10
14
|
}
|
|
@@ -19,8 +23,10 @@ export interface ChatArtifact {
|
|
|
19
23
|
/** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
|
|
20
24
|
* server-autoritativa com replay, como o ChatRail do Maestro). */
|
|
21
25
|
export type ChatTranscriptItem =
|
|
22
|
-
| { role: 'user' | 'assistant' | 'error'; content: string }
|
|
23
|
-
| { role: 'artifact'; artifact: ChatArtifact }
|
|
26
|
+
| { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
|
|
27
|
+
| { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
|
|
28
|
+
|
|
29
|
+
export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
|
|
24
30
|
|
|
25
31
|
/** Feedback humano do tool em uso (default pt-BR; override por prop). */
|
|
26
32
|
function defaultHumanizeTool(name: string): string {
|
|
@@ -85,6 +91,9 @@ export interface ChatProps {
|
|
|
85
91
|
kickoff?: () => Promise<string> | AsyncIterable<ChatEvent>
|
|
86
92
|
/** Render do evento `artifact` (card, iframe, preview…). Default: link com o título. */
|
|
87
93
|
renderArtifact?: (artifact: ChatArtifact) => React.ReactNode
|
|
94
|
+
/** Ações contextuais de uma mensagem. O Chat fornece apenas a anatomia; buscar dados
|
|
95
|
+
* detalhados, pedir reautorização e decidir o conteúdo pertencem ao app. */
|
|
96
|
+
renderMessageActions?: (message: ChatTranscriptMessage) => React.ReactNode
|
|
88
97
|
/** Rótulo humano do tool em uso no indicador vivo (modo autogerenciado). */
|
|
89
98
|
humanizeTool?: (name: string, detail?: string) => string
|
|
90
99
|
placeholder?: string
|
|
@@ -95,6 +104,14 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
|
|
|
95
104
|
return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
function transcriptMeta(): { id: string; createdAt: string } {
|
|
108
|
+
const cryptoApi = globalThis.crypto
|
|
109
|
+
const id = typeof cryptoApi?.randomUUID === 'function'
|
|
110
|
+
? cryptoApi.randomUUID()
|
|
111
|
+
: `message_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
112
|
+
return { id, createdAt: new Date().toISOString() }
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
/** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta). */
|
|
99
116
|
function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
|
|
100
117
|
const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
|
|
@@ -114,6 +131,7 @@ interface ChatTranscriptProps {
|
|
|
114
131
|
greeting: string | undefined
|
|
115
132
|
empty: React.ReactNode | undefined
|
|
116
133
|
renderArtifact: ChatProps['renderArtifact']
|
|
134
|
+
renderMessageActions: ChatProps['renderMessageActions']
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
/**
|
|
@@ -128,6 +146,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
128
146
|
greeting,
|
|
129
147
|
empty,
|
|
130
148
|
renderArtifact,
|
|
149
|
+
renderMessageActions,
|
|
131
150
|
}: ChatTranscriptProps): React.ReactElement {
|
|
132
151
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
133
152
|
const turns = React.useMemo(() => groupTurns(items), [items])
|
|
@@ -164,16 +183,21 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
164
183
|
// (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
|
|
165
184
|
// MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
|
|
166
185
|
// curtas virava uma parede uniforme, sem começo nem fim visíveis.
|
|
167
|
-
<div key={ti} className="flex flex-col gap-1">
|
|
186
|
+
<div key={turn.user?.id ?? turn.rest[0]?.id ?? ti} className="flex flex-col gap-1">
|
|
168
187
|
{turn.user !== null && turn.user.role === 'user' && (
|
|
169
|
-
<div
|
|
188
|
+
<div
|
|
189
|
+
data-slot="chat-turn-user"
|
|
190
|
+
data-message-id={turn.user.id}
|
|
191
|
+
data-message-created-at={turn.user.createdAt}
|
|
192
|
+
className="flex justify-end py-1.5"
|
|
193
|
+
>
|
|
170
194
|
<UserTurn content={turn.user.content} />
|
|
171
195
|
</div>
|
|
172
196
|
)}
|
|
173
197
|
{turn.rest.map((item, i) => {
|
|
174
198
|
if (item.role === 'artifact') {
|
|
175
199
|
return (
|
|
176
|
-
<div key={i} data-slot="chat-artifact" className="flex justify-start">
|
|
200
|
+
<div key={item.id ?? i} data-slot="chat-artifact" data-message-id={item.id} className="flex justify-start">
|
|
177
201
|
{renderArtifact !== undefined ? (
|
|
178
202
|
renderArtifact(item.artifact)
|
|
179
203
|
) : (
|
|
@@ -192,7 +216,9 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
192
216
|
if (item.role === 'error') {
|
|
193
217
|
return (
|
|
194
218
|
<div
|
|
195
|
-
key={i}
|
|
219
|
+
key={item.id ?? i}
|
|
220
|
+
data-slot="chat-error"
|
|
221
|
+
data-message-id={item.id}
|
|
196
222
|
className="max-w-[88%] self-start rounded-xl border border-context-danger-border bg-context-danger-subtle px-3.5 py-2.5 text-sm text-context-danger-emphasis"
|
|
197
223
|
>
|
|
198
224
|
{item.content}
|
|
@@ -201,8 +227,22 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
201
227
|
}
|
|
202
228
|
// O assistente responde direto no corpo, em Markdown (convenção da casa).
|
|
203
229
|
return (
|
|
204
|
-
<div
|
|
230
|
+
<div
|
|
231
|
+
key={item.id ?? i}
|
|
232
|
+
data-slot="chat-message"
|
|
233
|
+
data-message-id={item.id}
|
|
234
|
+
data-message-created-at={item.createdAt}
|
|
235
|
+
className="group/chat-message break-words px-1 text-sm leading-snug"
|
|
236
|
+
>
|
|
205
237
|
<Markdown content={item.content} />
|
|
238
|
+
{renderMessageActions !== undefined && (
|
|
239
|
+
<div
|
|
240
|
+
data-slot="chat-message-actions"
|
|
241
|
+
className="mt-1 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
|
|
242
|
+
>
|
|
243
|
+
{renderMessageActions(item)}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
206
246
|
</div>
|
|
207
247
|
)
|
|
208
248
|
})}
|
|
@@ -257,6 +297,7 @@ export function Chat({
|
|
|
257
297
|
initialMessages,
|
|
258
298
|
kickoff,
|
|
259
299
|
renderArtifact,
|
|
300
|
+
renderMessageActions,
|
|
260
301
|
humanizeTool = defaultHumanizeTool,
|
|
261
302
|
placeholder = 'Escreva uma mensagem…',
|
|
262
303
|
className,
|
|
@@ -335,7 +376,7 @@ export function Chat({
|
|
|
335
376
|
return
|
|
336
377
|
}
|
|
337
378
|
|
|
338
|
-
const next: ChatTranscriptItem[] = [...ownItems, { role: 'user', content: text }]
|
|
379
|
+
const next: ChatTranscriptItem[] = [...ownItems, { ...transcriptMeta(), role: 'user', content: text }]
|
|
339
380
|
setOwnItems(next)
|
|
340
381
|
const transcript = next.filter((i): i is ChatMessage => i.role === 'user' || i.role === 'assistant')
|
|
341
382
|
await consume(() => send!(transcript))
|
|
@@ -349,16 +390,18 @@ export function Chat({
|
|
|
349
390
|
const result = run()
|
|
350
391
|
if (isAsyncIterable(result)) {
|
|
351
392
|
let streaming = false
|
|
393
|
+
let streamingMeta: { id: string; createdAt: string } | undefined
|
|
352
394
|
for await (const event of result) {
|
|
353
395
|
if (event.type === 'text') {
|
|
354
396
|
setOwnActivity(null)
|
|
355
397
|
setOwnItems((list) => {
|
|
356
398
|
const last = list[list.length - 1]
|
|
357
399
|
if (streaming && last !== undefined && last.role === 'assistant') {
|
|
358
|
-
return [...list.slice(0, -1), {
|
|
400
|
+
return [...list.slice(0, -1), { ...last, content: last.content + event.delta }]
|
|
359
401
|
}
|
|
360
402
|
streaming = true
|
|
361
|
-
|
|
403
|
+
streamingMeta = transcriptMeta()
|
|
404
|
+
return [...list, { ...streamingMeta, role: 'assistant', content: event.delta }]
|
|
362
405
|
})
|
|
363
406
|
} else if (event.type === 'tool') {
|
|
364
407
|
streaming = false
|
|
@@ -367,20 +410,20 @@ export function Chat({
|
|
|
367
410
|
streaming = false
|
|
368
411
|
setOwnItems((list) => [
|
|
369
412
|
...list,
|
|
370
|
-
{ role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
|
|
413
|
+
{ ...transcriptMeta(), role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
|
|
371
414
|
])
|
|
372
415
|
} else if (event.type === 'done') {
|
|
373
416
|
if (!event.ok) {
|
|
374
|
-
setOwnItems((list) => [...list, { role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
|
|
417
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
|
|
375
418
|
}
|
|
376
419
|
}
|
|
377
420
|
}
|
|
378
421
|
} else {
|
|
379
422
|
const reply = await result
|
|
380
|
-
setOwnItems((list) => [...list, { role: 'assistant', content: reply }])
|
|
423
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'assistant', content: reply }])
|
|
381
424
|
}
|
|
382
425
|
} catch {
|
|
383
|
-
setOwnItems((list) => [...list, { role: 'error', content: 'Desculpe, algo falhou.' }])
|
|
426
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: 'Desculpe, algo falhou.' }])
|
|
384
427
|
} finally {
|
|
385
428
|
setOwnBusy(false)
|
|
386
429
|
setOwnActivity(null)
|
|
@@ -395,6 +438,7 @@ export function Chat({
|
|
|
395
438
|
greeting={greeting}
|
|
396
439
|
empty={empty}
|
|
397
440
|
renderArtifact={renderArtifact}
|
|
441
|
+
renderMessageActions={renderMessageActions}
|
|
398
442
|
/>
|
|
399
443
|
{/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
|
|
400
444
|
liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
|
|
@@ -92,6 +92,11 @@ Reidratação: passe `initialMessages` com o histórico persistido e troque a `k
|
|
|
92
92
|
componente ao trocar de conversa. O rótulo do indicador é customizável por
|
|
93
93
|
`humanizeTool={(name) => '…'}`.
|
|
94
94
|
|
|
95
|
+
Itens controlados podem carregar `id` e `createdAt`. O Chat os projeta no elemento da
|
|
96
|
+
mensagem e oferece `renderMessageActions` para ações contextuais discretas. O slot não
|
|
97
|
+
busca nem conhece detalhes: o app deve carregar informação complementar somente após a
|
|
98
|
+
interação da pessoa e aplicar novamente sua autorização.
|
|
99
|
+
|
|
95
100
|
## Propriedades de Chat
|
|
96
101
|
|
|
97
102
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
@@ -109,5 +114,6 @@ componente ao trocar de conversa. O rótulo do indicador é customizável por
|
|
|
109
114
|
| `initialMessages` | `ChatMessage[]` | | Histórico inicial do modo autogerenciado; troque a `key` ao trocar de conversa. |
|
|
110
115
|
| `kickoff` | `() => Promise<string> \| AsyncIterable<ChatEvent>` | | Conversa que começa pelo assistente, uma vez, quando o transcript nasce vazio. |
|
|
111
116
|
| `renderArtifact` | `(artifact: ChatArtifact) => ReactNode` | link com o título | Render do evento `artifact`. |
|
|
117
|
+
| `renderMessageActions` | `(message: ChatTranscriptMessage) => ReactNode` | | Ações contextuais da mensagem; ficam visíveis em hover ou foco. |
|
|
112
118
|
| `humanizeTool` | `(name: string, detail?: string) => string` | pt-BR embutido | Rótulo humano do tool em uso no indicador vivo. |
|
|
113
119
|
| `placeholder` | `string` | `'Escreva uma mensagem…'` | Placeholder do composer. |
|
package/src/ui/react.tsx
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
ChatMessage,
|
|
34
34
|
ChatArtifact,
|
|
35
35
|
ChatTranscriptItem,
|
|
36
|
+
ChatTranscriptMessage,
|
|
36
37
|
} from "./components/primitives/chat.tsx";
|
|
37
38
|
export { Ask } from "./components/primitives/ask.tsx";
|
|
38
39
|
export type { AskProps } from "./components/primitives/ask.tsx";
|