@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/assistant-schema.d.mts +55 -0
  4. package/bin/lib/assistant-schema.mjs +286 -0
  5. package/bin/lib/check.mjs +173 -0
  6. package/bin/lib/copy.mjs +2 -0
  7. package/bin/lib/gen-manifest.mjs +4 -0
  8. package/bin/lib/gen-runner.mjs +31 -2
  9. package/bin/lib/gen-stubs.mjs +3 -0
  10. package/bin/lib/mcp.mjs +1 -1
  11. package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
  12. package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
  13. package/docs/protocol.md +59 -1
  14. package/package.json +5 -1
  15. package/registry/skills/create-opus-action/SKILL.md +8 -3
  16. package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
  17. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  18. package/registry/skills/test-opus-action/SKILL.md +4 -1
  19. package/registry/skills/test-opus-action/references/evaluations.md +2 -0
  20. package/registry/skills/test-opus-action/references/harness.md +14 -0
  21. package/src/ai/index.ts +3 -0
  22. package/src/ai/resource-context.ts +113 -0
  23. package/src/core/actions.ts +5 -2
  24. package/src/core/assistant.ts +545 -0
  25. package/src/core/contracts.ts +2 -0
  26. package/src/core/index.ts +23 -0
  27. package/src/core/list-grouping.ts +45 -0
  28. package/src/core/runtime.ts +138 -0
  29. package/src/core/types.ts +39 -0
  30. package/src/mcp/index.ts +3 -0
  31. package/src/ui/components/patterns/grouped-list.ts +88 -0
  32. package/src/ui/components/patterns/list.tsx +395 -61
  33. package/src/ui/docs/content/action-list.md +42 -9
  34. package/src/ui/docs/content/actions.md +97 -1
  35. package/src/ui/docs/content/ai.md +4 -2
  36. package/src/ui/docs/content/cli.md +2 -1
  37. package/src/ui/docs/content/mcp.md +3 -1
  38. package/src/ui/drivers/context.tsx +50 -0
  39. package/src/ui/drivers/react.tsx +9 -69
  40. package/src/ui/meta.ts +1 -1
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { isReaction } from './reactions.ts'
15
15
  import { isBackgroundAction } from './actions.ts'
16
+ import { assertValidListGrouping } from './list-grouping.ts'
16
17
  import { isSchedule } from './schedules.ts'
17
18
  import {
18
19
  collectDomainWarnings,
@@ -25,6 +26,17 @@ import { createPurgeHandler, entityTableName, type PurgeHandler } from './retent
25
26
  import type { DataProductConfig } from './data-product.ts'
26
27
  import { AuditEmitter } from './audit.ts'
27
28
  import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
29
+ import {
30
+ assertAssistantDeclaration,
31
+ projectAssistantContext,
32
+ publicAssistant,
33
+ resolveAssistantTitle,
34
+ type AssistantOmission,
35
+ type ReadResourceOptions,
36
+ type ResourceContext,
37
+ type RegisteredAssistant,
38
+ type ResourceReadResult,
39
+ } from './assistant.ts'
28
40
  import { error, isActionError, normalizeError } from './errors.ts'
29
41
  import { normalizeTraceContext } from './trace.ts'
30
42
  import { isSystemActor, markSystemActor } from './system-actor.ts'
@@ -78,6 +90,7 @@ import type {
78
90
  BoundAi,
79
91
  AiTool,
80
92
  AIConfig,
93
+ ActionAssistant,
81
94
  User,
82
95
  TraceContext,
83
96
  } from './types.ts'
@@ -321,6 +334,8 @@ export class Runtime {
321
334
  private aiToolsCache: AiTool[] | null = null
322
335
 
323
336
  private readonly actions = new Map<string, ActionDef>()
337
+ /** A action dona de cada tipo de recurso com assistente (ADR 0053). */
338
+ private readonly assistantOwners = new Map<string, ActionDef & { assistant: ActionAssistant }>()
324
339
  private readonly reactions = new Map<string, ReactionDef<any>>()
325
340
  private readonly schedules = new Map<string, ScheduleDef>()
326
341
  private readonly dataProducts = new Map<string, DataProductConfig>()
@@ -532,11 +547,34 @@ export class Runtime {
532
547
  'Use simple ou form para escrever (ADR 0041).',
533
548
  })
534
549
  }
