@fugood/buttress-server 2.25.10 → 2.25.12
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/README.md +184 -6
- package/config/function-samples/README.md +1 -0
- package/config/function-samples/classify-ticket.ts +73 -0
- package/config/sample.toml +37 -0
- package/lib/agent/codemode-worker.d.ts +1 -0
- package/lib/agent/codemode.d.ts +7 -0
- package/lib/agent/decisions.check.d.ts +1 -0
- package/lib/agent/decisions.d.ts +113 -0
- package/lib/agent/mcp.d.ts +2 -3
- package/lib/agent/models.d.ts +4 -2
- package/lib/agent/sessions.d.ts +21 -8
- package/lib/agent/types.d.ts +30 -0
- package/lib/{cli-CtBbHMYQ.mjs → cli-CIa2rDj7.mjs} +2 -2
- package/lib/{client-DzfRSFcJ.mjs → client-BVyIuSyr.mjs} +1 -1
- package/lib/codemode-CRowB9aL.mjs +112 -0
- package/lib/codemode-worker.mjs +3 -0
- package/lib/config-cxApm_PZ.mjs +2 -0
- package/lib/functions/templates.d.ts +1 -1
- package/lib/functions/types.d.ts +7 -1
- package/lib/index.d.ts +4 -2
- package/lib/index.mjs +169 -72
- package/lib/mcp-DzknURyz.mjs +5 -0
- package/lib/mlx-bridge.py +47 -11
- package/lib/protocol-WkPByalT.mjs +468 -0
- package/lib/quickjs.wasm +0 -0
- package/lib/routes/decision-shared.d.ts +28 -0
- package/lib/routes/generator-cache.d.ts +3 -1
- package/lib/routes/index.d.ts +1 -0
- package/lib/routes/llm-shared.d.ts +8 -0
- package/lib/routes/openai-compat.d.ts +13 -2
- package/lib/routes/openai-decisions.check.d.ts +1 -0
- package/lib/routes/openai-decisions.d.ts +97 -0
- package/lib/routes/tts-shared.d.ts +5 -0
- package/lib/routes/typesafe-compat.check.d.ts +1 -0
- package/lib/routes/typesafe-compat.d.ts +30 -0
- package/lib/services/ggml-decision.d.ts +91 -0
- package/lib/services/ggml-llm.d.ts +34 -1
- package/lib/services/index.d.ts +3 -0
- package/lib/services/register-tts-result.d.ts +4 -0
- package/lib/{tui-DbQ0zW-C.mjs → tui-Bxeb1d7E.mjs} +1 -1
- package/lib/types.d.ts +31 -1
- package/lib/utils/SessionFileManager.d.ts +12 -4
- package/lib/utils/cors.d.ts +1 -0
- package/lib/utils/decision-sharing.d.ts +37 -0
- package/package.json +9 -8
- package/public/status.html +104 -1
- package/lib/agent/session-fs.d.ts +0 -3
- package/lib/config-DbRjQnNp.mjs +0 -2
package/README.md
CHANGED
|
@@ -162,6 +162,7 @@ Configuration is loaded from a TOML file passed via `--config` / `-c`. Every top
|
|
|
162
162
|
| `[autodiscover]` | LAN UDP / HTTP / mDNS discovery toggles |
|
|
163
163
|
| `[openai_compat]` | Enable `/oai-compat/v1/*` — see [Compatibility Endpoints](#compatibility-endpoints-experimental) |
|
|
164
164
|
| `[anthropic_messages]` | Enable `/anthropic-messages` — see [Compatibility Endpoints](#compatibility-endpoints-experimental)|
|
|
165
|
+
| `[typesafe_compat]` | Enable `/typesafe-compat/v1/*` — see [Compatibility Endpoints](#compatibility-endpoints-experimental) |
|
|
165
166
|
| `[functions]` | Enable local functions — see [Local Functions](#local-functions-experimental) |
|
|
166
167
|
| `[[agents]]` | Config-defined agents — see [Agents](#agents-experimental) |
|
|
167
168
|
| `[[generators]]` | Array of generator instances — one entry per loaded model |
|
|
@@ -241,7 +242,7 @@ Every generator entry has a `type`, an optional `[generators.backend]` table, an
|
|
|
241
242
|
|
|
242
243
|
```toml
|
|
243
244
|
[[generators]]
|
|
244
|
-
type = "ggml-llm" # or "ggml-stt" / "ggml-tts" / "mlx-llm"
|
|
245
|
+
type = "ggml-llm" # or "ggml-stt" / "ggml-tts" / "ggml-decision" / "mlx-llm"
|
|
245
246
|
|
|
246
247
|
[generators.backend]
|
|
247
248
|
# (see per-type sections below)
|
|
@@ -261,7 +262,7 @@ Shared by **all** generator types:
|
|
|
261
262
|
| `revision` | string | Default `"main"` |
|
|
262
263
|
| `download` | boolean | Pre-download at server startup (default `false`) |
|
|
263
264
|
|
|
264
|
-
Additional keys honored by **ggml-llm**, **ggml-stt** and **ggml-
|
|
265
|
+
Additional keys honored by **ggml-llm**, **ggml-stt**, **ggml-tts** and **ggml-decision** (mlx-llm gets quantization from the repo itself and does not use these):
|
|
265
266
|
|
|
266
267
|
| Key | Type | Notes |
|
|
267
268
|
| ------------------------- | --------- | -------------------------------------------------------------------------------- |
|
|
@@ -493,6 +494,127 @@ vocoder_batch_size = 4096
|
|
|
493
494
|
|
|
494
495
|
---
|
|
495
496
|
|
|
497
|
+
### `ggml-decision` (typed decisions via `@fugood/llama.node`)
|
|
498
|
+
|
|
499
|
+
Loads a typed decision model (System One — Julia-1, Laya, Kev, Jevling, lev,
|
|
500
|
+
Clef, LiquidAI d1, pplx-decider, ...) and answers `choice` / `score` / `noul`
|
|
501
|
+
questions about a state with calibrated probabilities: one forward pass per
|
|
502
|
+
answer, no generated tokens. Clients reach it through the Typed Decision (GGML)
|
|
503
|
+
generator; local functions through `context.buttress.decide`.
|
|
504
|
+
|
|
505
|
+
The model is checked when its context loads: a GGUF with no decision metadata
|
|
506
|
+
(`<arch>.decision.type`) and no `system_one` template, or one whose decision
|
|
507
|
+
type this build cannot serve, fails the load with the reason. Requests on one
|
|
508
|
+
generator run one at a time.
|
|
509
|
+
|
|
510
|
+
**Requests** follow the TypeSafe `/v1/systemone` shape:
|
|
511
|
+
`{ state, questions, images?, files? }`.
|
|
512
|
+
|
|
513
|
+
- `state` is text, or a JSON object/array (given to the model as JSON text).
|
|
514
|
+
- `questions` maps an id to a `choice`, `score` or `noul` question. A `noul`
|
|
515
|
+
question takes `criteria: { true?, false? }`; `lfm2-d1-omni` also takes
|
|
516
|
+
`yes` / `no`.
|
|
517
|
+
- `images` carries the media: images, or audio clips for a model whose prompt
|
|
518
|
+
takes audio (`lfm2-d1-omni`). `files` is an alias; both lists are used. A
|
|
519
|
+
client session sends uploaded file names (resolved like STT audio) or data
|
|
520
|
+
URLs (`data:image/...`, `data:audio/...`); local functions send file paths
|
|
521
|
+
or data URLs; the HTTP endpoints take data URLs only. `lfm2-d1-omni` takes
|
|
522
|
+
images or one audio clip, not both.
|
|
523
|
+
- With media, `state` may be `null` (or empty) to ask about the media only.
|
|
524
|
+
Only `lfm2-d1` and `lfm2-d1-omni` accept that; other models reject it. Without
|
|
525
|
+
media a `state` is required.
|
|
526
|
+
|
|
527
|
+
The OpenAI-compatible `POST /oai-compat/v1/decisions` also takes `input_audio`
|
|
528
|
+
content parts (`{ type: 'input_audio', input_audio: { data, format } }`, base64
|
|
529
|
+
`data`, `format` `wav` or `mp3`). An input with only images or audio gets a
|
|
530
|
+
placeholder state (`The attached image.`), since every model type takes one.
|
|
531
|
+
|
|
532
|
+
**Sharing a model with `ggml-llm`** — a model that both generates and decides
|
|
533
|
+
(Jevling, for one) can be configured as both a `ggml-llm` and a
|
|
534
|
+
`ggml-decision`. By default each loads its own context. Set
|
|
535
|
+
`[generators.backend] share_llm_context = true` on the decision entry to run
|
|
536
|
+
its decisions on the LLM's context instead, when the two entries name the same
|
|
537
|
+
weights (same `repo_id`, `revision`, `filename`, `quantization`, `url` and
|
|
538
|
+
`local_path`): the model is then loaded once, with the LLM's config. This
|
|
539
|
+
applies to local functions, the TypeSafe-compatible endpoint and client
|
|
540
|
+
sessions (whose decision runtime options then do not apply).
|
|
541
|
+
|
|
542
|
+
Sharing trades throughput for memory. A decision takes one of the LLM's
|
|
543
|
+
`n_parallel` slots, and its forward passes run between the LLM's batches, so
|
|
544
|
+
concurrent completions stop generating while it runs (in a CPU test, a
|
|
545
|
+
128-token completion took twice as long beside six decisions; with a separate
|
|
546
|
+
decision context it did not slow down). Results are not affected: the slot's
|
|
547
|
+
sequence and prompt cache are cleared after each decision. Without
|
|
548
|
+
`kv_unified = true` on the LLM a slot holds `n_ctx / n_parallel` tokens, which
|
|
549
|
+
must fit the state plus the questions. The decision loads its own context
|
|
550
|
+
anyway when it sets `rank_pooling` (it needs an embedding context), when its
|
|
551
|
+
projector differs from the LLM's, or when the LLM is embedding or vocab-only.
|
|
552
|
+
The server logs at startup which decision models share.
|
|
553
|
+
|
|
554
|
+
```toml
|
|
555
|
+
[[generators]]
|
|
556
|
+
type = "ggml-llm"
|
|
557
|
+
[generators.model]
|
|
558
|
+
repo_id = "BricksDisplay/jevling-0.8b-v1-GGUF"
|
|
559
|
+
filename = "jevling-0.8b-v1-q8_0-embf16.gguf"
|
|
560
|
+
[generators.runtime]
|
|
561
|
+
n_ctx = 16384
|
|
562
|
+
|
|
563
|
+
[[generators]]
|
|
564
|
+
type = "ggml-decision"
|
|
565
|
+
[generators.backend]
|
|
566
|
+
share_llm_context = true
|
|
567
|
+
[generators.model]
|
|
568
|
+
repo_id = "BricksDisplay/jevling-0.8b-v1-GGUF"
|
|
569
|
+
filename = "jevling-0.8b-v1-q8_0-embf16.gguf"
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
**`[generators.backend]`** — same keys as `ggml-tts` (`variant`,
|
|
573
|
+
`variant_preference`, `gpu_memory_fraction`, `cpu_memory_fraction`).
|
|
574
|
+
|
|
575
|
+
**`[generators.model]`** — common keys plus:
|
|
576
|
+
|
|
577
|
+
| Key | Type | Default | Notes |
|
|
578
|
+
| ------------------------- | -------- | -------------------------------- | ------------------------------------------------------------------ |
|
|
579
|
+
| `preferred_quantizations` | string[] | `["q8_0", "q4_k_m", <no-quant>]` | Default fallback chain |
|
|
580
|
+
| `mmproj_repo_id` | string | falls back to `repo_id` | Projector repo, for models whose prompt takes images or audio |
|
|
581
|
+
| `mmproj_filename` | string | auto (`*mmproj*`) | Pin a specific projector artifact |
|
|
582
|
+
| `mmproj_url` | string | — | Direct projector URL (skips manifest lookup) |
|
|
583
|
+
| `mmproj_revision` | string | falls back to `revision` | |
|
|
584
|
+
| `mmproj_local_path` | string | — | Requires `allow_local_file` |
|
|
585
|
+
| `rank_pooling` | boolean | `false` | `system_one` models with a classification head (`rank_head` readout) |
|
|
586
|
+
| `use_gpu` | boolean | `true` | Force-disable GPU even when available |
|
|
587
|
+
| `n_gpu_layers` | number | `99` | Forced to `0` when the GPU is unused |
|
|
588
|
+
|
|
589
|
+
Setting any `mmproj_*` key loads a projector; requests can then pass `images`
|
|
590
|
+
(images or audio clips, as file paths or data URLs). The projector is ignored,
|
|
591
|
+
with a warning, when the model's prompt has no place for images or audio.
|
|
592
|
+
|
|
593
|
+
**Runtime extras** — under `[runtime]` for ggml-decision:
|
|
594
|
+
|
|
595
|
+
| Key | Type | Default | Notes |
|
|
596
|
+
| -------------------------- | ------ | --------------- | ---------------------------------------- |
|
|
597
|
+
| `n_ctx` | number | `4096` | Must hold the state plus every question |
|
|
598
|
+
| `n_batch` / `n_ubatch` | number | `2048` / `512` | |
|
|
599
|
+
| `max_threads` | number | auto | Caps the llama.cpp thread count |
|
|
600
|
+
| `flash_attn_type` | string | `auto` | `auto` / `on` / `off` |
|
|
601
|
+
| `context_release_delay_ms` | number | `10000` | `0` releases the context right away |
|
|
602
|
+
|
|
603
|
+
**Example**
|
|
604
|
+
|
|
605
|
+
```toml
|
|
606
|
+
[[generators]]
|
|
607
|
+
type = "ggml-decision"
|
|
608
|
+
[generators.model]
|
|
609
|
+
repo_id = "ggml-org/Julia-1-GGUF"
|
|
610
|
+
filename = "Julia-1-Q8_0.gguf"
|
|
611
|
+
download = true
|
|
612
|
+
[generators.runtime]
|
|
613
|
+
n_ctx = 4096
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
496
618
|
### `mlx-llm` (Apple Silicon, Python `mlx-lm` / `mlx-vlm` bridge)
|
|
497
619
|
|
|
498
620
|
Loads an MLX-format model on Apple Silicon. On first use, the backend creates a virtualenv at `{cache_dir}/mlx-env` and installs `mlx_lm_package`, `mlx_vlm_package`, plus `torch` and `torchvision` (required by some VLM processors). If an existing venv already has `mlx_vlm` and `torch` importable, the install step is skipped. There is no `[generators.backend]` section.
|
|
@@ -638,7 +760,7 @@ Examples:
|
|
|
638
760
|
|
|
639
761
|
## Compatibility Endpoints (Experimental)
|
|
640
762
|
|
|
641
|
-
The server can expose OpenAI- and
|
|
763
|
+
The server can expose OpenAI-, Anthropic- and TypeSafe-compatible HTTP endpoints in addition to the native RPC. Each endpoint is opt-in via the TOML config:
|
|
642
764
|
|
|
643
765
|
```toml
|
|
644
766
|
[openai_compat]
|
|
@@ -648,14 +770,19 @@ enabled = true
|
|
|
648
770
|
[anthropic_messages]
|
|
649
771
|
enabled = true
|
|
650
772
|
# cors_allowed_origins = ["http://localhost:3000"]
|
|
773
|
+
|
|
774
|
+
[typesafe_compat]
|
|
775
|
+
enabled = true
|
|
651
776
|
```
|
|
652
777
|
|
|
653
778
|
| Endpoint | Config flag |
|
|
654
779
|
| --------------------- | ------------------------------------ |
|
|
655
780
|
| `/oai-compat/v1/*` | `[openai_compat] enabled = true` |
|
|
656
781
|
| `/anthropic-messages` | `[anthropic_messages] enabled = true` |
|
|
782
|
+
| `/typesafe-compat/v1/*` | `[typesafe_compat] enabled = true` |
|
|
657
783
|
|
|
658
|
-
Completion requests may select only LLM models declared in `[[generators]]
|
|
784
|
+
Completion requests may select only LLM models declared in `[[generators]]`, and decision
|
|
785
|
+
requests only `ggml-decision` generators. Use each
|
|
659
786
|
endpoint's `GET /v1/models` response for valid model IDs; unknown IDs are rejected before the
|
|
660
787
|
server starts or downloads a generator.
|
|
661
788
|
|
|
@@ -719,6 +846,7 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
719
846
|
| `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
|
|
720
847
|
| `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
|
|
721
848
|
| `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts` or `ggml-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
|
|
849
|
+
| `buttress.decide({ model?, state, questions, images?, files? })` | Answer typed `choice` / `score` / `noul` questions about `state` with this server's `ggml-decision` generator → `{ model, answers, usage }`. `answers[id]` is typed from its question. `images` (alias `files`) are images or audio clips, as local paths or data URLs; with media, `state` may be `null` for `lfm2-d1` / `lfm2-d1-omni`. |
|
|
722
850
|
| `bank.list({ keyword?, meta?, ids? })` / `bank.get(id)` / `bank.update(properties, { dontNotify? })` / `bank.remove(id)` | Read/write the bound workspace's remote **Data Bank** (see below). To listen for changes, use `bank.subscribe` inside a daemon function. |
|
|
723
851
|
| `daemons.emit(name, event, data?)` / `daemons.list()` | Signal a running **daemon function**'s `onEvent` handlers (see below) / list running daemon names. |
|
|
724
852
|
| `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
|
|
@@ -894,6 +1022,7 @@ system_prompt_file = "./prompts/ops.md" # or inline: system_prompt = "..."
|
|
|
894
1022
|
tools = ["get_server_status", "restart_service"] # local function names (explicit; no wildcard)
|
|
895
1023
|
local_devices = false # opt in to LAN scan + device DevTools tools
|
|
896
1024
|
max_turns = 30 # assistant↔tool round-trips per run (default 30)
|
|
1025
|
+
# codemode = true # opt in: a sandboxed JS tool that calls the other tools
|
|
897
1026
|
# max_tokens_per_run = 200000 # per-run token budget; unset = unlimited
|
|
898
1027
|
# temperature = 0.2 # unrecognized keys pass through to generation
|
|
899
1028
|
|
|
@@ -901,6 +1030,9 @@ max_turns = 30 # assistant↔tool round-trips pe
|
|
|
901
1030
|
url = "https://api.githubcopilot.com/mcp/"
|
|
902
1031
|
# headers = { Authorization = "Bearer ..." }
|
|
903
1032
|
# optional = true # continue without this server if it won't connect
|
|
1033
|
+
# timeout = 60 # per-request seconds (progress renews it)
|
|
1034
|
+
# exposure = "direct" # "hidden": unreachable; "codemode": scripts only (needs codemode = true)
|
|
1035
|
+
# tool_exposure = { "delete_*" = "hidden" } # per tool, by exact name or * pattern
|
|
904
1036
|
|
|
905
1037
|
[agents_options] # optional; defaults shown
|
|
906
1038
|
# sessions_dir = "./.buttress-agent/sessions" # relative to this config file
|
|
@@ -924,8 +1056,54 @@ tracking, and the function's own `meta.timeout` deadline), and aborting the
|
|
|
924
1056
|
run aborts in-flight tool calls and their spawned processes. A listed function
|
|
925
1057
|
that is missing fails the run loudly rather than letting a headless automation
|
|
926
1058
|
improvise around it. MCP tools get server-qualified names
|
|
927
|
-
(`mcp__github__create_issue
|
|
928
|
-
fail closed unless marked
|
|
1059
|
+
(`mcp__github__create_issue`; characters outside `[A-Za-z0-9_]` become `_`);
|
|
1060
|
+
MCP servers connect lazily on the first run and fail closed unless marked
|
|
1061
|
+
`optional = true`.
|
|
1062
|
+
|
|
1063
|
+
**Codemode.** `codemode = true` on an `[[agents]]` entry (default `false`) adds a
|
|
1064
|
+
`codemode` tool: the model writes JavaScript that runs in a QuickJS sandbox and
|
|
1065
|
+
calls the agent's other tools (`await tools.<name>(args)`), so it can batch calls
|
|
1066
|
+
and filter large results before they reach the context. MCP servers of such an
|
|
1067
|
+
agent may use `exposure = "codemode"` (or `"deferred"`) to keep their tools out
|
|
1068
|
+
of the model's tool list; scripts find them with `searchTools()`.
|
|
1069
|
+
`models.classify()` runs pi-ai classifier models with the agent's credentials,
|
|
1070
|
+
including this server's decision generators (`buttress/<ggml-decision id>`).
|
|
1071
|
+
Small local models may write poor scripts — prefer it with capable models.
|
|
1072
|
+
|
|
1073
|
+
**Decisions (System One).** `decision_model` gives an agent a typed-decision
|
|
1074
|
+
model: `buttress/<ggml-decision id>` (as `GET /typesafe-compat/v1/models` lists
|
|
1075
|
+
it), or a hosted classifier such as `typesafe/jev-latest` (`TYPESAFE_API_KEY`;
|
|
1076
|
+
the questions and the state are then sent to TypeSafe). Unset means no
|
|
1077
|
+
decisions; it never falls back to the chat model.
|
|
1078
|
+
|
|
1079
|
+
```toml
|
|
1080
|
+
[[agents]]
|
|
1081
|
+
name = "ops-assistant"
|
|
1082
|
+
model = "buttress/unsloth/Qwen3.5-4B-GGUF"
|
|
1083
|
+
tools = ["host-info"]
|
|
1084
|
+
decision_model = "buttress/BricksDisplay/jevling-e2b-v1-GGUF:jevling-e2b-v1-q8_0-embf16.gguf"
|
|
1085
|
+
decision_assistance = true # opt-in tool prefilter (default false)
|
|
1086
|
+
# decision_timeout_ms = 500 # prefilter deadline; past it the run keeps every tool
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
- With a `decision_model`, the agent gets a `decision_evaluate` tool. The model
|
|
1090
|
+
calls it itself with a `state` and `choice` / `score` / `bool` questions, and
|
|
1091
|
+
gets calibrated probabilities back. The tool sees only that state, not the
|
|
1092
|
+
conversation. Its answers are evidence for the model, not permission.
|
|
1093
|
+
- With `decision_assistance = true` too, a tool prefilter runs before each run.
|
|
1094
|
+
It asks three yes/no questions about the request: does it need live facts
|
|
1095
|
+
about the system, does it ask for an action (or approve/retry one), and does
|
|
1096
|
+
it point at something not in the conversation. Only when all three are
|
|
1097
|
+
clearly no (each probability < 0.35) does the run go out **without tool
|
|
1098
|
+
definitions**: a shorter prompt for greetings, thanks, explanations or
|
|
1099
|
+
translations. Anything else, including a timeout or an error, keeps every
|
|
1100
|
+
tool. It sends the request, the nearest user/assistant text (≤ 6,000
|
|
1101
|
+
characters) and the tool names; never tool arguments, results or reasoning.
|
|
1102
|
+
The server logs each outcome (`tool prefilter pruned|kept …`).
|
|
1103
|
+
- The prefilter never adds or allows a tool; it only withholds them for one
|
|
1104
|
+
run. Small decision models judge it less reliably: jevling-0.8b pruned some
|
|
1105
|
+
live-fact questions that need a tool, jevling-e2b kept them (about 170 ms per
|
|
1106
|
+
run on a GPU). The first run after start may time out while the model loads.
|
|
929
1107
|
|
|
930
1108
|
**Local devices.** Set `local_devices = true` on an individual `[[agents]]` entry
|
|
931
1109
|
(default `false`) to add just two built-in tools, independently of local functions/MCP:
|
|
@@ -14,6 +14,7 @@ needed. On first start the server scaffolds `buttress-functions.d.ts` and a
|
|
|
14
14
|
| `simple-rag.ts` | Token chunking, embeddings, `sqlite3` + `sqlite-vec` retrieval, then completion | a chat LLM + a GGML embedding `[[generators]]` entry (below) |
|
|
15
15
|
| `transcribe-media.ts` | `context.spawn` (ffmpeg), the scratch dir, SSE progress, STT | `ffmpeg` on PATH + an STT `[[generators]]` entry |
|
|
16
16
|
| `text-to-speech.ts` | TTS (`context.buttress.synthesize`) + downloadable output (`context.fileUrl`) | an `onnx-tts` `[[generators]]` entry |
|
|
17
|
+
| `classify-ticket.ts` | Typed decisions (`context.buttress.decide`): a choice, a score and yes/no flags with calibrated probabilities | a `ggml-decision` `[[generators]]` entry |
|
|
17
18
|
| `bank-note.ts` | Reading/writing the workspace's remote Data Bank (`context.bank`) | `bricks buttress bank-key` run on this host |
|
|
18
19
|
| `bank-watch-daemon.ts` | A daemon (`meta.daemon = true`): `context.bank.subscribe`, `context.setInterval`, `context.onEvent` | `bricks buttress bank-key` run on this host |
|
|
19
20
|
| `_auth.ts` | Custom auth: keep workspace tokens working, add static API keys | see the file header |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Triage a support ticket with this server's typed decision model: which team
|
|
2
|
+
// takes it, how urgent it is, and two yes/no flags — calibrated probabilities
|
|
3
|
+
// from one forward pass per answer, no text generation and no output parsing.
|
|
4
|
+
//
|
|
5
|
+
// Needs a ggml-decision generator in the server config, e.g.:
|
|
6
|
+
//
|
|
7
|
+
// [[generators]]
|
|
8
|
+
// type = "ggml-decision"
|
|
9
|
+
// [generators.model]
|
|
10
|
+
// repo_id = "ggml-org/Julia-1-GGUF"
|
|
11
|
+
// filename = "Julia-1-Q8_0.gguf"
|
|
12
|
+
//
|
|
13
|
+
// curl -X POST http://<host>:<port>/functions/classify-ticket \
|
|
14
|
+
// -H 'Content-Type: application/json' \
|
|
15
|
+
// -d '{"message": "I was charged twice and nobody replies. I want my money back today."}'
|
|
16
|
+
// # → { "result": { "route": "billing", "urgency": 1.53, "urgencyLabel": "Today", "angry": true, … } }
|
|
17
|
+
|
|
18
|
+
export const meta: ButtressFunctionMeta = {
|
|
19
|
+
description:
|
|
20
|
+
'Triage a support ticket: route it to a team, rate its urgency, and flag anger or a refund request',
|
|
21
|
+
parameters: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
message: { type: 'string', description: 'The customer message' },
|
|
25
|
+
model: { type: 'string', description: 'Optional decision model override' },
|
|
26
|
+
},
|
|
27
|
+
required: ['message'],
|
|
28
|
+
},
|
|
29
|
+
timeout: '2m',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Input = { message: string; model?: string }
|
|
33
|
+
|
|
34
|
+
const URGENCY_LEVELS = ['Can wait', 'This week', 'Today', 'Right now']
|
|
35
|
+
|
|
36
|
+
export default async function ({ message, model }: Input, context: ButtressFunctionContext) {
|
|
37
|
+
const { answers, usage } = await context.buttress.decide({
|
|
38
|
+
model,
|
|
39
|
+
state: message,
|
|
40
|
+
questions: {
|
|
41
|
+
route: {
|
|
42
|
+
type: 'choice',
|
|
43
|
+
instructions: 'Which team should handle this?',
|
|
44
|
+
criteria: {
|
|
45
|
+
billing: 'Payments, charges, refunds',
|
|
46
|
+
shipping: 'Delivery and tracking',
|
|
47
|
+
technical: 'App or website problems',
|
|
48
|
+
account: 'Login and profile',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
urgency: { type: 'score', instructions: 'How urgent is this?', criteria: URGENCY_LEVELS },
|
|
52
|
+
angry: { type: 'noul', instructions: 'The customer is angry' },
|
|
53
|
+
wants_refund: { type: 'noul', instructions: 'The customer asks for a refund' },
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// `answers.route.choice` is typed as 'billing' | 'shipping' | 'technical' | 'account'
|
|
58
|
+
return {
|
|
59
|
+
route: answers.route.choice,
|
|
60
|
+
routeConfidence: answers.route.confidence,
|
|
61
|
+
// Expected level index (0 = "Can wait"), can fall between two levels
|
|
62
|
+
urgency: answers.urgency.score,
|
|
63
|
+
urgencyLabel: URGENCY_LEVELS[Math.round(answers.urgency.score)],
|
|
64
|
+
angry: answers.angry.noul >= 0.5,
|
|
65
|
+
wantsRefund: answers.wants_refund.noul >= 0.5,
|
|
66
|
+
probabilities: {
|
|
67
|
+
route: answers.route.probabilities,
|
|
68
|
+
angry: answers.angry.noul,
|
|
69
|
+
wantsRefund: answers.wants_refund.noul,
|
|
70
|
+
},
|
|
71
|
+
inputTokens: usage.input_tokens,
|
|
72
|
+
}
|
|
73
|
+
}
|
package/config/sample.toml
CHANGED
|
@@ -29,6 +29,13 @@ enabled = true
|
|
|
29
29
|
# cors_allowed_origins = ["http://localhost:3000", "https://example.com"]
|
|
30
30
|
# cors_allowed_origins = "*"
|
|
31
31
|
|
|
32
|
+
# TypeSafe-compatible System One API (`/typesafe-compat/v1/systemone`) for the
|
|
33
|
+
# TypeSafe SDKs: TYPESAFE_BASE_URL=http://<host>:<port>/typesafe-compat.
|
|
34
|
+
# Serves the decision generators below.
|
|
35
|
+
# [typesafe_compat]
|
|
36
|
+
# enabled = true
|
|
37
|
+
# cors_allowed_origins = ["http://localhost:3000"]
|
|
38
|
+
|
|
32
39
|
# Local functions (EXPERIMENTAL): .ts/.js files exposed as MCP tools and HTTP
|
|
33
40
|
# endpoints. Interfaces may change between releases.
|
|
34
41
|
# See the "Local Functions" section of README.md; ready-to-copy examples live
|
|
@@ -57,6 +64,8 @@ enabled = true
|
|
|
57
64
|
# tools = ["host-info"] # local function names (explicit; no wildcard)
|
|
58
65
|
# local_devices = false # opt in: devices_scan + devtools (inspection AND control)
|
|
59
66
|
# max_turns = 30
|
|
67
|
+
# decision_model = "buttress/<ggml-decision id>" # adds the decision_evaluate tool
|
|
68
|
+
# decision_assistance = false # opt in: skip tool definitions on runs that need no tool
|
|
60
69
|
# [agents.mcp_servers.example]
|
|
61
70
|
# url = "https://example.com/mcp/"
|
|
62
71
|
# optional = true
|
|
@@ -300,3 +309,31 @@ use_gpu = true
|
|
|
300
309
|
[generators.runtime]
|
|
301
310
|
n_ctx = 8192
|
|
302
311
|
vocoder_batch_size = 4096
|
|
312
|
+
|
|
313
|
+
# Typed decision generators
|
|
314
|
+
#
|
|
315
|
+
# A typed decision model (System One) answers `choice` / `score` / `noul`
|
|
316
|
+
# questions about a state with calibrated probabilities — one forward pass per
|
|
317
|
+
# answer, no generated tokens. Clients reach it through the Typed Decision
|
|
318
|
+
# (GGML) generator, and local functions through `context.buttress.decide`.
|
|
319
|
+
# Uncomment to enable:
|
|
320
|
+
#
|
|
321
|
+
# [[generators]]
|
|
322
|
+
# type = "ggml-decision"
|
|
323
|
+
# [generators.model]
|
|
324
|
+
# repo_id = "ggml-org/Julia-1-GGUF"
|
|
325
|
+
# filename = "Julia-1-Q8_0.gguf"
|
|
326
|
+
# download = true
|
|
327
|
+
# # Models whose prompt takes images also need a projector:
|
|
328
|
+
# # mmproj_repo_id = "..."
|
|
329
|
+
# # mmproj_filename = "mmproj-....gguf"
|
|
330
|
+
# # system_one models with a classification head (rank_head readout):
|
|
331
|
+
# # rank_pooling = true
|
|
332
|
+
# [generators.runtime]
|
|
333
|
+
# n_ctx = 4096
|
|
334
|
+
#
|
|
335
|
+
# A model that also generates (Jevling, for one) can be configured as both a
|
|
336
|
+
# ggml-llm and a ggml-decision. To load it once, add to the decision entry
|
|
337
|
+
# [generators.backend]
|
|
338
|
+
# share_llm_context = true
|
|
339
|
+
# (decisions then compete with completions for the LLM's slots; see README).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@earendil-works/pi-codemode/worker';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MutableModels } from '@earendil-works/pi-ai';
|
|
2
|
+
export declare function createAgentCodemodeTool({ tools, declaredNames, getState, models, }: {
|
|
3
|
+
tools: any[];
|
|
4
|
+
declaredNames: Set<string>;
|
|
5
|
+
getState: () => any;
|
|
6
|
+
models: MutableModels;
|
|
7
|
+
}): Promise<any>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System One decisions for Buttress agents.
|
|
3
|
+
*
|
|
4
|
+
* - Buttress decision generators as pi-ai classifier models (`buttress/<id>`),
|
|
5
|
+
* answered in-process. Codemode `models.classify()` and the pieces below go
|
|
6
|
+
* through pi-ai, so a hosted classifier (`typesafe/jev-latest`) is
|
|
7
|
+
* interchangeable with a local one.
|
|
8
|
+
* - `decision_evaluate`: a tool the model calls itself with typed questions.
|
|
9
|
+
* - Tool prefilter (`decision_assistance`): before a run, three yes/no questions
|
|
10
|
+
* decide whether its request needs tools at all; a conversational request
|
|
11
|
+
* is sent without tool definitions, which shortens the prompt.
|
|
12
|
+
*
|
|
13
|
+
* A decision is evidence, never permission: it can only withhold tools for one
|
|
14
|
+
* run, never grant one, and any failure keeps the full tool set.
|
|
15
|
+
*/
|
|
16
|
+
import type { ClassifierModel, ClassifierResult, MutableModels, ProviderClassifier } from '@earendil-works/pi-ai';
|
|
17
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
18
|
+
import type { Config, DecisionRequest, DecisionResult } from '../types';
|
|
19
|
+
import type { AgentDefinition } from './types';
|
|
20
|
+
export declare const BUTTRESS_DECISION_API = "buttress-decision";
|
|
21
|
+
export type DecideFn = (request: DecisionRequest & {
|
|
22
|
+
model?: string;
|
|
23
|
+
}) => Promise<DecisionResult>;
|
|
24
|
+
/** One pi-ai classifier model per configured decision generator. */
|
|
25
|
+
export declare const buttressClassifierModels: (config: Config, provider: string, baseUrl: string) => ClassifierModel<string>[];
|
|
26
|
+
/**
|
|
27
|
+
* Answers classifier requests with this server's decision generators. Never
|
|
28
|
+
* rejects. A decision cannot be stopped once started, so a caller that gives
|
|
29
|
+
* up (timeout, abort) leaves it running; until it settles, new requests for
|
|
30
|
+
* that model fail fast instead of piling up behind it (a cold model load
|
|
31
|
+
* would otherwise start one more per agent run).
|
|
32
|
+
*/
|
|
33
|
+
export declare const createButtressClassifier: (decide: DecideFn) => ProviderClassifier;
|
|
34
|
+
/** The agent's decision model, or null when it has none. Throws on an unknown one. */
|
|
35
|
+
export declare const resolveDecisionModel: (models: MutableModels, agent: AgentDefinition) => ClassifierModel<any> | null;
|
|
36
|
+
export declare const DECISION_TOOL_NAME = "decision_evaluate";
|
|
37
|
+
/** Typed questions about a state the model supplies. Results never authorize actions. */
|
|
38
|
+
export declare const createDecisionEvaluateTool: (models: MutableModels, model: ClassifierModel<any>) => {
|
|
39
|
+
name: string;
|
|
40
|
+
label: string;
|
|
41
|
+
description: string;
|
|
42
|
+
parameters: {
|
|
43
|
+
type: string;
|
|
44
|
+
properties: {
|
|
45
|
+
state: {
|
|
46
|
+
type: string;
|
|
47
|
+
description: string;
|
|
48
|
+
};
|
|
49
|
+
questions: {
|
|
50
|
+
type: string;
|
|
51
|
+
description: string;
|
|
52
|
+
additionalProperties: {
|
|
53
|
+
type: string;
|
|
54
|
+
properties: {
|
|
55
|
+
type: {
|
|
56
|
+
type: string;
|
|
57
|
+
enum: string[];
|
|
58
|
+
};
|
|
59
|
+
instructions: {
|
|
60
|
+
type: string;
|
|
61
|
+
};
|
|
62
|
+
criteria: {};
|
|
63
|
+
};
|
|
64
|
+
required: string[];
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
required: string[];
|
|
69
|
+
};
|
|
70
|
+
execute: (_toolCallId: string, args: any, signal?: AbortSignal) => Promise<{
|
|
71
|
+
content: {
|
|
72
|
+
type: string;
|
|
73
|
+
text: string;
|
|
74
|
+
}[];
|
|
75
|
+
details: {};
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
/** Every probability must be below this to send a run without tools. */
|
|
79
|
+
export declare const PREFILTER_THRESHOLD = 0.35;
|
|
80
|
+
/**
|
|
81
|
+
* The prefilter's evidence: the request, the nearest user/assistant exchanges
|
|
82
|
+
* that fit, and only counts for what is left out. Tool arguments and results,
|
|
83
|
+
* reasoning and system messages are never sent.
|
|
84
|
+
*/
|
|
85
|
+
export declare const buildPrefilterState: (prompt: string, history: readonly AgentMessage[], toolNames: string[]) => {
|
|
86
|
+
latestRequest: string;
|
|
87
|
+
recentConversation: {
|
|
88
|
+
role: string;
|
|
89
|
+
text: string;
|
|
90
|
+
}[];
|
|
91
|
+
availableTools: string[];
|
|
92
|
+
omittedContext: {
|
|
93
|
+
olderConversation: number;
|
|
94
|
+
toolPayloads: number;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export type PrefilterOutcome = {
|
|
98
|
+
prune: boolean;
|
|
99
|
+
reason: 'conversational' | 'action-or-uncertain' | 'unavailable' | 'skipped';
|
|
100
|
+
detail?: string;
|
|
101
|
+
};
|
|
102
|
+
/** Prune only on clear "no" answers to every question; anything else keeps every tool. */
|
|
103
|
+
export declare const interpretPrefilter: (result: ClassifierResult) => PrefilterOutcome;
|
|
104
|
+
/** Decide whether this run's request can go out without tool definitions. */
|
|
105
|
+
export declare const runToolPrefilter: ({ models, model, prompt, history, toolNames, timeoutMs, signal, }: {
|
|
106
|
+
models: MutableModels;
|
|
107
|
+
model: ClassifierModel<any>;
|
|
108
|
+
prompt: string;
|
|
109
|
+
history: readonly AgentMessage[];
|
|
110
|
+
toolNames: string[];
|
|
111
|
+
timeoutMs: number;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
}) => Promise<PrefilterOutcome>;
|
package/lib/agent/mcp.d.ts
CHANGED
|
@@ -4,12 +4,11 @@ export type McpAgentTool = {
|
|
|
4
4
|
label: string;
|
|
5
5
|
description: string;
|
|
6
6
|
parameters: Record<string, any>;
|
|
7
|
-
execute: (toolCallId: string, args: any, signal?: AbortSignal) => Promise<any>;
|
|
7
|
+
execute: (toolCallId: string, args: any, signal?: AbortSignal, onUpdate?: any) => Promise<any>;
|
|
8
8
|
_mcpServer: string;
|
|
9
9
|
_mcpToolName: string;
|
|
10
|
+
_mcpExposure: string;
|
|
10
11
|
};
|
|
11
|
-
/** `mcp__<server>__<tool>`, hashed into the cap when too long or colliding. */
|
|
12
|
-
export declare const qualifyToolName: (serverName: string, toolName: string, usedNames: Set<string>) => string;
|
|
13
12
|
export type McpManager = {
|
|
14
13
|
/**
|
|
15
14
|
* Tools for every configured server of an agent. Fail-closed: a server that
|
package/lib/agent/models.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Model, type MutableModels } from '@earendil-works/pi-ai';
|
|
2
2
|
import type { Config } from '../types';
|
|
3
3
|
import type { AgentDefinition } from './types';
|
|
4
|
+
import { type DecideFn } from './decisions';
|
|
4
5
|
export declare const BUTTRESS_PROVIDER_ID = "buttress";
|
|
5
6
|
/**
|
|
6
7
|
* Loopback requests never leave the process: this host name only exists so the
|
|
@@ -13,8 +14,9 @@ export type LoopbackFetch = typeof fetch;
|
|
|
13
14
|
/**
|
|
14
15
|
* A `Models` collection with every pi built-in provider (env-var API keys)
|
|
15
16
|
* plus the `buttress` pseudo-provider whose models are the configured LLM
|
|
16
|
-
* generators, streamed over the in-process OpenAI-compat loopback
|
|
17
|
+
* generators, streamed over the in-process OpenAI-compat loopback, and the
|
|
18
|
+
* configured decision generators as classifier models, answered by `decide`.
|
|
17
19
|
*/
|
|
18
|
-
export declare const buildAgentModels: (config: Config, loopbackFetch: LoopbackFetch) => MutableModels;
|
|
20
|
+
export declare const buildAgentModels: (config: Config, loopbackFetch: LoopbackFetch, decide: DecideFn) => MutableModels;
|
|
19
21
|
/** Resolve an agent's model reference against the registry, with clear errors. */
|
|
20
22
|
export declare const resolveAgentModel: (models: MutableModels, agent: AgentDefinition) => Model<any>;
|
package/lib/agent/sessions.d.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
|
-
import type { AgentMessage
|
|
1
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
2
2
|
import type { AgentSessionSummary, AgentsConfig } from './types';
|
|
3
|
+
type SessionMeta = {
|
|
4
|
+
id: string;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
parentSessionId?: string;
|
|
7
|
+
path: string;
|
|
8
|
+
modifiedAt: number;
|
|
9
|
+
/** Written by pi-agent-core's v4 repo; converted on the first append. */
|
|
10
|
+
legacy: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type AgentSession = {
|
|
13
|
+
metadata: SessionMeta;
|
|
14
|
+
/** True once the file is in the current format with no torn tail. */
|
|
15
|
+
appendable: boolean;
|
|
16
|
+
};
|
|
3
17
|
export type AgentSessionStore = {
|
|
4
|
-
create: (agentName: string) => Promise<
|
|
18
|
+
create: (agentName: string) => Promise<AgentSession>;
|
|
5
19
|
/** Throws when the id is unknown within the agent's scope. */
|
|
6
|
-
open: (agentName: string, sessionId: string) => Promise<
|
|
7
|
-
fork: (agentName: string, sessionId: string) => Promise<
|
|
8
|
-
append: (session:
|
|
9
|
-
close: (session: Session<any>) => Promise<void>;
|
|
20
|
+
open: (agentName: string, sessionId: string) => Promise<AgentSession>;
|
|
21
|
+
fork: (agentName: string, sessionId: string) => Promise<AgentSession>;
|
|
22
|
+
append: (session: AgentSession, message: AgentMessage) => Promise<void>;
|
|
10
23
|
list: (agentName: string, limit?: number) => Promise<AgentSessionSummary[]>;
|
|
11
24
|
/** Reconstruct the pi message history for continuing a session. */
|
|
12
|
-
messages: (session:
|
|
25
|
+
messages: (session: AgentSession) => Promise<AgentMessage[]>;
|
|
13
26
|
/** Retention sweep across every configured agent scope. */
|
|
14
27
|
sweep: (agentNames: string[]) => Promise<number>;
|
|
15
|
-
dispose: () => Promise<void>;
|
|
16
28
|
};
|
|
17
29
|
export declare const createAgentSessionStore: (config: AgentsConfig) => AgentSessionStore;
|
|
30
|
+
export {};
|
package/lib/agent/types.d.ts
CHANGED
|
@@ -13,7 +13,18 @@ export type AgentMcpServerConfig = {
|
|
|
13
13
|
env?: Record<string, string>;
|
|
14
14
|
/** Degrade-and-continue when this server fails to connect (default: fail the run). */
|
|
15
15
|
optional?: boolean;
|
|
16
|
+
/** Per-request timeout in seconds (default 60; progress notifications renew it). */
|
|
17
|
+
timeout?: number;
|
|
18
|
+
/**
|
|
19
|
+
* `direct` (default) declares every tool; `codemode` / `deferred` leave tools
|
|
20
|
+
* to codemode scripts (agents with `codemode = true` only); `hidden` makes
|
|
21
|
+
* them unreachable.
|
|
22
|
+
*/
|
|
23
|
+
exposure?: AgentMcpExposure;
|
|
24
|
+
/** Per-tool exposure by exact tool name or `*` pattern. */
|
|
25
|
+
toolExposure?: Record<string, AgentMcpExposure>;
|
|
16
26
|
};
|
|
27
|
+
export type AgentMcpExposure = 'direct' | 'codemode' | 'deferred' | 'hidden';
|
|
17
28
|
export type AgentDefinition = {
|
|
18
29
|
/** Unique agent name — the session scope key. */
|
|
19
30
|
name: string;
|
|
@@ -29,12 +40,31 @@ export type AgentDefinition = {
|
|
|
29
40
|
tools: string[];
|
|
30
41
|
/** Opt-in LAN discovery and device DevTools access (default false). */
|
|
31
42
|
localDevices: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Opt-in `codemode` tool (default false): scripts that call the agent's
|
|
45
|
+
* tools, including MCP tools with `codemode` / `deferred` exposure.
|
|
46
|
+
*/
|
|
47
|
+
codemode: boolean;
|
|
32
48
|
mcpServers: Record<string, AgentMcpServerConfig>;
|
|
33
49
|
/** Assistant↔tool round-trips per run. */
|
|
34
50
|
maxTurns: number;
|
|
35
51
|
/** Cumulative token budget per run; null = unlimited (default). */
|
|
36
52
|
maxTokensPerRun: number | null;
|
|
37
53
|
thinking?: 'off' | 'minimal' | 'low' | 'medium' | 'high';
|
|
54
|
+
/**
|
|
55
|
+
* Classifier model (`provider/model-id`, e.g. `buttress/<decision generator>`
|
|
56
|
+
* or `typesafe/jev-latest`) for the `decision_evaluate` tool and decision
|
|
57
|
+
* assistance; null = no decisions (never falls back to the chat model).
|
|
58
|
+
*/
|
|
59
|
+
decisionModel: {
|
|
60
|
+
provider: string;
|
|
61
|
+
modelId: string;
|
|
62
|
+
modelRef: string;
|
|
63
|
+
} | null;
|
|
64
|
+
/** Opt-in tool prefilter before each run (needs `decisionModel`; default false). */
|
|
65
|
+
decisionAssistance: boolean;
|
|
66
|
+
/** Prefilter deadline; past it the run keeps every tool. */
|
|
67
|
+
decisionTimeoutMs: number;
|
|
38
68
|
/** Generation passthrough (temperature, top_p, …). */
|
|
39
69
|
generation: Record<string, unknown>;
|
|
40
70
|
};
|