@softize/opus 8.6.6
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 +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Membros do monorepo: apps (produtos) em apps/*, libs compartilhadas em packages/*.
|
|
2
|
+
packages:
|
|
3
|
+
- 'apps/*'
|
|
4
|
+
- 'packages/*'
|
|
5
|
+
|
|
6
|
+
# Permissão de build scripts (o pnpm bloqueia por default): o postinstall do
|
|
7
|
+
# @softize/opus roda o `opus setup` a cada install; o esbuild (vite) compila o binário.
|
|
8
|
+
allowBuilds:
|
|
9
|
+
'@softize/opus': true
|
|
10
|
+
esbuild: true
|
|
11
|
+
|
|
12
|
+
# A base é do nosso registry (confiada): fora da quarentena de release do pnpm.
|
|
13
|
+
minimumReleaseAgeExclude:
|
|
14
|
+
- '@softize/opus'
|
package/src/ai/ask.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrato de "perguntar à pessoa no meio do turno" (elicitação) — o ponto de CONSISTÊNCIA
|
|
3
|
+
* pra qualquer consumidor de IA do Opus. O driver reserva o nome `ask_user` e, com um
|
|
4
|
+
* `onAsk` (ver `AiRunOptions.onAsk`), faz o round-trip real; sem ele, recusa com erro
|
|
5
|
+
* acionável em vez de deixar a chamada morrer no vazio. Aqui vivem o nome reservado, o
|
|
6
|
+
* schema/descrição da tool e o formatador da resposta.
|
|
7
|
+
*/
|
|
8
|
+
import type { AskQuestion, AskAnswer } from '../core/index.ts'
|
|
9
|
+
|
|
10
|
+
/** Nome reservado da tool de perguntar à pessoa — o driver a intercepta (ver `onAsk`). */
|
|
11
|
+
export const ASK_USER_TOOL = 'ask_user'
|
|
12
|
+
|
|
13
|
+
/** Descrição da tool — instrui o modelo a preferir isto a chutar. */
|
|
14
|
+
export const ASK_TOOL_DESCRIPTION =
|
|
15
|
+
'Faça UMA rodada de perguntas de esclarecimento à pessoa e BLOQUEIE até ela responder. ' +
|
|
16
|
+
'Use quando faltar informação pra decidir (escopo, formato, obrigatoriedade) em vez de assumir. ' +
|
|
17
|
+
'Envie de 1 a 4 perguntas objetivas, cada uma com opções curtas; a ferramenta devolve as escolhas da pessoa.'
|
|
18
|
+
|
|
19
|
+
/** JSON Schema do input do `ask_user` — fonte única (o driver injeta a tool com isto). */
|
|
20
|
+
export const ASK_INPUT_SCHEMA = {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
questions: {
|
|
24
|
+
type: 'array',
|
|
25
|
+
minItems: 1,
|
|
26
|
+
maxItems: 4,
|
|
27
|
+
description: 'De 1 a 4 perguntas objetivas de esclarecimento.',
|
|
28
|
+
items: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
question: { type: 'string', description: 'O enunciado da pergunta.' },
|
|
32
|
+
header: { type: 'string', description: 'Rótulo curto (1-2 palavras) que resume a pergunta.' },
|
|
33
|
+
multiSelect: { type: 'boolean', description: 'Permite marcar mais de uma opção.' },
|
|
34
|
+
options: {
|
|
35
|
+
type: 'array',
|
|
36
|
+
minItems: 2,
|
|
37
|
+
items: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: { label: { type: 'string' }, description: { type: 'string' } },
|
|
40
|
+
required: ['label'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['question', 'options'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ['questions'],
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
/** Texto legível das respostas pro modelo consumir como resultado do tool. */
|
|
52
|
+
export function formatAnswers(questions: AskQuestion[], answers: AskAnswer[]): string {
|
|
53
|
+
return questions
|
|
54
|
+
.map((q, i) => {
|
|
55
|
+
const a = answers[i]
|
|
56
|
+
const label = q.header !== undefined && q.header.length > 0 ? q.header : q.question
|
|
57
|
+
const picked = [
|
|
58
|
+
...(a?.selected ?? []),
|
|
59
|
+
...(a?.text !== undefined && a.text.trim().length > 0 ? [a.text.trim()] : []),
|
|
60
|
+
]
|
|
61
|
+
return `${label}: ${picked.length > 0 ? picked.join(', ') : '(sem resposta)'}`
|
|
62
|
+
})
|
|
63
|
+
.join('\n')
|
|
64
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/ai/anthropic — driver Anthropic (EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* O consumidor pode construir o client (`new Anthropic(...)`) e injetá-lo — em
|
|
5
|
+
* teste, um fake sem rede. Sem client injetado, o driver constrói um sob demanda
|
|
6
|
+
* (peer OPCIONAL `@anthropic-ai/sdk`; `ANTHROPIC_API_KEY` do ambiente vale).
|
|
7
|
+
*
|
|
8
|
+
* `extract` usa tool forçado (`tool_choice: {type: 'tool'}` garante exatamente um
|
|
9
|
+
* bloco `tool_use`) com o JSON Schema derivado do schema zod, e VALIDA a resposta
|
|
10
|
+
* pelo próprio `Schema` do protocolo antes de devolver — o contrato da action é o
|
|
11
|
+
* contrato do modelo. Upgrade futuro: `output_config.format` (SDK ≥0.35, sampling
|
|
12
|
+
* restrito por gramática) quando valer alargar o subset injetável do client.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { zodToJsonSchema } from 'zod-to-json-schema'
|
|
16
|
+
import type {
|
|
17
|
+
AiAdapter,
|
|
18
|
+
AiCompleteOptions,
|
|
19
|
+
AiMessage,
|
|
20
|
+
AiRunOptions,
|
|
21
|
+
AiRunResult,
|
|
22
|
+
AskQuestion,
|
|
23
|
+
ChatEvent,
|
|
24
|
+
Schema,
|
|
25
|
+
} from '../../core/index.ts'
|
|
26
|
+
import { ASK_INPUT_SCHEMA, ASK_TOOL_DESCRIPTION, ASK_USER_TOOL, formatAnswers } from '../ask.ts'
|
|
27
|
+
|
|
28
|
+
/** Injeta a tool reservada `ask_user` no catálogo quando há `onAsk` (round-trip de perguntar
|
|
29
|
+
* à pessoa) — assim o consumidor só precisa passar o handler; a tool aparece pro modelo. */
|
|
30
|
+
function withAskTool(
|
|
31
|
+
tools: Array<{ name: string; description: string; input_schema: unknown }>,
|
|
32
|
+
opts: AiRunOptions,
|
|
33
|
+
): Array<{ name: string; description: string; input_schema: unknown }> {
|
|
34
|
+
if (opts.onAsk === undefined || tools.some((t) => t.name === ASK_USER_TOOL)) return tools
|
|
35
|
+
return [...tools, { name: ASK_USER_TOOL, description: ASK_TOOL_DESCRIPTION, input_schema: ASK_INPUT_SCHEMA }]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Executa uma tool escolhida pelo modelo. A `ask_user` é INTERCEPTADA: com `onAsk`, faz o
|
|
39
|
+
* round-trip real; sem ele, recusa com erro acionável (nunca dead-end silencioso). O resto
|
|
40
|
+
* cai no `execute` do consumidor. */
|
|
41
|
+
async function callTool(name: string, input: unknown, opts: AiRunOptions): Promise<unknown> {
|
|
42
|
+
if (name === ASK_USER_TOOL) {
|
|
43
|
+
const questions = ((input as { questions?: AskQuestion[] })?.questions ?? []) as AskQuestion[]
|
|
44
|
+
if (opts.onAsk !== undefined) {
|
|
45
|
+
const answers = await opts.onAsk(questions)
|
|
46
|
+
return { text: formatAnswers(questions, answers) }
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
error:
|
|
50
|
+
'Não há canal interativo para perguntar à pessoa aqui. Faça a pergunta no seu texto de ' +
|
|
51
|
+
'resposta e siga com o melhor palpite — não trave nem invente que perguntou.',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return opts.execute(name, input)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** O subset do client Anthropic que o driver usa (injetável em teste). O `stream` é
|
|
58
|
+
* opcional: com ele, `runStream` emite texto token a token; sem ele (ex.: fake de
|
|
59
|
+
* teste só com `create`), o texto de cada passo sai como um delta único. */
|
|
60
|
+
export interface AnthropicClientLike {
|
|
61
|
+
messages: {
|
|
62
|
+
create(params: Record<string, unknown>): Promise<unknown>
|
|
63
|
+
stream?(params: Record<string, unknown>): AsyncIterable<unknown> & { finalMessage(): Promise<unknown> }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AnthropicAiOptions {
|
|
68
|
+
/** Client pronto (injetável em teste). Default: `new Anthropic()` do peer, sob demanda. */
|
|
69
|
+
client?: AnthropicClientLike
|
|
70
|
+
/** API key pro client default. Sem ela, o SDK lê `ANTHROPIC_API_KEY` do ambiente. */
|
|
71
|
+
apiKey?: string
|
|
72
|
+
/** Modelo default das chamadas (por chamada via opts). Default: haiku (rápido/barato). */
|
|
73
|
+
model?: string
|
|
74
|
+
/** Teto default de tokens de saída (default 1024). */
|
|
75
|
+
maxTokens?: number
|
|
76
|
+
/** Nome do adapter (default 'anthropic'). */
|
|
77
|
+
name?: string
|
|
78
|
+
/**
|
|
79
|
+
* Conversor schema → JSON Schema do tool (injetável). Default: `zod-to-json-schema`
|
|
80
|
+
* inline (sem $ref) — cobre os schemas zod que as actions da casa usam.
|
|
81
|
+
*/
|
|
82
|
+
toJsonSchema?: (schema: unknown) => unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'
|
|
86
|
+
|
|
87
|
+
// Import sob demanda do SDK (peer opcional): cacheado após o primeiro uso.
|
|
88
|
+
let sdkPromise: Promise<typeof import('@anthropic-ai/sdk')> | null = null
|
|
89
|
+
function sdk(): Promise<typeof import('@anthropic-ai/sdk')> {
|
|
90
|
+
sdkPromise ??= import('@anthropic-ai/sdk').catch(() => {
|
|
91
|
+
sdkPromise = null
|
|
92
|
+
throw new Error('ai/anthropic precisa do peer @anthropic-ai/sdk — `pnpm add @anthropic-ai/sdk`.')
|
|
93
|
+
})
|
|
94
|
+
return sdkPromise
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultToJsonSchema(schema: unknown): unknown {
|
|
98
|
+
return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ContentBlock {
|
|
102
|
+
type: string
|
|
103
|
+
text?: string
|
|
104
|
+
input?: unknown
|
|
105
|
+
id?: string
|
|
106
|
+
name?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
|
|
110
|
+
const model = options.model ?? DEFAULT_ANTHROPIC_MODEL
|
|
111
|
+
const maxTokens = options.maxTokens ?? 1024
|
|
112
|
+
const toJsonSchema = options.toJsonSchema ?? defaultToJsonSchema
|
|
113
|
+
|
|
114
|
+
let clientPromise: Promise<AnthropicClientLike> | null = null
|
|
115
|
+
function client(): Promise<AnthropicClientLike> {
|
|
116
|
+
if (options.client !== undefined) return Promise.resolve(options.client)
|
|
117
|
+
// O create real é overloaded (streaming/non-streaming); o subset injetável cobre o driver.
|
|
118
|
+
clientPromise ??= sdk().then(
|
|
119
|
+
({ default: Anthropic }) =>
|
|
120
|
+
new Anthropic(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}) as never,
|
|
121
|
+
)
|
|
122
|
+
return clientPromise
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function baseParams(opts?: AiCompleteOptions): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
model: opts?.model ?? model,
|
|
128
|
+
max_tokens: opts?.maxTokens ?? maxTokens,
|
|
129
|
+
...(opts?.system !== undefined ? { system: opts.system } : {}),
|
|
130
|
+
...(opts?.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
name: options.name ?? 'anthropic',
|
|
136
|
+
kind: 'ai',
|
|
137
|
+
|
|
138
|
+
async complete(prompt: string, opts?: AiCompleteOptions): Promise<string> {
|
|
139
|
+
const c = await client()
|
|
140
|
+
const response = (await c.messages.create({
|
|
141
|
+
...baseParams(opts),
|
|
142
|
+
messages: [{ role: 'user', content: prompt }],
|
|
143
|
+
})) as { content: ContentBlock[] }
|
|
144
|
+
return response.content
|
|
145
|
+
.filter((b) => b.type === 'text')
|
|
146
|
+
.map((b) => b.text ?? '')
|
|
147
|
+
.join('')
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async extract<T>(prompt: string, schema: Schema<T>, opts?: AiCompleteOptions): Promise<T> {
|
|
151
|
+
const inputSchema = toJsonSchema(schema)
|
|
152
|
+
// A API de tools exige objeto na raiz — schema de action já é (z.object).
|
|
153
|
+
if ((inputSchema as { type?: string }).type !== 'object') {
|
|
154
|
+
throw new Error('ai/anthropic extract: o schema precisa ser um objeto na raiz (ex.: z.object).')
|
|
155
|
+
}
|
|
156
|
+
const c = await client()
|
|
157
|
+
const response = (await c.messages.create({
|
|
158
|
+
...baseParams(opts),
|
|
159
|
+
tools: [
|
|
160
|
+
{
|
|
161
|
+
name: 'extract',
|
|
162
|
+
description: 'Devolve o dado extraído, exatamente no formato pedido.',
|
|
163
|
+
input_schema: inputSchema,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
tool_choice: { type: 'tool', name: 'extract' },
|
|
167
|
+
messages: [{ role: 'user', content: prompt }],
|
|
168
|
+
})) as { content: ContentBlock[] }
|
|
169
|
+
|
|
170
|
+
const toolUse = response.content.find((b) => b.type === 'tool_use')
|
|
171
|
+
if (toolUse === undefined) {
|
|
172
|
+
throw new Error('ai/anthropic extract: resposta sem bloco tool_use (inesperado com tool forçado).')
|
|
173
|
+
}
|
|
174
|
+
const result = await Promise.resolve(schema['~standard'].validate(toolUse.input))
|
|
175
|
+
if ('issues' in result && result.issues !== undefined) {
|
|
176
|
+
const detail = result.issues.map((i) => `${(i.path ?? []).join('.')}: ${i.message}`).join('; ')
|
|
177
|
+
throw new Error(`ai/anthropic extract: resposta do modelo reprovou no schema — ${detail}`)
|
|
178
|
+
}
|
|
179
|
+
return (result as { value: T }).value
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async run(input: string | AiMessage[], opts: AiRunOptions): Promise<AiRunResult> {
|
|
183
|
+
const c = await client()
|
|
184
|
+
const tools = withAskTool(
|
|
185
|
+
opts.tools.map((t) => ({
|
|
186
|
+
name: t.name,
|
|
187
|
+
description: t.description,
|
|
188
|
+
input_schema: toJsonSchema(t.inputSchema),
|
|
189
|
+
})),
|
|
190
|
+
opts,
|
|
191
|
+
)
|
|
192
|
+
// Mensagens no formato Anthropic — crescem com tool_use/tool_result a cada passo.
|
|
193
|
+
const messages: Array<{ role: string; content: unknown }> =
|
|
194
|
+
typeof input === 'string'
|
|
195
|
+
? [{ role: 'user', content: input }]
|
|
196
|
+
: input.map((m) => ({ role: m.role, content: m.content }))
|
|
197
|
+
|
|
198
|
+
const maxSteps = opts.maxSteps ?? 8
|
|
199
|
+
const calls: Array<{ name: string; input: unknown }> = []
|
|
200
|
+
|
|
201
|
+
for (let steps = 1; steps <= maxSteps; steps++) {
|
|
202
|
+
const response = (await c.messages.create({
|
|
203
|
+
...baseParams(opts),
|
|
204
|
+
tools,
|
|
205
|
+
messages,
|
|
206
|
+
})) as { content: ContentBlock[] }
|
|
207
|
+
messages.push({ role: 'assistant', content: response.content })
|
|
208
|
+
|
|
209
|
+
const toolUses = response.content.filter((b) => b.type === 'tool_use')
|
|
210
|
+
if (toolUses.length === 0) {
|
|
211
|
+
const text = response.content
|
|
212
|
+
.filter((b) => b.type === 'text')
|
|
213
|
+
.map((b) => b.text ?? '')
|
|
214
|
+
.join('')
|
|
215
|
+
return { text, steps, calls }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Executa cada tool escolhida e devolve o resultado (ou o erro) pro modelo.
|
|
219
|
+
const results = []
|
|
220
|
+
for (const tu of toolUses) {
|
|
221
|
+
calls.push({ name: tu.name ?? '', input: tu.input })
|
|
222
|
+
let out: unknown
|
|
223
|
+
try {
|
|
224
|
+
out = await callTool(tu.name ?? '', tu.input, opts)
|
|
225
|
+
} catch (err) {
|
|
226
|
+
out = { error: err instanceof Error ? err.message : String(err) }
|
|
227
|
+
}
|
|
228
|
+
results.push({
|
|
229
|
+
type: 'tool_result',
|
|
230
|
+
tool_use_id: tu.id ?? '',
|
|
231
|
+
content: JSON.stringify(out ?? null),
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
messages.push({ role: 'user', content: results })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Bateu o teto de passos sem uma resposta final em texto.
|
|
238
|
+
return { text: 'Não consegui concluir dentro do limite de passos.', steps: maxSteps, calls }
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async *runStream(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent> {
|
|
242
|
+
const c = await client()
|
|
243
|
+
const tools = withAskTool(
|
|
244
|
+
opts.tools.map((t) => ({
|
|
245
|
+
name: t.name,
|
|
246
|
+
description: t.description,
|
|
247
|
+
input_schema: toJsonSchema(t.inputSchema),
|
|
248
|
+
})),
|
|
249
|
+
opts,
|
|
250
|
+
)
|
|
251
|
+
const messages: Array<{ role: string; content: unknown }> =
|
|
252
|
+
typeof input === 'string'
|
|
253
|
+
? [{ role: 'user', content: input }]
|
|
254
|
+
: input.map((m) => ({ role: m.role, content: m.content }))
|
|
255
|
+
|
|
256
|
+
const maxSteps = opts.maxSteps ?? 8
|
|
257
|
+
|
|
258
|
+
for (let steps = 1; steps <= maxSteps; steps++) {
|
|
259
|
+
const params = { ...baseParams(opts), tools, messages }
|
|
260
|
+
let content: ContentBlock[]
|
|
261
|
+
if (typeof c.messages.stream === 'function') {
|
|
262
|
+
// Streaming de verdade: os deltas de texto saem conforme o modelo gera.
|
|
263
|
+
const stream = c.messages.stream(params)
|
|
264
|
+
for await (const event of stream) {
|
|
265
|
+
const e = event as { type?: string; delta?: { type?: string; text?: string } }
|
|
266
|
+
if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
|
|
267
|
+
yield { type: 'text', delta: e.delta.text }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
content = ((await stream.finalMessage()) as { content: ContentBlock[] }).content
|
|
271
|
+
} else {
|
|
272
|
+
// Fallback sem stream (ex.: fake de teste): o texto do passo vira um delta único.
|
|
273
|
+
content = ((await c.messages.create(params)) as { content: ContentBlock[] }).content
|
|
274
|
+
const text = content
|
|
275
|
+
.filter((b) => b.type === 'text')
|
|
276
|
+
.map((b) => b.text ?? '')
|
|
277
|
+
.join('')
|
|
278
|
+
if (text !== '') yield { type: 'text', delta: text }
|
|
279
|
+
}
|
|
280
|
+
messages.push({ role: 'assistant', content })
|
|
281
|
+
|
|
282
|
+
const toolUses = content.filter((b) => b.type === 'tool_use')
|
|
283
|
+
if (toolUses.length === 0) {
|
|
284
|
+
yield { type: 'done', ok: true }
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const results = []
|
|
289
|
+
for (const tu of toolUses) {
|
|
290
|
+
yield { type: 'tool', name: tu.name ?? '' }
|
|
291
|
+
let out: unknown
|
|
292
|
+
try {
|
|
293
|
+
out = await callTool(tu.name ?? '', tu.input, opts)
|
|
294
|
+
} catch (err) {
|
|
295
|
+
out = { error: err instanceof Error ? err.message : String(err) }
|
|
296
|
+
}
|
|
297
|
+
results.push({
|
|
298
|
+
type: 'tool_result',
|
|
299
|
+
tool_use_id: tu.id ?? '',
|
|
300
|
+
content: JSON.stringify(out ?? null),
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
messages.push({ role: 'user', content: results })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
yield { type: 'done', ok: false, error: 'Não consegui concluir dentro do limite de passos.' }
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/ai — a capability de IA generativa (EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* O contrato (`AiAdapter`) vive no core: `complete` (texto → texto) e `extract`
|
|
5
|
+
* (texto → objeto VALIDADO por um `Schema` do protocolo — o mesmo tipo de schema
|
|
6
|
+
* das actions vira o contrato da resposta do modelo). Driver em `ai/anthropic`.
|
|
7
|
+
* A superfície é a mínima de backoffice e cresce por reincidência — não por
|
|
8
|
+
* especulação (streaming, chat multi-turn e tools livres entram quando um caso
|
|
9
|
+
* real cobrar).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type { AiAdapter, AiCompleteOptions } from '../core/index.ts'
|
|
13
|
+
// Elicitação — perguntar à pessoa no meio do turno (round-trip via `AiRunOptions.onAsk`).
|
|
14
|
+
// O contrato (tipos) mora no core; aqui saem o nome reservado, o schema/descrição da tool e
|
|
15
|
+
// o formatador. É o ponto de consistência pra qualquer consumidor (chat, copilot, MCP externo).
|
|
16
|
+
export type { AskOption, AskQuestion, AskAnswer } from '../core/index.ts'
|
|
17
|
+
export { ASK_USER_TOOL, ASK_TOOL_DESCRIPTION, ASK_INPUT_SCHEMA, formatAnswers } from './ask.ts'
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/audit/console — Console sink driver
|
|
3
|
+
*
|
|
4
|
+
* Imprime `AuditRecord` no stdout/stderr. Útil em dev pra inspeção
|
|
5
|
+
* imediata e em testes; em produção, prefira sinks persistentes (/pg,
|
|
6
|
+
* sentry, datadog).
|
|
7
|
+
*
|
|
8
|
+
* Formatos:
|
|
9
|
+
* - 'pretty' — multi-linha com cores ANSI (dev default)
|
|
10
|
+
* - 'json' — single-line JSON (prod default; parseável)
|
|
11
|
+
*
|
|
12
|
+
* Default: 'pretty' se TTY, 'json' caso contrário.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AuditRecord, AuditSink } from '../../core/index.ts'
|
|
16
|
+
import { redactRecord } from '../index.ts'
|
|
17
|
+
|
|
18
|
+
export interface ConsoleAuditOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Formato de output. Default: 'pretty' se process.stdout.isTTY, senão 'json'.
|
|
21
|
+
*/
|
|
22
|
+
format?: 'pretty' | 'json'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Override do logger usado pra escrever. Default: console.log / console.error.
|
|
26
|
+
* Útil pra capturar em testes ou redirecionar.
|
|
27
|
+
*/
|
|
28
|
+
write?: (level: 'info' | 'error', line: string) => void
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Nome do sink (default 'console'). Útil quando registrando múltiplos
|
|
32
|
+
* sinks console pra contextos diferentes.
|
|
33
|
+
*/
|
|
34
|
+
name?: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Redator aplicado a input/output antes de imprimir (ex.: `redactDeep` de
|
|
38
|
+
* `@softize/opus/audit`) — terminal/log agregado também vaza senha.
|
|
39
|
+
*/
|
|
40
|
+
redact?: (value: unknown) => unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function consoleAudit(options: ConsoleAuditOptions = {}): AuditSink {
|
|
44
|
+
const {
|
|
45
|
+
format = defaultFormat(),
|
|
46
|
+
write = defaultWrite,
|
|
47
|
+
name = 'console',
|
|
48
|
+
redact,
|
|
49
|
+
} = options
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
kind: 'audit',
|
|
54
|
+
emit(record: AuditRecord) {
|
|
55
|
+
const r = redact === undefined ? record : redactRecord(record, redact)
|
|
56
|
+
const line = format === 'pretty' ? renderPretty(r) : renderJson(r)
|
|
57
|
+
const level = r.outcome === 'error' ? 'error' : 'info'
|
|
58
|
+
write(level, line)
|
|
59
|
+
},
|
|
60
|
+
healthCheck() {
|
|
61
|
+
return Promise.resolve({ ok: true })
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// Format helpers
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
function defaultFormat(): 'pretty' | 'json' {
|
|
71
|
+
/* v8 ignore next — ambiente Node sem stdout.isTTY (raro) */
|
|
72
|
+
if (typeof process === 'undefined' || process.stdout === undefined) return 'json'
|
|
73
|
+
return process.stdout.isTTY === true ? 'pretty' : 'json'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultWrite(level: 'info' | 'error', line: string): void {
|
|
77
|
+
const target = level === 'error' ? console.error : console.log
|
|
78
|
+
target(line)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderJson(record: AuditRecord): string {
|
|
82
|
+
return JSON.stringify(record)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderPretty(record: AuditRecord): string {
|
|
86
|
+
const tag = record.outcome === 'success' ? ANSI.green('✓') : ANSI.red('✗')
|
|
87
|
+
const sev = paintSeverity(record.severity)
|
|
88
|
+
const head = `${tag} ${ANSI.bold(record.action)} ${ANSI.dim(record.id)}`
|
|
89
|
+
const meta = [
|
|
90
|
+
`${ANSI.dim('outcome:')} ${record.outcome}`,
|
|
91
|
+
`${ANSI.dim('severity:')} ${sev}`,
|
|
92
|
+
`${ANSI.dim('duration:')} ${record.durationMs.toFixed(2)}ms`,
|
|
93
|
+
`${ANSI.dim('actor:')} ${record.actor.id ?? 'system'}`,
|
|
94
|
+
...(record.tenant !== null && record.tenant !== undefined
|
|
95
|
+
? [`${ANSI.dim('tenant:')} ${record.tenant}`]
|
|
96
|
+
: []),
|
|
97
|
+
].join(' ')
|
|
98
|
+
return `${head}\n ${meta}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function paintSeverity(s: AuditRecord['severity']): string {
|
|
102
|
+
if (s === 'error') return ANSI.red(s)
|
|
103
|
+
if (s === 'warning') return ANSI.yellow(s)
|
|
104
|
+
return ANSI.green(s)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// ANSI helpers (zero-dep)
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
const ANSI = {
|
|
112
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
113
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
114
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
115
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
116
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
117
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/audit/pg — PostgreSQL sink driver
|
|
3
|
+
*
|
|
4
|
+
* Persiste `AuditRecord` numa tabela Postgres via `pg.Pool`. Schema esperado:
|
|
5
|
+
*
|
|
6
|
+
* CREATE TABLE audit_log (
|
|
7
|
+
* id uuid PRIMARY KEY,
|
|
8
|
+
* timestamp timestamptz NOT NULL,
|
|
9
|
+
* action text NOT NULL,
|
|
10
|
+
* outcome text NOT NULL,
|
|
11
|
+
* duration_ms numeric NOT NULL,
|
|
12
|
+
* provenance_kind text NOT NULL, -- discriminator (http, schedule, reaction, ...)
|
|
13
|
+
* provenance jsonb NOT NULL, -- objeto completo da Provenance (incl. chain)
|
|
14
|
+
* actor_id text,
|
|
15
|
+
* actor_type text,
|
|
16
|
+
* tenant text,
|
|
17
|
+
* input jsonb,
|
|
18
|
+
* output jsonb,
|
|
19
|
+
* error jsonb,
|
|
20
|
+
* severity text NOT NULL,
|
|
21
|
+
* trace_request_id text,
|
|
22
|
+
* trace_parent_action_id text,
|
|
23
|
+
* meta jsonb
|
|
24
|
+
* );
|
|
25
|
+
* CREATE INDEX audit_provenance_kind_idx ON audit_log(provenance_kind);
|
|
26
|
+
*
|
|
27
|
+
* Tabela é criada pelo consumer (migration). Adapter apenas insere.
|
|
28
|
+
*
|
|
29
|
+
* Uso:
|
|
30
|
+
* import { Pool } from 'pg'
|
|
31
|
+
* import { pgAudit } from '@softize/opus/audit/pg'
|
|
32
|
+
*
|
|
33
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
34
|
+
* createRuntime({ audit: pgAudit({ pool, table: 'audit_log' }) })
|
|
35
|
+
*
|
|
36
|
+
* Com dado sensível, plugue o redator global (recursivo, por nome de chave):
|
|
37
|
+
*
|
|
38
|
+
* import { redactDeep } from '@softize/opus/audit'
|
|
39
|
+
* pgAudit({ pool, redact: redactDeep })
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { AuditRecord, AuditSink, HealthStatus } from '../../core/index.ts'
|
|
43
|
+
import { redactRecord } from '../index.ts'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Subset mínimo de `pg.Pool` que precisamos. Aceitar qualquer objeto com
|
|
47
|
+
* `query()` evita dep direta de `pg` no tipo público; aceita pools-like
|
|
48
|
+
* (e.g., wrappers de Neon, Supabase pooler).
|
|
49
|
+
*/
|
|
50
|
+
export interface PgQueryable {
|
|
51
|
+
query(text: string, values?: unknown[]): Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PgAuditOptions {
|
|
55
|
+
/** Pool ou conexão pg-compatível. */
|
|
56
|
+
pool: PgQueryable
|
|
57
|
+
|
|
58
|
+
/** Nome da tabela. Default 'audit_log'. */
|
|
59
|
+
table?: string
|
|
60
|
+
|
|
61
|
+
/** Nome do sink (default 'pg'). */
|
|
62
|
+
name?: string
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Redator aplicado a input/output ANTES de persistir — sem ele, os dois vão
|
|
66
|
+
* CRUS (user.create/setPassword gravaria senha em claro no log). Pra dado
|
|
67
|
+
* sensível, plugue o `redactDeep` de `@softize/opus/audit` (recursivo, por
|
|
68
|
+
* nome de chave) ou um redator próprio. O `AuditConfig.redact` por action
|
|
69
|
+
* (dot-paths) continua valendo — este é o cinto de segurança global do sink.
|
|
70
|
+
*/
|
|
71
|
+
redact?: (value: unknown) => unknown
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function pgAudit(options: PgAuditOptions): AuditSink {
|
|
75
|
+
const { pool, table = 'audit_log', name = 'pg', redact } = options
|
|
76
|
+
validateTableName(table)
|
|
77
|
+
const sql = buildInsertSql(table)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
kind: 'audit',
|
|
82
|
+
|
|
83
|
+
async emit(record: AuditRecord) {
|
|
84
|
+
const values = recordToValues(redact === undefined ? record : redactRecord(record, redact))
|
|
85
|
+
await pool.query(sql, values)
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
89
|
+
try {
|
|
90
|
+
await pool.query('SELECT 1')
|
|
91
|
+
return { ok: true }
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { ok: false, details: { error: String(err) } }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// SQL builder
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
const COLUMNS = [
|
|
104
|
+
'id',
|
|
105
|
+
'timestamp',
|
|
106
|
+
'action',
|
|
107
|
+
'outcome',
|
|
108
|
+
'duration_ms',
|
|
109
|
+
'provenance_kind',
|
|
110
|
+
'provenance',
|
|
111
|
+
'actor_id',
|
|
112
|
+
'actor_type',
|
|
113
|
+
'tenant',
|
|
114
|
+
'input',
|
|
115
|
+
'output',
|
|
116
|
+
'error',
|
|
117
|
+
'severity',
|
|
118
|
+
'trace_request_id',
|
|
119
|
+
'trace_parent_action_id',
|
|
120
|
+
'meta',
|
|
121
|
+
] as const
|
|
122
|
+
|
|
123
|
+
function buildInsertSql(table: string): string {
|
|
124
|
+
const cols = COLUMNS.join(', ')
|
|
125
|
+
const params = COLUMNS.map((_, i) => `$${i + 1}`).join(', ')
|
|
126
|
+
return `INSERT INTO ${table} (${cols}) VALUES (${params})`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function recordToValues(r: AuditRecord): unknown[] {
|
|
130
|
+
return [
|
|
131
|
+
r.id,
|
|
132
|
+
r.timestamp,
|
|
133
|
+
r.action,
|
|
134
|
+
r.outcome,
|
|
135
|
+
r.durationMs,
|
|
136
|
+
r.provenance.kind,
|
|
137
|
+
jsonOrNull(r.provenance),
|
|
138
|
+
r.actor.id,
|
|
139
|
+
r.actor.type ?? null,
|
|
140
|
+
r.tenant ?? null,
|
|
141
|
+
jsonOrNull(r.input),
|
|
142
|
+
jsonOrNull(r.output),
|
|
143
|
+
jsonOrNull(r.error),
|
|
144
|
+
r.severity,
|
|
145
|
+
r.trace?.requestId ?? null,
|
|
146
|
+
r.trace?.parentActionId ?? null,
|
|
147
|
+
jsonOrNull(r.meta),
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function jsonOrNull(value: unknown): string | null {
|
|
152
|
+
if (value === undefined || value === null) return null
|
|
153
|
+
return JSON.stringify(value)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// Safety
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Valida que `table` é um identificador SQL seguro. Aceita
|
|
162
|
+
* `schema.table` opcional. Lança se conter caracteres suspeitos —
|
|
163
|
+
* preveni injection já que table name não pode ser parametrizado.
|
|
164
|
+
*/
|
|
165
|
+
function validateTableName(table: string): void {
|
|
166
|
+
if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i.test(table)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`pgAudit: invalid table name "${table}". ` +
|
|
169
|
+
`Must match /^[a-z_][a-z0-9_]*(\\.[a-z_][a-z0-9_]*)?$/i.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|