@softize/opus 9.0.8 → 9.1.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @softize/opus/observability/opentelemetry
3
+ *
4
+ * Driver fino sobre `@opentelemetry/api`: não cria provider, exporter, collector nem
5
+ * context manager. O app configura seu SDK OTel e pode injetar um Tracer nos testes.
6
+ */
7
+
8
+ import {
9
+ context,
10
+ createTraceState,
11
+ SpanKind,
12
+ SpanStatusCode,
13
+ trace,
14
+ TraceFlags,
15
+ type Attributes,
16
+ type Span,
17
+ type SpanContext,
18
+ type Tracer,
19
+ } from '@opentelemetry/api'
20
+ import type {
21
+ ActionResult,
22
+ HealthStatus,
23
+ ObservabilityAdapter,
24
+ ObservabilityOperation,
25
+ TraceContext,
26
+ } from '../../core/index.ts'
27
+
28
+ export interface OpenTelemetryObservabilityOptions {
29
+ /** Tracer injetado; default usa o provider global configurado pelo app. */
30
+ tracer?: Tracer
31
+ /** Nome do instrumentation scope. */
32
+ instrumentationName?: string
33
+ /** Versão do instrumentation scope. */
34
+ instrumentationVersion?: string
35
+ /** Nome do adapter no health agregado. */
36
+ name?: string
37
+ /** Shutdown opt-in de recurso possuído pelo caller; nunca desliga provider global sozinho. */
38
+ shutdown?: () => Promise<void> | void
39
+ /** Health adicional do wiring/exporter possuído pelo caller. */
40
+ healthCheck?: () => Promise<HealthStatus> | HealthStatus
41
+ }
42
+
43
+ export function openTelemetryObservability(
44
+ options: OpenTelemetryObservabilityOptions = {},
45
+ ): ObservabilityAdapter {
46
+ const {
47
+ name = 'opentelemetry',
48
+ instrumentationName = '@softize/opus',
49
+ instrumentationVersion,
50
+ } = options
51
+ const tracer = options.tracer ?? trace.getTracer(instrumentationName, instrumentationVersion)
52
+
53
+ return {
54
+ name,
55
+ kind: 'observability',
56
+
57
+ async runInSpan<T>(
58
+ operation: ObservabilityOperation,
59
+ run: (traceContext: TraceContext) => Promise<T>,
60
+ ): Promise<T> {
61
+ const parent = parentContext(operation.parent)
62
+ return tracer.startActiveSpan(
63
+ spanName(operation),
64
+ {
65
+ kind: SpanKind.INTERNAL,
66
+ attributes: operationAttributes(operation),
67
+ },
68
+ parent,
69
+ async (span) => runOperation(operation, span, run),
70
+ )
71
+ },
72
+
73
+ async healthCheck(): Promise<HealthStatus> {
74
+ if (options.healthCheck !== undefined) return options.healthCheck()
75
+ return { ok: true, details: { api: 'configured' } }
76
+ },
77
+
78
+ async dispose(): Promise<void> {
79
+ await options.shutdown?.()
80
+ },
81
+ }
82
+ }
83
+
84
+ async function runOperation<T>(
85
+ operation: ObservabilityOperation,
86
+ span: Span,
87
+ run: (traceContext: TraceContext) => Promise<T>,
88
+ ): Promise<T> {
89
+ const spanContext = span.spanContext()
90
+ if (!trace.isSpanContextValid(spanContext)) {
91
+ span.end()
92
+ throw new Error(
93
+ 'OpenTelemetry tracer is not recording valid spans; configure a TracerProvider before the Opus runtime',
94
+ )
95
+ }
96
+ try {
97
+ const result = await run(fromSpanContext(spanContext))
98
+ const failed = operation.resultKind === 'action-result' && isFailedActionResult(result)
99
+ span.setAttribute('opus.outcome', failed ? 'error' : 'success')
100
+ if (failed) {
101
+ span.setAttribute('opus.error.category', result.error.category)
102
+ span.setStatus({ code: SpanStatusCode.ERROR })
103
+ } else {
104
+ span.setStatus({ code: SpanStatusCode.OK })
105
+ }
106
+ return result
107
+ } catch (err) {
108
+ span.setAttribute('opus.outcome', 'error')
109
+ span.setStatus({ code: SpanStatusCode.ERROR })
110
+ throw err
111
+ } finally {
112
+ span.end()
113
+ }
114
+ }
115
+
116
+ function parentContext(parent: TraceContext | undefined) {
117
+ if (parent === undefined) return context.active()
118
+ const spanContext = toSpanContext(parent)
119
+ return trace.isSpanContextValid(spanContext)
120
+ ? trace.setSpanContext(context.active(), spanContext)
121
+ : context.active()
122
+ }
123
+
124
+ function toSpanContext(value: TraceContext): SpanContext {
125
+ return {
126
+ traceId: value.traceId,
127
+ spanId: value.spanId ?? '0000000000000000',
128
+ traceFlags: value.traceFlags ?? TraceFlags.NONE,
129
+ ...(value.traceState !== undefined ? { traceState: createTraceState(value.traceState) } : {}),
130
+ isRemote: true,
131
+ }
132
+ }
133
+
134
+ function fromSpanContext(value: SpanContext): TraceContext {
135
+ const traceState = value.traceState?.serialize()
136
+ return {
137
+ traceId: value.traceId,
138
+ spanId: value.spanId,
139
+ traceFlags: value.traceFlags,
140
+ ...(traceState !== undefined && traceState.length > 0 ? { traceState } : {}),
141
+ }
142
+ }
143
+
144
+ function spanName(operation: ObservabilityOperation): string {
145
+ return `opus.${operation.kind} ${operation.name}`
146
+ }
147
+
148
+ /** Allowlist de baixa cardinalidade; IDs de execução/atores nunca viram atributos. */
149
+ function operationAttributes(operation: ObservabilityOperation): Attributes {
150
+ const attributes: Attributes = {
151
+ 'opus.operation.kind': operation.kind,
152
+ 'opus.result.kind': operation.resultKind,
153
+ }
154
+ const actionKind = operation.attributes?.['opus.action.kind']
155
+ if (typeof actionKind === 'string') attributes['opus.action.kind'] = actionKind
156
+ const eventType = operation.attributes?.['opus.event.type']
157
+ if (typeof eventType === 'string') attributes['opus.event.type'] = eventType
158
+ return attributes
159
+ }
160
+
161
+ function isFailedActionResult(value: unknown): value is Extract<ActionResult<unknown>, { ok: false }> {
162
+ return typeof value === 'object' && value !== null && 'ok' in value && value.ok === false &&
163
+ 'error' in value && typeof value.error === 'object' && value.error !== null &&
164
+ 'category' in value.error && typeof value.error.category === 'string'
165
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @softize/opus/observability — superfície dos drivers de observabilidade.
3
+ *
4
+ * A porta vendor-neutral (`ObservabilityAdapter`, `TraceContext`) permanece no core.
5
+ * Drivers concretos vivem em subpaths, como `./opentelemetry`.
6
+ */
7
+
8
+ export {}
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * Worker (consumer-side) é responsabilidade do consumer — esse adapter
8
8
  * só **enfileira** e **consulta**. Pra processar, o consumer registra
9
- * um `new Worker(queueName, processor, { connection })` que chama
10
- * `runtime.execute()` no callback.
9
+ * um `new Worker(queueName, processor, { connection })`, reidrata auth e chama
10
+ * `runtime.executeJob()` no callback.
11
11
  *
12
12
  * Uso:
13
13
  * import { Queue } from 'bullmq'
@@ -23,6 +23,7 @@ import type {
23
23
  JobStatus,
24
24
  QueueAdapter,
25
25
  } from '../../core/index.ts'
26
+ import { normalizeTraceContext } from '../../core/index.ts'
26
27
 
27
28
  // =============================================================================
28
29
  // BullMQ-compatible interfaces (zero direct dep no tipo público)
@@ -58,11 +59,18 @@ export interface BullMQQueue {
58
59
  add(
59
60
  name: string,
60
61
  data: unknown,
61
- opts?: { jobId?: string },
62
+ opts?: BullMQAddOptions,
62
63
  ): Promise<BullMQJob>
63
64
  getJob(jobId: string): Promise<BullMQJob | undefined | null>
64
65
  }
65
66
 
67
+ export interface BullMQAddOptions {
68
+ jobId?: string
69
+ attempts?: number
70
+ priority?: number
71
+ backoff?: { type: 'fixed' | 'exponential'; delay: number }
72
+ }
73
+
66
74
  // =============================================================================
67
75
  // Adapter factory
68
76
  // =============================================================================
@@ -80,8 +88,8 @@ export function bullmqQueue(options: BullmqQueueOptions): QueueAdapter {
80
88
  kind: 'queue',
81
89
 
82
90
  async enqueue(spec: JobSpec): Promise<JobHandle> {
83
- const job = await queue.add(spec.action, spec, { jobId: spec.jobId })
84
- return jobToHandle(spec.action, job, 'queued')
91
+ const job = await queue.add(spec.action, spec, bullmqOptions(spec))
92
+ return jobToHandle(spec.action, job, 'queued', normalizeTraceContext(spec.ctx.trace))
85
93
  },
86
94
 
87
95
  async status(jobId: string): Promise<JobHandle | null> {
@@ -101,6 +109,31 @@ export function bullmqQueue(options: BullmqQueueOptions): QueueAdapter {
101
109
  }
102
110
  }
103
111
 
112
+ function bullmqOptions(spec: JobSpec): BullMQAddOptions {
113
+ const options: BullMQAddOptions = { jobId: spec.jobId }
114
+ if (spec.config.priority !== undefined) {
115
+ options.priority = { high: 1, normal: 5, low: 10 }[spec.config.priority]
116
+ }
117
+ const retry = spec.config.retry
118
+ if (retry !== undefined) {
119
+ options.attempts = retry.attempts
120
+ if (retry.backoff?.kind === 'fixed') {
121
+ options.backoff = { type: 'fixed', delay: retry.backoff.delayMs }
122
+ } else if (retry.backoff?.kind === 'exponential') {
123
+ if (
124
+ (retry.backoff.multiplier !== undefined && retry.backoff.multiplier !== 2) ||
125
+ retry.backoff.maxMs !== undefined
126
+ ) {
127
+ throw new Error(
128
+ 'BullMQ native exponential backoff supports multiplier 2 without maxMs; configure a worker backoffStrategy for custom behavior',
129
+ )
130
+ }
131
+ options.backoff = { type: 'exponential', delay: retry.backoff.initialMs }
132
+ }
133
+ }
134
+ return options
135
+ }
136
+
104
137
  // =============================================================================
105
138
  // Mapping
106
139
  // =============================================================================
@@ -136,6 +169,7 @@ function jobToHandle(
136
169
  action: string,
137
170
  job: BullMQJob,
138
171
  status: JobStatus,
172
+ trace = readTrace(job),
139
173
  ): JobHandle {
140
174
  const handle: JobHandle = {
141
175
  jobId: job.id ?? '',
@@ -144,6 +178,7 @@ function jobToHandle(
144
178
  enqueuedAt: toIso(job.timestamp),
145
179
  attempts: job.attemptsMade,
146
180
  }
181
+ if (trace !== undefined) handle.trace = trace
147
182
  if (job.processedOn !== undefined) handle.startedAt = toIso(job.processedOn)
148
183
  if (job.finishedOn !== undefined) handle.finishedAt = toIso(job.finishedOn)
149
184
  if (status === 'done' && job.returnvalue !== undefined) handle.data = job.returnvalue
@@ -161,6 +196,13 @@ function jobToHandle(
161
196
  return handle
162
197
  }
163
198
 
199
+ function readTrace(job: BullMQJob): JobHandle['trace'] | undefined {
200
+ if (typeof job.data !== 'object' || job.data === null || !('ctx' in job.data)) return undefined
201
+ const ctx = (job.data as { ctx?: unknown }).ctx
202
+ if (typeof ctx !== 'object' || ctx === null || !('trace' in ctx)) return undefined
203
+ return normalizeTraceContext((ctx as { trace?: unknown }).trace)
204
+ }
205
+
164
206
  function readAction(job: BullMQJob): string {
165
207
  if (
166
208
  typeof job.data === 'object' &&
@@ -35,12 +35,14 @@ import {
35
35
  } from '../../schema/openapi.ts'
36
36
  import {
37
37
  httpStatusFor,
38
+ extractW3CTraceContext,
38
39
  inputSourceFor,
39
40
  methodFor,
40
41
  pathFor,
41
42
  serializeResult,
42
43
  shouldMountPublicly,
43
44
  successStatusFor,
45
+ w3cTraceResponseHeaders,
44
46
  } from '../index.ts'
45
47
 
46
48
  // =============================================================================
@@ -62,6 +64,9 @@ export interface FastifyServerOptions {
62
64
 
63
65
  /** Servers do OpenAPI document. */
64
66
  openapiServers?: OpenAPIServer[]
67
+
68
+ /** Propagação HTTP opt-in. `'w3c'` extrai/injeta traceparent + tracestate. */
69
+ traceContext?: false | 'w3c'
65
70
  }
66
71
 
67
72
  // =============================================================================
@@ -74,6 +79,7 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
74
79
  apiPrefix = '/api',
75
80
  openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
76
81
  openapiServers,
82
+ traceContext = false,
77
83
  } = options
78
84
  let runtime: RuntimeRef | undefined
79
85
  const mountedActions: ActionDef[] = []
@@ -104,7 +110,7 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
104
110
 
105
111
  const handler = async (req: FastifyRequest, reply: FastifyReply) => {
106
112
  const rt = requireRuntime()
107
- const ctx = await resolveCtx(rt, req)
113
+ const ctx = await resolveCtx(rt, req, traceContext)
108
114
  const input = source === 'body' ? req.body : req.query
109
115
 
110
116
  const result = await rt.execute(action.name, input, ctx)
@@ -112,6 +118,11 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
112
118
  ? successStatusFor(action)
113
119
  : httpStatusFor(result.error)
114
120
 
121
+ if (traceContext === 'w3c') {
122
+ for (const [key, value] of Object.entries(w3cTraceResponseHeaders(result.meta.trace))) {
123
+ reply.header(key, value)
124
+ }
125
+ }
115
126
  reply.code(status).send(serializeResult(result))
116
127
  }
117
128
 
@@ -138,11 +149,15 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
138
149
  async function resolveCtx(
139
150
  rt: RuntimeRef,
140
151
  req: FastifyRequest,
152
+ traceContext: false | 'w3c',
141
153
  ): Promise<ContextBase> {
142
154
  const base = await resolveAuthBase(rt, req)
143
155
  // Fastify sempre popula req.id (auto-gen ou via genReqId opt). Não é optional.
144
156
  return {
145
157
  ...base,
158
+ ...(traceContext === 'w3c'
159
+ ? { trace: extractW3CTraceContext(req.headers as Record<string, string | string[] | undefined>) }
160
+ : {}),
146
161
  requestId: req.id,
147
162
  provenance: {
148
163
  kind: 'http',
@@ -42,12 +42,14 @@ import {
42
42
  } from '../../schema/openapi.ts'
43
43
  import {
44
44
  httpStatusFor,
45
+ extractW3CTraceContext,
45
46
  inputSourceFor,
46
47
  methodFor,
47
48
  pathFor,
48
49
  serializeResult,
49
50
  shouldMountPublicly,
50
51
  successStatusFor,
52
+ w3cTraceResponseHeaders,
51
53
  } from '../index.ts'
52
54
 
53
55
  // =============================================================================
@@ -72,6 +74,9 @@ export interface NodeServerOptions {
72
74
 
73
75
  /** Servers do OpenAPI document. */
74
76
  openapiServers?: OpenAPIServer[]
77
+
78
+ /** Propagação HTTP opt-in. `'w3c'` extrai/injeta traceparent + tracestate. */
79
+ traceContext?: false | 'w3c'
75
80
  }
76
81
 
77
82
  export interface NodeServerHandle {
@@ -98,6 +103,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
98
103
  endpoints,
99
104
  openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
100
105
  openapiServers,
106
+ traceContext = false,
101
107
  } = options
102
108
 
103
109
  let runtime: RuntimeRef | undefined
@@ -142,7 +148,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
142
148
  routes.set(`${method} ${path}`, async (req, res, url) => {
143
149
  const rt = requireRuntime()
144
150
  const requestId = crypto.randomUUID()
145
- const ctx = await resolveCtx(rt, req, requestId)
151
+ const ctx = await resolveCtx(rt, req, requestId, traceContext)
146
152
 
147
153
  let input: unknown
148
154
  if (source === 'body') {
@@ -177,7 +183,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
177
183
  ? successStatusFor(action)
178
184
  : httpStatusFor(result.error)
179
185
 
180
- sendResult(res, status, result)
186
+ sendResult(res, status, result, traceContext === 'w3c')
181
187
  })
182
188
  },
183
189
 
@@ -235,10 +241,14 @@ async function resolveCtx(
235
241
  rt: RuntimeRef,
236
242
  req: IncomingMessage,
237
243
  requestId: string,
244
+ traceContext: false | 'w3c',
238
245
  ): Promise<ContextBase> {
239
246
  const base = await resolveAuthBase(rt, req)
240
247
  return {
241
248
  ...base,
249
+ ...(traceContext === 'w3c'
250
+ ? { trace: extractW3CTraceContext(req.headers) }
251
+ : {}),
242
252
  requestId,
243
253
  provenance: {
244
254
  kind: 'http',
@@ -275,9 +285,19 @@ async function readBody(req: IncomingMessage): Promise<string> {
275
285
  return body
276
286
  }
277
287
 
278
- function sendResult(res: ServerResponse, status: number, result: ActionResult<unknown>): void {
288
+ function sendResult(
289
+ res: ServerResponse,
290
+ status: number,
291
+ result: ActionResult<unknown>,
292
+ injectTrace = false,
293
+ ): void {
279
294
  res.statusCode = status
280
295
  res.setHeader('content-type', 'application/json; charset=utf-8')
296
+ if (injectTrace) {
297
+ for (const [key, value] of Object.entries(w3cTraceResponseHeaders(result.meta.trace))) {
298
+ res.setHeader(key, value)
299
+ }
300
+ }
281
301
  res.end(JSON.stringify(serializeResult(result)))
282
302
  }
283
303
 
@@ -15,8 +15,65 @@ import type {
15
15
  ActionError,
16
16
  ActionResult,
17
17
  ErrorCategory,
18
+ TraceContext,
18
19
  } from '../core/index.ts'
19
20
 
21
+ // =============================================================================
22
+ // W3C trace context
23
+ // =============================================================================
24
+
25
+ export type HttpHeaderValue = string | string[] | undefined
26
+
27
+ /** Extrai somente traceparent v00 válido; tracestate é opcional e limitado a 512 bytes. */
28
+ export function extractW3CTraceContext(
29
+ headers: Record<string, HttpHeaderValue>,
30
+ ): TraceContext | undefined {
31
+ const raw = firstHeader(headers['traceparent'])
32
+ if (raw === undefined) return undefined
33
+ const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i.exec(raw.trim())
34
+ if (match === null) return undefined
35
+ const traceId = match[1]?.toLowerCase()
36
+ const spanId = match[2]?.toLowerCase()
37
+ const flags = match[3]
38
+ if (traceId === undefined || spanId === undefined || flags === undefined) return undefined
39
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return undefined
40
+ const traceState = validTraceState(firstHeader(headers['tracestate']))
41
+ return {
42
+ traceId,
43
+ spanId,
44
+ traceFlags: Number.parseInt(flags, 16),
45
+ ...(traceState !== undefined ? { traceState } : {}),
46
+ }
47
+ }
48
+
49
+ export function w3cTraceResponseHeaders(
50
+ traceContext: TraceContext | undefined,
51
+ ): Record<string, string> {
52
+ if (traceContext === undefined || !validTraceIds(traceContext)) return {}
53
+ const flags = (traceContext.traceFlags ?? 0) & 0xff
54
+ return {
55
+ traceparent: `00-${traceContext.traceId}-${traceContext.spanId}-${flags.toString(16).padStart(2, '0')}`,
56
+ ...(validTraceState(traceContext.traceState) !== undefined
57
+ ? { tracestate: traceContext.traceState as string }
58
+ : {}),
59
+ }
60
+ }
61
+
62
+ function firstHeader(value: HttpHeaderValue): string | undefined {
63
+ return Array.isArray(value) ? (value.length === 1 ? value[0] : undefined) : value
64
+ }
65
+
66
+ function validTraceState(value: string | undefined): string | undefined {
67
+ if (value === undefined || value.length === 0 || value.length > 512) return undefined
68
+ return /[^\x20-\x7e]/.test(value) ? undefined : value
69
+ }
70
+
71
+ function validTraceIds(value: TraceContext): value is TraceContext & { spanId: string } {
72
+ return /^[0-9a-f]{32}$/i.test(value.traceId) && !/^0+$/.test(value.traceId) &&
73
+ typeof value.spanId === 'string' && /^[0-9a-f]{16}$/i.test(value.spanId) &&
74
+ !/^0+$/.test(value.spanId)
75
+ }
76
+
20
77
  // =============================================================================
21
78
  // HTTP status mapping
22
79
  // =============================================================================
@@ -46,6 +46,7 @@ import type {
46
46
  StorageObject,
47
47
  StoragePutOptions,
48
48
  User,
49
+ TraceContext,
49
50
  } from '../core/index.ts'
50
51
 
51
52
  // =============================================================================
@@ -73,6 +74,7 @@ export interface TestContextOptions {
73
74
  storage?: StorageAdapter | null
74
75
  ai?: AiAdapter | null
75
76
  meta?: Record<string, unknown>
77
+ trace?: TraceContext
76
78
  }
77
79
 
78
80
  export interface TestContext {
@@ -133,6 +135,7 @@ export function testContext(options: TestContextOptions = {}): TestContext {
133
135
  storage: options.storage ?? null,
134
136
  ai: options.ai ? toBoundAi(options.ai) : null,
135
137
  provenance: { kind: 'system', source: 'test' },
138
+ ...(options.trace !== undefined ? { trace: options.trace } : {}),
136
139
  meta: options.meta ?? {},
137
140
  }
138
141
  return { ctx, emitted, logged }
@@ -212,7 +212,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
212
212
  })
213
213
 
214
214
  /**
215
- * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
215
+ * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha;
216
+ * com o campo vazio, ↑/↓ navegam pelas mensagens anteriores do usuário),
216
217
  * transcript em turnos, fala do usuário em fundo discreto, assistente em Markdown direto
217
218
  * no corpo, tool como indicador vivo
218
219
  * (nunca mensagem) e artefatos via `renderArtifact`. Dois modos:
@@ -246,11 +247,52 @@ export function Chat({
246
247
  const [input, setInput] = React.useState('')
247
248
  const [ownBusy, setOwnBusy] = React.useState(false)
248
249
  const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
250
+ const historyCursor = React.useRef<number | null>(null)
251
+ const historyDraft = React.useRef('')
249
252
 
250
253
  const items = controlled ? messages : ownItems
251
254
  const busy = controlled ? (busyProp ?? false) : ownBusy
252
255
  // Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
253
256
  const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
257
+ const inputHistory = React.useMemo(
258
+ () => items.filter((item): item is ChatMessage => item.role === 'user').map((item) => item.content),
259
+ [items],
260
+ )
261
+
262
+ function changeInput(value: string): void {
263
+ historyCursor.current = null
264
+ setInput(value)
265
+ }
266
+
267
+ function showPreviousInput(): boolean {
268
+ if (inputHistory.length === 0) return false
269
+
270
+ if (historyCursor.current === null) {
271
+ // Não rouba ↑ do cursor em mensagens novas ou multilinha. A navegação começa no
272
+ // composer vazio, como em shells; depois disso ↑/↓ pertencem ao histórico.
273
+ if (input !== '') return false
274
+ historyDraft.current = input
275
+ historyCursor.current = inputHistory.length - 1
276
+ } else if (historyCursor.current > 0) {
277
+ historyCursor.current -= 1
278
+ }
279
+
280
+ setInput(inputHistory[historyCursor.current])
281
+ return true
282
+ }
283
+
284
+ function showNextInput(): boolean {
285
+ if (historyCursor.current === null) return false
286
+
287
+ if (historyCursor.current < inputHistory.length - 1) {
288
+ historyCursor.current += 1
289
+ setInput(inputHistory[historyCursor.current])
290
+ } else {
291
+ historyCursor.current = null
292
+ setInput(historyDraft.current)
293
+ }
294
+ return true
295
+ }
254
296
 
255
297
  const kickoffRan = React.useRef(false)
256
298
  React.useEffect(() => {
@@ -264,6 +306,8 @@ export function Chat({
264
306
  async function submit(): Promise<void> {
265
307
  const text = input.trim()
266
308
  if (text === '' || busy) return
309
+ historyCursor.current = null
310
+ historyDraft.current = ''
267
311
  setInput('')
268
312
 
269
313
  if (controlled) {
@@ -339,8 +383,10 @@ export function Chat({
339
383
  {notice}
340
384
  <Composer
341
385
  value={input}
342
- onChange={setInput}
386
+ onChange={changeInput}
343
387
  onSubmit={() => void submit()}
388
+ onHistoryPrevious={showPreviousInput}
389
+ onHistoryNext={showNextInput}
344
390
  busy={busy}
345
391
  placeholder={placeholder}
346
392
  actions={composerActions}
@@ -12,6 +12,10 @@ export interface ComposerProps {
12
12
  onChange: (value: string) => void
13
13
  /** Enter (sem Shift) ou o botão enviar. Só dispara quando dá pra enviar (ver `submitDisabled`). */
14
14
  onSubmit: () => void
15
+ /** Navegação opcional pelo histórico do dono do composer. Retorne `true` quando a tecla
16
+ * foi consumida; o `<Chat>` usa isso para ↑/↓ sem interferir no cursor normal. */
17
+ onHistoryPrevious?: () => boolean
18
+ onHistoryNext?: () => boolean
15
19
  /** Trava o composer enquanto o turno/ação corre — o enviar vira spinner. */
16
20
  busy?: boolean
17
21
  /** Gate EXTRA de envio além de "vazio" e "busy" (ex.: falta escolher o app). Desabilita o
@@ -39,6 +43,8 @@ export function Composer({
39
43
  value,
40
44
  onChange,
41
45
  onSubmit,
46
+ onHistoryPrevious,
47
+ onHistoryNext,
42
48
  busy = false,
43
49
  submitDisabled = false,
44
50
  placeholder = 'Escreva uma mensagem…',
@@ -59,6 +65,14 @@ export function Composer({
59
65
  value={value}
60
66
  onChange={(e) => onChange(e.target.value)}
61
67
  onKeyDown={(e) => {
68
+ if (e.key === 'ArrowUp' && onHistoryPrevious?.()) {
69
+ e.preventDefault()
70
+ return
71
+ }
72
+ if (e.key === 'ArrowDown' && onHistoryNext?.()) {
73
+ e.preventDefault()
74
+ return
75
+ }
62
76
  if (e.key === 'Enter' && !e.shiftKey) {
63
77
  e.preventDefault()
64
78
  fire()
@@ -31,6 +31,8 @@ interface AuditRecord {
31
31
  output?: unknown
32
32
  error?: ActionError
33
33
  severity: 'info' | 'warning' | 'error'
34
+ trace?: { requestId?: string; parentActionId?: string } // legado
35
+ traceContext?: TraceContext // vendor-neutral
34
36
  meta?: Record<string, unknown>
35
37
  }
36
38
  ```
@@ -48,6 +50,21 @@ const dev = consoleAudit()
48
50
  const prod = pgAudit({ pool, table: 'audit_log' })
49
51
  ```
50
52
 
53
+ O default continua compatível com a tabela legada. Para persistir o contexto novo, primeiro
54
+ adicione colunas próprias e depois habilite o opt-in — não reutilize as colunas legadas:
55
+
56
+ ```sql
57
+ ALTER TABLE audit_log ADD COLUMN trace_id text;
58
+ ALTER TABLE audit_log ADD COLUMN span_id text;
59
+ ```
60
+
61
+ ```ts
62
+ const prod = pgAudit({ pool, traceContextColumns: true })
63
+ ```
64
+
65
+ Rollouts com nomes existentes podem usar
66
+ `traceContextColumns: { traceId: 'otel_trace_id', spanId: 'otel_span_id' }`.
67
+
51
68
  ## Dado sensível: o redator global
52
69
 
53
70
  Sem redator, os sinks persistem input e output **crus** — `user.create`/`setPassword`
@@ -1,6 +1,6 @@
1
1
  ## Básico
2
2
 
3
- Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
3
+ Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. Com o composer vazio, **↑** recupera as mensagens anteriores do usuário e **↓** volta em direção ao rascunho; durante a edição, as setas continuam movendo o cursor normalmente. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
4
4
 
5
5
  ```tsx preview
6
6
  <div className="h-96 rounded-lg border">