@softize/opus 12.4.0 → 12.5.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 CHANGED
@@ -7,6 +7,29 @@ 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
+ ## 12.5.1 — 2026-08-24
11
+
12
+ `Dock` passa a fazer o roving tabindex que uma `toolbar` promete: a barra tem um único ponto de
13
+ entrada no Tab, e as setas andam entre as ações a partir dele. Antes as setas eram um caminho a
14
+ mais e a barra continuava cobrando um Tab por ícone, que é justamente o que o padrão evita.
15
+
16
+ A mudança é interna ao componente e não altera a API.
17
+
18
+ ## 12.5.0 — 2026-08-24
19
+
20
+ Cache vira porta do protocolo. `CacheAdapter` chega aos handlers como `ctx.cache`, com a superfície
21
+ mínima que um handler usa — `get`, `set`, `delete` — e cresce por reincidência, como a do storage.
22
+ O **miss é o `null`**: quem observa a execução distingue acerto de ausência pelo retorno, sem o
23
+ adapter reportar métrica por fora, e erro de infraestrutura continua sendo exceção — cache
24
+ indisponível não deve virar "não estava em cache".
25
+
26
+ `cache/memory` é o driver de processo, para desenvolvimento e para quem roda numa instância só.
27
+ Expira preguiçosamente (a entrada vencida some quando alguém a procura, sem timer permanente),
28
+ tem teto de entradas para não crescer sem limite dentro de um servidor de longa duração, e expõe
29
+ `sweep()` e `size()` para quem quiser forçar a limpeza ou observar o tamanho.
30
+
31
+ Nada é obrigatório: sem adapter montado, `ctx.cache` é `null` e o handler decide o que fazer.
32
+
10
33
  ## 12.4.0 — 2026-08-24
11
34
 
12
35
  Superfícies de trabalho — canvas, editor, preview — ganham os dois lugares que faltavam. `Dock`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.4.0",
3
+ "version": "12.5.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",
@@ -122,6 +122,14 @@
122
122
  "./storage": "./src/storage/index.ts",
123
123
  "./storage/fs": "./src/storage/drivers/fs.ts",
124
124
  "./storage/s3": "./src/storage/drivers/s3.ts",
125
+ "./cache": {
126
+ "types": "./src/cache/index.ts",
127
+ "default": "./src/cache/index.ts"
128
+ },
129
+ "./cache/memory": {
130
+ "types": "./src/cache/drivers/memory.ts",
131
+ "default": "./src/cache/drivers/memory.ts"
132
+ },
125
133
  "./ai": "./src/ai/index.ts",
126
134
  "./ai/anthropic": "./src/ai/drivers/anthropic.ts",
127
135
  "./mcp": "./src/mcp/index.ts",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Driver de cache em memória do processo.