550
+ this.claimAssistant(action)
551
+ if (action.kind === 'list') assertValidListGrouping(action)
535
552
  this.actions.set(action.name, action)
536
553
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
537
554
  }
538
555
  }
539
556
 
557
+ /**
558
+ * Valida a declaração do assistente e reserva o tipo de recurso para a action (ADR 0053). Duas
559
+ * actions donas do mesmo recurso são recusadas com `runtime.assistant_duplicate_resource`.
560
+ */
561
+ private claimAssistant(action: ActionDef): void {
562
+ assertAssistantDeclaration(action)
563
+ const assistant = (action as { assistant?: ActionAssistant }).assistant
564
+ if (assistant === undefined) return
565
+ const owner = this.assistantOwners.get(assistant.resource)
566
+ if (owner !== undefined) {
567
+ throw error({
568
+ code: 'runtime.assistant_duplicate_resource',
569
+ category: 'internal',
570
+ message:
571
+ `O recurso "${assistant.resource}" já tem a action dona "${owner.name}"; ` +
572
+ `"${action.name}" não pode declarar o mesmo resource (ADR 0053).`,
573
+ })
574
+ }
575
+ this.assistantOwners.set(assistant.resource, action as ActionDef & { assistant: ActionAssistant })
576
+ }
577
+
540
578
  /**
541
579
  * `emits` e `background` pertencem ao binding de qualquer kind pelo tipo, mas o runtime e o
542
580
  * manifest só os aplicam em `simple` e `form`. Restringir o tipo quebraria bindings
@@ -726,6 +764,103 @@ export class Runtime {
726
764
  return this.executeAction(action, input, ctxBase, 'request')
727
765
  }
728
766
 
767
+ /**
768
+ * Lê um recurso pela action dona do tipo (ADR 0053). Executa a action com `{ id }` pelo mesmo
769
+ * `execute` de qualquer chamada: autorização, loaders, auditoria, marca de dado pessoal e span são
770
+ * os da action. Em caso de sucesso, devolve o descritor e, com `{ context: true }`, a projeção
771
+ * limitada que o host pode entregar ao modelo. Em caso de falha, devolve o erro da action. Um tipo
772
+ * sem action dona devolve `runtime.assistant_resource_unknown`, sem executar nada.
773
+ */
774
+ async readResource(
775
+ resource: string,
776
+ id: string,
777
+ ctxBase: ContextBase,
778
+ options: ReadResourceOptions = {},
779
+ ): Promise<ResourceReadResult> {
780
+ const owner = this.assistantOwners.get(resource)
781
+ if (owner === undefined) {
782
+ return {
783
+ ok: false,
784
+ error: error({
785
+ code: 'runtime.assistant_resource_unknown',
786
+ category: 'validation',
787
+ // O tipo veio de fora: a mensagem é fixa, e o valor recebido fica em `meta`.
788
+ message: 'O tipo de recurso informado não tem action dona.',
789
+ meta: { resource },
790
+ }),
791
+ }
792
+ }
793
+ const result = await this.execute(owner.name, { id }, ctxBase)
794
+ if (!result.ok) return { ok: false, error: result.error, meta: result.meta }
795
+
796
+ const { assistant } = owner
797
+ let title: string | null
798
+ let omitted: AssistantOmission[]
799
+ let context: ResourceContext | undefined
800
+ try {
801
+ if (options.context === true) {
802
+ // A projeção já resolve o título; sem contexto pedido, basta o título, sem pagar o corte.
803
+ const projection = projectAssistantContext(assistant, result.data)
804
+ title = projection.title
805
+ omitted = projection.omitted
806
+ context = {
807
+ data: projection.data,
808
+ truncated: projection.truncated,
809
+ omitted: projection.omitted,
810
+ personalData: owner.personalData === true,
811
+ }
812
+ } else {
813
+ ;({ title, omitted } = resolveAssistantTitle(assistant, result.data))
814
+ }
815
+ } catch (thrown) {
816
+ // A action executou e foi auditada; o que falhou foi transformar a saída em dado ao modelo
817
+ // (por exemplo, `BigInt` ou referência circular, que o JSON não representa).
818
+ this.log.error('A saída da action dona não pôde ser projetada para o assistente.', {
819
+ code: 'runtime.assistant_projection_failed',
820
+ action: owner.name,
821
+ resource,
822
+ error: thrown instanceof Error ? thrown.message : String(thrown),
823
+ })
824
+ return {
825
+ ok: false,
826
+ error: error({
827
+ code: 'runtime.assistant_projection_failed',
828
+ category: 'internal',
829
+ message: 'Não foi possível preparar os dados deste recurso para o assistente.',
830
+ cause: thrown,
831
+ }),
832
+ meta: result.meta,
833
+ }
834
+ }
835
+ const missing = omitted.filter((entry) => entry.reason === 'missing').map((entry) => entry.path)
836
+ if (missing.length > 0) {
837
+ this.log.warn(
838
+ `A saída de "${owner.name}" não trouxe ${missing.map((path) => `\`${path}\``).join(', ')}, ` +
839
+ 'declarado no assistente, como texto ou valor esperado; o recurso foi lido sem esses campos.',
840
+ { code: 'runtime.assistant_path_missing', action: owner.name, resource, paths: missing },
841
+ )
842
+ }
843
+ return {
844
+ ok: true,
845
+ resource: { kind: resource, id, title, label: assistant.label, skill: assistant.skill },
846
+ ...(context !== undefined ? { context } : {}),
847
+ meta: result.meta,
848
+ }
849
+ }
850
+
851
+ /**
852
+ * As declarações de assistente registradas, com a action dona de cada uma (ADR 0053). Serve ao
853
+ * consumidor para montar rotas e telas a partir da declaração e para conferir as chaves de
854
+ * procedimento contra o registro que ele mantém.
855
+ */
856
+ assistants(): RegisteredAssistant[] {
857
+ return [...this.assistantOwners.values()].map((owner) => ({
858
+ action: owner.name,
859
+ ...publicAssistant(owner.assistant),
860
+ personalData: owner.personalData === true,
861
+ }))
862
+ }
863
+
729
864
  /**
730
865
  * Executa no worker um envelope criado por `execute()`. O consumer fornece
731
866
  * auth/can reidratados; identidade, provenance e trace vêm do envelope.
@@ -1026,6 +1161,9 @@ export class Runtime {
1026
1161
  metadata: {
1027
1162
  ...(effect !== undefined ? { effect } : {}),
1028
1163
  ...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
1164
+ ...(action.kind === 'view' && action.assistant !== undefined
1165
+ ? { assistant: publicAssistant(action.assistant) }
1166
+ : {}),
1029
1167
  dataProducts,
1030
1168
  dataProductLabels: Object.fromEntries(
1031
1169
  dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
package/src/core/types.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * 13. RuntimeConfig (§14)
22
22
  */
23
23
 
24
+ import type { AssistantDeclaration } from './assistant.ts'
24
25
  import type { SoftDeleteEntity } from './retention.ts'
25
26
 
26
27
  // =============================================================================
@@ -548,6 +549,30 @@ export interface AIConfig {
548
549
  requiresConfirmation?: boolean
549
550
  }
550
551
 
552
+ /**
553
+ * O assistente do recurso que uma action `view` lê (ADR 0053). A action que o declara é a action
554
+ * dona do tipo de recurso: é por ela que o recurso é lido e que o acesso a ele é conferido. A
555
+ * declaração é serializável e não carrega função, consulta nem regra de acesso.
556
+ */
557
+ export interface ActionAssistant {
558
+ /** O tipo de recurso de que a action é dona, em camelCase e único no runtime (`customer`). */
559
+ resource: string
560
+ /** A chave do procedimento que a IA segue ao tratar do recurso, em minúsculas com hífen. */
561
+ skill: string
562
+ /** O nome do assistente para pessoas (`Assistente do cliente`). */
563
+ label: string
564
+ /** O caminho, na saída, do campo que nomeia o recurso para pessoas (`name`). */
565
+ title: string
566
+ /**
567
+ * Os caminhos, na saída, dos campos que o host pode entregar ao modelo como dado do recurso. O
568
+ * ponto desce em objetos (`leads.totalCount`); um caminho que passa por uma lista se aplica a cada
569
+ * item.
570
+ */
571
+ context: readonly string[]
572
+ /** Teto, em caracteres, do JSON de `context`. Padrão de 4.000, mínimo de 1.000, máximo de 16.000. */
573
+ maxContextChars?: number
574
+ }
575
+
551
576
  export interface Example {
552
577
  name: string
553
578
  description?: string
@@ -899,6 +924,9 @@ export interface SearchFilters {
899
924
  paginate?: 'cursor' | 'offset' | false
900
925
  text?: { fields: string[]; placeholder?: I18nRef }
901
926
  periods?: PeriodSpec[]
927
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. Cada nome
928
+ * referencia um filtro select com opções static ou dictionary. */
929
+ grouping?: { fields: string[] }
902
930
  /**
903
931
  * Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
904
932
  * `'NOT archived'`). Runtime NÃO executa SQL — quem consome a search
@@ -931,6 +959,8 @@ export interface ListAction<In, Out, ParsedIn = In>
931
959
  paginate?: 'cursor' | 'offset' | false
932
960
  text?: { fields: string[]; placeholder?: I18nRef }
933
961
  periods?: PeriodSpec[]
962
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. */
963
+ grouping?: { fields: string[] }
934
964
  /**
935
965
  * Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
936
966
  * via `compileWhere` (do `@softize/opus/dsl`). Runtime não executa SQL.
@@ -943,6 +973,12 @@ export interface ViewAction<In, Out, ParsedIn = In> extends ActionBase<In, Out,
943
973
  /** Entidades relacionadas projetáveis. View simples não projeta nada — omita. */
944
974
  projection?: string[]
945
975
  expand?: Record<string, ExpandSpec>
976
+ /**
977
+ * Torna esta action a dona de um tipo de recurso e declara o assistente dele (ADR 0053). Exige
978
+ * `ai` habilitado, `personalData` declarado e uma entrada em que `id`, do tipo texto, é o único
979
+ * campo obrigatório. O runtime lê o recurso por `runtime.readResource`.
980
+ */
981
+ assistant?: ActionAssistant
946
982
  }
947
983
 
948
984
  /**
@@ -1341,6 +1377,9 @@ export interface AiTool {
1341
1377
  effect?: ActionEffect
1342
1378
  /** A action declara devolver dado pessoal; ausente quando não declarou. */
1343
1379
  personalData?: boolean
1380
+ /** O assistente do recurso de que a action é dona (ADR 0053), com o teto resolvido; ausente
1381
+ * quando não declara. */
1382
+ assistant?: AssistantDeclaration
1344
1383
  }
1345
1384
  }
1346
1385
 
package/src/mcp/index.ts CHANGED
@@ -58,6 +58,9 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
58
58
  ...(t.metadata?.personalData !== undefined
59
59
  ? { 'com.softize.opus/personal-data': t.metadata.personalData }
60
60
  : {}),
61
+ // O assistente do recurso de que a action é dona (ADR 0053): o host conhece o assistente
62
+ // pelo catálogo que já consulta.
63
+ ...(t.metadata?.assistant !== undefined ? { 'com.softize.opus/assistant': t.metadata.assistant } : {}),
61
64
  },
62
65
  })),
63
66
  }))
@@ -0,0 +1,88 @@
1
+ import { useQueries } from '@tanstack/react-query'
2
+ import { useRef } from 'react'
3
+ import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
4
+ import { useClient } from '../../drivers/context.tsx'
5
+
6
+ export interface ListActionGroup<TItem> {
7
+ value: string
8
+ items: TItem[]
9
+ total: number | undefined
10
+ error: ActionError | undefined
11
+ isLoading: boolean
12
+ isFetching: boolean
13
+ isError: boolean
14
+ refetch: () => Promise<void>
15
+ }
16
+
17
+ /** Implementação interna do ActionList para executar um recorte paginado por grupo. */
18
+ export function useGroupedListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
19
+ action: { name: string; kind: 'list' },
20
+ input: TInput,
21
+ field: string | null,
22
+ values: string[],
23
+ multiple: boolean,
24
+ pages: Record<string, number>,
25
+ pageSize: number,
26
+ ): ListActionGroup<TItem>[] {
27
+ const client = useClient('ActionList grouping')
28
+ const previous = useRef<{
29
+ field: string | null
30
+ groups: Map<string, { items: TItem[]; total: number }>
31
+ }>({ field, groups: new Map() })
32
+ if (previous.current.field !== field) {
33
+ previous.current = { field, groups: new Map() }
34
+ }
35
+ const groupField = field ?? '__disabled_group__'
36
+ const requests =
37
+ field === null
38
+ ? []
39
+ : values.flatMap((value) =>
40
+ Array.from({ length: pages[value] ?? 1 }, (_, index) => ({
41
+ value,
42
+ page: index + 1,
43
+ })),
44
+ )
45
+ const queries = useQueries({
46
+ queries: requests.map(({ value, page }) => {
47
+ const groupInput = {
48
+ ...input,
49
+ [groupField]: multiple ? [value] : value,
50
+ page,
51
+ limit: pageSize,
52
+ }
53
+ return {
54
+ queryKey: [action.name, groupInput],
55
+ queryFn: async (): Promise<Paginated<TItem>> => {
56
+ const result = await client.run<Paginated<TItem>>(action as unknown as ActionDef, groupInput)
57
+ if (!result.ok) throw result.error
58
+ return result.data
59
+ },
60
+ }
61
+ }),
62
+ })
63
+
64
+ return values.map((value) => {
65
+ const groupQueries = queries.filter((_, index) => requests[index]?.value === value)
66
+ const items = groupQueries.flatMap((query) => query.data?.items ?? [])
67
+ const total = groupQueries.find((query) => query.data?.total !== undefined)?.data?.total
68
+ const isError = groupQueries.some((query) => query.isError)
69
+ const hasCompleteData = groupQueries.length > 0 && groupQueries.every((query) => query.data !== undefined)
70
+ if (hasCompleteData && !isError && total !== undefined) {
71
+ previous.current.groups.set(value, { items, total })
72
+ }
73
+ const fallback = previous.current.groups.get(value)
74
+ const hasCurrentData = groupQueries.some((query) => query.data !== undefined)
75
+ return {
76
+ value,
77
+ items: hasCurrentData ? items : (fallback?.items ?? []),
78
+ total: hasCurrentData ? total : fallback?.total,
79
+ error: (groupQueries.find((query) => query.error !== null)?.error as unknown as ActionError | null) ?? undefined,
80
+ isLoading: (groupQueries[0]?.isPending ?? true) && fallback === undefined,
81
+ isFetching: groupQueries.some((query) => query.isFetching),
82
+ isError,
83
+ refetch: async () => {
84
+ await Promise.all(groupQueries.map((query) => query.refetch()))
85
+ },
86
+ }
87
+ })
88
+ }