@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 +23 -0
- package/package.json +9 -1
- package/src/cache/drivers/memory.ts +82 -0
- package/src/cache/index.ts +15 -0
- package/src/core/index.ts +2 -0
- package/src/core/runtime.ts +8 -0
- package/src/core/types.ts +25 -0
- package/src/testing/index.ts +3 -0
- package/src/ui/components/patterns/dock.tsx +17 -1
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.
|
|
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
package/src/core/runtime.ts
CHANGED
|
@@ -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. */
|
package/src/testing/index.ts
CHANGED
|
@@ -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]
|
|
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',
|