3
+ *
4
+ * Serve desenvolvimento e o caso de um único processo. Não compartilha nada entre
5
+ * instâncias — quem precisa disso usa um driver distribuído, e a troca é só a montagem.
6
+ *
7
+ * A expiração é preguiçosa: a entrada vencida some quando alguém a procura. Sem varredura
8
+ * de fundo, um cache de processo não justifica um timer permanente; `sweep()` existe para
9
+ * quem quiser forçar a limpeza.
10
+ */
11
+ import type { CacheAdapter, CacheSetOptions } from '../../core/index.ts'
12
+
13
+ interface Entry {
14
+ value: unknown
15
+ /** Epoch em milissegundos; `null` = não expira. */
16
+ expiresAt: number | null
17
+ }
18
+
19
+ export interface MemoryCacheOptions {
20
+ /** Expiração padrão quando a chamada não passa `ttlSeconds`. Ausente = não expira. */
21
+ defaultTtlSeconds?: number
22
+ /** Teto de entradas. Passando dele, a mais antiga sai — um cache de processo não deve
23
+ * crescer sem limite dentro de um servidor de longa duração. */
24
+ maxEntries?: number
25
+ }
26
+
27
+ export interface MemoryCache extends CacheAdapter {
28
+ /** Remove as entradas vencidas agora. */
29
+ sweep(): number
30
+ /** Quantas entradas o cache guarda neste momento. */
31
+ size(): number
32
+ }
33
+
34
+ export function memoryCache(options: MemoryCacheOptions = {}): MemoryCache {
35
+ const entries = new Map<string, Entry>()
36
+ const maxEntries = options.maxEntries ?? 10_000
37
+
38
+ const expired = (entry: Entry, now: number): boolean => entry.expiresAt !== null && entry.expiresAt <= now
39
+
40
+ return {
41
+ name: 'memory',
42
+ kind: 'cache',
43
+ get<T>(key: string): Promise<T | null> {
44
+ const entry = entries.get(key)
45
+ if (entry === undefined) return Promise.resolve(null)
46
+ if (expired(entry, Date.now())) {
47
+ entries.delete(key)
48
+ return Promise.resolve(null)
49
+ }
50
+ return Promise.resolve(entry.value as T)
51
+ },
52
+ set<T>(key: string, value: T, opts: CacheSetOptions = {}): Promise<void> {
53
+ const ttl = opts.ttlSeconds ?? options.defaultTtlSeconds
54
+ // Reinserir move a chave para o fim da ordem do Map, que é o que faz o descarte
55
+ // abaixo tirar a MAIS ANTIGA, e não a que acabou de ser escrita.
56
+ entries.delete(key)
57
+ entries.set(key, { value, expiresAt: ttl === undefined ? null : Date.now() + ttl * 1000 })
58
+ if (entries.size > maxEntries) {
59
+ const oldest = entries.keys().next()
60
+ if (!oldest.done) entries.delete(oldest.value)
61
+ }
62
+ return Promise.resolve()
63
+ },
64
+ delete(key: string): Promise<void> {
65
+ entries.delete(key)
66
+ return Promise.resolve()
67
+ },
68
+ sweep(): number {
69
+ const now = Date.now()
70
+ let removed = 0
71
+ for (const [key, entry] of entries) {
72
+ if (expired(entry, now)) {
73
+ entries.delete(key)
74
+ removed += 1
75
+ }
76
+ }
77
+ return removed
78
+ },
79
+ size: () => entries.size,
80
+ healthCheck: () => Promise.resolve({ ok: true as const, details: { entries: entries.size } }),
81
+ }
82
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @softize/opus/cache — helpers compartilhados dos drivers de cache.
3
+ *
4
+ * EXPERIMENTAL. O contrato (`CacheAdapter`) vive no core; o driver de memória fica em
5
+ * `cache/memory`. A superfície é a mínima que um handler usa — guardar, ler, remover — e
6
+ * cresce por reincidência, não por especulação.
7
+ */
8
+
9
+ export type { CacheAdapter, CacheSetOptions } from '../core/index.ts'
10
+
11
+ /** Monta uma chave estável a partir de partes. Evita a concatenação à mão que produz
12
+ * chaves parecidas e colidentes entre domínios. */
13
+ export function cacheKey(...parts: (string | number)[]): string {
14
+ return parts.map((part) => String(part)).join(':')
15
+ }
package/src/core/index.ts CHANGED
@@ -99,6 +99,8 @@ export type {
99
99
  SchedulerAdapter,
100
100
  ClientAdapter,
101
101
  StorageAdapter,
102
+ CacheAdapter,
103
+ CacheSetOptions,
102
104
  StoragePutOptions,
103
105
  StorageObject,
104
106
  AiAdapter,
@@ -61,6 +61,7 @@ import type {
61
61
  SchedulerAdapter,
62
62
  ServerAdapter,
63
63
  StorageAdapter,
64
+ CacheAdapter,
64
65
  AiAdapter,
65
66
  AiRunOptions,
66
67
  BoundAiRunOptions,
@@ -97,6 +98,8 @@ export interface RuntimeSetup {
97
98
  client?: ClientAdapter
98
99
  /** EXPERIMENTAL — storage de objetos (arquivos); chega nos handlers via `ctx.storage`. */
99
100
  storage?: StorageAdapter
101
+ /** EXPERIMENTAL — cache de leitura; chega nos handlers via `ctx.cache`. */
102
+ cache?: CacheAdapter
100
103
  /** EXPERIMENTAL — IA generativa (complete/extract); chega nos handlers via `ctx.ai`. */
101
104
  ai?: AiAdapter
102
105
 
@@ -243,6 +246,7 @@ export class Runtime {
243
246
  private readonly scheduler: SchedulerAdapter | undefined
244
247
  private readonly client: ClientAdapter | undefined
245
248
  private readonly storage: StorageAdapter | undefined
249
+ private readonly cache: CacheAdapter | undefined
246
250
  private readonly ai: AiAdapter | undefined
247
251
  /** Cache das tools derivadas das actions `ai:enabled` (registry é estático pós-start). */
248
252
  private aiToolsCache: AiTool[] | null = null
@@ -293,6 +297,7 @@ export class Runtime {
293
297
  this.scheduler = setup.scheduler
294
298
  this.client = setup.client
295
299
  this.storage = setup.storage
300
+ this.cache = setup.cache
296
301
  this.ai = setup.ai
297
302
  }
298
303
 
@@ -764,6 +769,7 @@ export class Runtime {
764
769
  if (this.scheduler !== undefined) yield this.scheduler
765
770
  if (this.client !== undefined) yield this.client
766
771
  if (this.storage !== undefined) yield this.storage
772
+ if (this.cache !== undefined) yield this.cache
767
773
  if (this.ai !== undefined) yield this.ai
768
774
  }
769
775
 
@@ -860,6 +866,7 @@ export class Runtime {
860
866
  log,
861
867
  emit: this.buildEmit(action, actionId, base, trace),
862
868
  storage: this.storage ?? null,
869
+ cache: this.cache ?? null,
863
870
  ai: this.bindAi({ ...base, ...(trace !== undefined ? { trace } : {}) }),
864
871
  provenance,
865
872
  ...(trace !== undefined ? { trace } : {}),
@@ -1192,6 +1199,7 @@ export class Runtime {
1192
1199
  log: reactionLog,
1193
1200
  emit: noopEmit,
1194
1201
  storage: this.storage ?? null,
1202
+ cache: this.cache ?? null,
1195
1203
  // Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
1196
1204
  // padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
1197
1205
  ai: this.bindAi({
package/src/core/types.ts CHANGED
@@ -277,6 +277,7 @@ export interface ActionContext {
277
277
  log: Logger
278
278
  emit: EmitFn
279
279
  storage: StorageAdapter | null
280
+ cache: CacheAdapter | null
280
281
  ai: BoundAi | null
281
282
  provenance: Provenance
282
283
  trace?: TraceContext
@@ -294,6 +295,7 @@ export interface ReactionContext {
294
295
  log: Logger
295
296
  emit: EmitFn
296
297
  storage: StorageAdapter | null
298
+ cache: CacheAdapter | null
297
299
  ai: BoundAi | null
298
300
  provenance: Provenance
299
301
  trace?: TraceContext
@@ -941,6 +943,7 @@ export type AdapterKind =
941
943
  | 'scheduler'
942
944
  | 'client'
943
945
  | 'storage'
946
+ | 'cache'
944
947
  | 'ai'
945
948
  | 'ui'
946
949
  | 'schema'
@@ -1167,6 +1170,28 @@ export interface StorageAdapter extends Adapter {
1167
1170
  url(key: string, opts?: { expiresInSeconds?: number }): Promise<string>
1168
1171
  }
1169
1172
 
1173
+ /**
1174
+ * EXPERIMENTAL — cache de leitura. A superfície é a mínima que um handler usa e cresce
1175
+ * por reincidência, como a do storage.
1176
+ *
1177
+ * O MISS é o `null`: quem observa a execução distingue acerto de ausência pelo retorno,
1178
+ * sem o adapter precisar reportar métrica por fora. Erro de infraestrutura é outra coisa
1179
+ * e continua sendo exceção — cache indisponível não deve virar "não estava em cache".
1180
+ */
1181
+ export interface CacheAdapter extends Adapter {
1182
+ kind: 'cache'
1183
+ /** Valor guardado, ou `null` quando a chave não está presente (ou expirou). */
1184
+ get<T>(key: string): Promise<T | null>
1185
+ /** Guarda o valor. Sem `ttlSeconds`, a expiração é a que o driver definir. */
1186
+ set<T>(key: string, value: T, opts?: CacheSetOptions): Promise<void>
1187
+ /** Remove. Idempotente: remover o que não existe não é erro. */
1188
+ delete(key: string): Promise<void>
1189
+ }
1190
+
1191
+ export interface CacheSetOptions {
1192
+ ttlSeconds?: number
1193
+ }
1194
+
1170
1195
  /** Opções por chamada do `AiAdapter` — tudo tem default do driver. */
1171
1196
  export interface AiCompleteOptions {
1172
1197
  /** System prompt da chamada. */
@@ -43,6 +43,7 @@ import type {
43
43
  Paginated,
44
44
  Schema,
45
45
  StorageAdapter,
46
+ CacheAdapter,
46
47
  StorageObject,
47
48
  StoragePutOptions,
48
49
  User,
@@ -72,6 +73,7 @@ export interface TestContextOptions {
72
73
  can?: ActionContext['can']
73
74
  db?: unknown
74
75
  storage?: StorageAdapter | null
76
+ cache?: CacheAdapter | null
75
77
  ai?: AiAdapter | null
76
78
  meta?: Record<string, unknown>
77
79
  trace?: TraceContext
@@ -133,6 +135,7 @@ export function testContext(options: TestContextOptions = {}): TestContext {
133
135
  return Promise.resolve()
134
136
  },
135
137
  storage: options.storage ?? null,
138
+ cache: options.cache ?? null,
136
139
  ai: options.ai ? toBoundAi(options.ai) : null,
137
140
  provenance: { kind: 'system', source: 'test' },
138
141
  ...(options.trace !== undefined ? { trace: options.trace } : {}),
@@ -36,6 +36,18 @@ export interface DockProps {
36
36
  export function Dock({ position = 'bottom', label, className, children }: DockProps): React.ReactElement {
37
37
  const ref = React.useRef<HTMLDivElement>(null)
38
38
 
39
+ // Roving tabindex: uma toolbar tem UM ponto de entrada no Tab; dentro dela, as setas andam.
40
+ // Sem isto as setas seriam um caminho a mais, e a barra continuaria cobrando um Tab por ícone.
41
+ const roving = React.useCallback((focado?: HTMLElement) => {
42
+ const items = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])
43
+ const entrada = items.find((item) => item === focado) ?? items.find((item) => !item.disabled)
44
+ for (const item of items) item.tabIndex = item === entrada ? 0 : -1
45
+ }, [])
46
+
47
+ React.useEffect(() => {
48
+ roving()
49
+ })
50
+
39
51
  // Navegação de toolbar: as setas andam entre as ações, e Home/End vão às pontas. Sem isso
40
52
  // uma barra com dez ícones cobra dez Tabs de quem navega por teclado.
41
53
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
@@ -54,7 +66,10 @@ export function Dock({ position = 'bottom', label, className, children }: DockPr
54
66
  : event.key === 'ArrowRight'
55
67
  ? (current + 1) % items.length
56
68
  : (current - 1 + items.length) % items.length
57
- items[next]?.focus()
69
+ const alvo = items[next]
70
+ if (alvo === undefined) return
71
+ roving(alvo)
72
+ alvo.focus()
58
73
  }
59
74
 
60
75
  return (
@@ -65,6 +80,7 @@ export function Dock({ position = 'bottom', label, className, children }: DockPr
65
80
  aria-label={label}
66
81
  aria-orientation="horizontal"
67
82
  onKeyDown={onKeyDown}
83
+ onFocus={(event) => roving(event.target as HTMLElement)}
68
84
  className={cn(
69
85
  'absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-1.5 overflow-x-auto',
70
86
  'rounded-2xl border border-border bg-background/95 p-1.5 shadow-lg backdrop-blur',