@fugood/buttress-server 2.26.0-beta.1 → 2.26.0-beta.10

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.
Files changed (45) hide show
  1. package/README.md +164 -4
  2. package/config/function-samples/README.md +2 -0
  3. package/config/function-samples/bank-note.ts +47 -0
  4. package/config/function-samples/bank-watch-daemon.ts +63 -0
  5. package/config/function-samples/run-agent.ts +39 -0
  6. package/config/sample.toml +22 -0
  7. package/lib/agent/cli.d.ts +19 -0
  8. package/lib/agent/client.d.ts +66 -0
  9. package/lib/agent/config.d.ts +15 -0
  10. package/lib/agent/context.d.ts +11 -0
  11. package/lib/agent/loopback.d.ts +21 -0
  12. package/lib/agent/mcp.d.ts +23 -0
  13. package/lib/agent/models.d.ts +20 -0
  14. package/lib/agent/service.d.ts +16 -0
  15. package/lib/agent/session-fs.d.ts +3 -0
  16. package/lib/agent/sessions.d.ts +17 -0
  17. package/lib/agent/tools.d.ts +32 -0
  18. package/lib/agent/tui.d.ts +17 -0
  19. package/lib/agent/types.d.ts +123 -0
  20. package/lib/cli-DrbWX4ea.mjs +22 -0
  21. package/lib/client-BCBBen9i.mjs +8 -0
  22. package/lib/config-lP89VahD.mjs +2 -0
  23. package/lib/functions/bank-subscribe.d.ts +46 -0
  24. package/lib/functions/bank.d.ts +21 -0
  25. package/lib/functions/daemons.d.ts +45 -0
  26. package/lib/functions/executor.d.ts +31 -4
  27. package/lib/functions/index.d.ts +17 -7
  28. package/lib/functions/registry.d.ts +7 -1
  29. package/lib/functions/status.d.ts +49 -1
  30. package/lib/functions/templates.d.ts +3 -1
  31. package/lib/functions/types.d.ts +129 -0
  32. package/lib/index.d.ts +8 -2
  33. package/lib/index.mjs +267 -55
  34. package/lib/mlx-bridge.py +681 -0
  35. package/lib/routes/agents.d.ts +37 -0
  36. package/lib/routes/anthropic-messages.d.ts +2 -2
  37. package/lib/routes/index.d.ts +1 -0
  38. package/lib/routes/openai-compat.d.ts +2 -2
  39. package/lib/tui-7B7x6A08.mjs +2 -0
  40. package/lib/types.d.ts +9 -0
  41. package/lib/utils/cors.check.d.ts +1 -0
  42. package/lib/utils/cors.d.ts +72 -0
  43. package/lib/utils/workspaceState.d.ts +9 -0
  44. package/package.json +8 -5
  45. package/public/status.html +77 -1
package/README.md CHANGED
@@ -154,6 +154,7 @@ Configuration is loaded from a TOML file passed via `--config` / `-c`. Every top
154
154
  | `[openai_compat]` | Enable `/oai-compat/v1/*` — see [Compatibility Endpoints](#compatibility-endpoints-experimental) |
155
155
  | `[anthropic_messages]` | Enable `/anthropic-messages` — see [Compatibility Endpoints](#compatibility-endpoints-experimental)|
156
156
  | `[functions]` | Enable local functions — see [Local Functions](#local-functions-experimental) |
157
+ | `[[agents]]` | Config-defined agents — see [Agents](#agents-experimental) |
157
158
  | `[[generators]]` | Array of generator instances — one entry per loaded model |
158
159
 
159
160
  ### `[env]`
@@ -653,9 +654,9 @@ dir = "./functions" # relative paths resolve against this config file
653
654
 
654
655
  `ENABLE_FUNCTIONS_ENDPOINT=1` and `BUTTRESS_FUNCTIONS_DIR=<dir>` are equivalent to the first two keys.
655
656
 
656
- On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and a commented `_example.ts` when the directory holds no functions yet.
657
+ On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and commented `_example.ts` / `_example-daemon.ts` files when the directory holds no functions yet.
657
658
 
658
- Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
659
+ Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, a remote Data Bank read/write, a Data Bank–watching daemon, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
659
660
 
660
661
  ### Writing a function
661
662
 
@@ -693,6 +694,8 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
693
694
  | `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
694
695
  | `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
695
696
  | `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 }`. |
697
+ | `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. |
698
+ | `daemons.emit(name, event, data?)` / `daemons.list()` | Signal a running **daemon function**'s `onEvent` handlers (see below) / list running daemon names. |
696
699
  | `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
697
700
  | `signal` | `AbortSignal`, aborted on timeout or caller disconnect. |
698
701
  | `tempDir` | Per-call scratch directory, created on first access. |
@@ -700,6 +703,52 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
700
703
  | `log`, `fetch`, `env`, `config`, `dir` | Prefixed logging, host `fetch`, `process.env`, the `[functions.config]` table, the functions directory. |
701
704
  | `libs` | `_`/`lodash`, `moment`, `math`/`mathjs`, `voca`, `chroma`, `json5`, `qs`, `bytes`, `ms`, `nanoid`, `md5`. |
702
705
 
706
+ #### Remote Data Bank (`context.bank`)
707
+
708
+ A bound server can read and write the workspace's remote **Data Bank** — the same property space the `bricks data` CLI commands and remote-update apps use. Credentials are issued through the Workspace API by the CLI and stored next to the workspace binding:
709
+
710
+ ```bash
711
+ bricks buttress bank-key # on the server host, from a workspace-authed CLI
712
+ # then restart buttress-server
713
+ ```
714
+
715
+ This writes a `bank` entry (Data Bank endpoint + spacename + space key) into `~/.bricks-cli/buttress/state.json`. Until it exists, every `context.bank` method throws with a hint. `bricks buttress bank-key --revoke` revokes the key and removes the entry; re-running `bank-key` rotates it.
716
+
717
+ `context.bank` is read/write; to *react* to Data Bank changes, use `bank.subscribe` inside a daemon function (below) — it throws elsewhere. Mind one Bank semantic: `update` **replaces `value` unconditionally** (an omitted `value` clears the stored one), so read-merge first when only part of an object should change. Updates notify subscribed devices unless you pass `{ dontNotify: true }`. See `config/function-samples/bank-note.ts` for the end-to-end shape.
718
+
719
+ #### Daemon functions (`meta.daemon`)
720
+
721
+ A file with `meta.daemon = true` runs as a **daemon**: a long-lived background function. Its default export runs **once** when the daemon starts — with a `ButtressDaemonContext` as its only argument — and everything it registers there keeps running after it returns. Daemons are not MCP tools and cannot be called over HTTP (a direct call returns `400 FUNCTION_NOT_CALLABLE`); `GET /functions` lists them in a separate `daemons` array. `meta.timeout` does not apply — a daemon has no deadline.
722
+
723
+ ```ts
724
+ export const meta: ButtressFunctionMeta = {
725
+ description: 'Watch the Data Bank and keep a summary fresh',
726
+ daemon: true,
727
+ }
728
+
729
+ export default async function (context: ButtressDaemonContext) {
730
+ context.setInterval(async () => {
731
+ // periodic work; an error here is logged, the timer keeps firing
732
+ }, 5 * 60 * 1000)
733
+
734
+ context.bank.subscribe(['orders', 'menu'], async (properties) => {
735
+ // remote Data Bank changes to the watched property ids
736
+ })
737
+
738
+ context.onEvent(async ({ event, data, source }) => {
739
+ // another local function called context.daemons.emit('<this-file>', event, data)
740
+ })
741
+
742
+ return () => {
743
+ // optional cleanup, runs when the daemon stops
744
+ }
745
+ }
746
+ ```
747
+
748
+ The daemon context is the full function context plus lifetime APIs. `setInterval(cb, ms)` / `clearInterval(handle)` are managed timers: a callback that throws is logged (and recorded on `/status`) but never stops the timer or the daemon; everything is cleared when the daemon stops. `bank.subscribe(propertyIds, onChange)` uses the same Bank credentials as `context.bank` (`bricks buttress bank-key`; throws until stored); all subscriptions share one auto-reconnecting connection, so a network drop never stops the daemon — note a daemon that writes the properties it watches also notifies itself unless it passes `{ dontNotify: true }`. `onEvent(handler)` receives what other local functions (and daemons) send with `context.daemons.emit(name, event, data?)`, as `{ event, data, source }`; `emit` throws when the target daemon is not running or registered no handler, and events emitted while a daemon is still starting are buffered until it settles.
749
+
750
+ The handler runs with no deadline; `context.signal` aborts when the daemon stops. A file edit restarts it (cleanup → fresh module → start) — on save with `hot_reload = true`, otherwise on the periodic (~60s) reconcile sweep. A start invocation that throws, or a file that stops loading, suspends the daemon (shown as an error on `/status`) until the file changes again. On stop, timers and subscriptions are torn down, spawned children are killed, and the returned cleanup gets a bounded (~10s) run. `context.emit` (SSE progress) is a no-op inside a daemon — there is no caller; use `context.log` and the status page.
751
+
703
752
  Functions may `import` Node builtins (`node:fs/promises`, …), sibling files inside the functions directory, and the two provided database packages `sqlite3` and `sqlite-vec`. Other package imports are rejected. `sqlite-vec` supports macOS and Linux on x64/arm64, plus Windows x64; its upstream package does not ship a Windows arm64 extension, so the standalone Windows arm64 build does not provide either SQLite import.
704
753
 
705
754
  Edits are picked up on the next call — the server re-transpiles when a file in the function's module graph changes, so no restart is needed. A file that fails to load is logged and skipped; the rest keep working.
@@ -718,7 +767,7 @@ For faster authoring feedback, opt into eager reloading with `[functions] hot_re
718
767
  | `GET /functions/files/<path>` | Download a file a function wrote to its `tempDir` — functions hand out these URLs via `context.fileUrl` |
719
768
  | `POST /functions/upload` | Stage an input file on the server (multipart, `file` field) → `{ "path", "url", "name", "size" }` |
720
769
 
721
- Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
770
+ Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_NOT_CALLABLE` (400, the name is a daemon), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
722
771
 
723
772
  #### Calling with GET
724
773
 
@@ -752,7 +801,7 @@ curl -X POST <base>/functions/transcribe-media -F file=@interview.mp4
752
801
 
753
802
  To stage a file once and reuse it across calls, `curl -F file=@interview.mp4 <base>/functions/upload` stores it in its own scratch directory and returns `{ "path", "url", "name", "size" }` — pass `path` as the function's input. Either way the client file name is sanitized to a bare name, and staged files share the auth guard and the ~24h sweep. Requests are capped by `[server] max_body_size` (default 50MB) — raise it for large media.
754
803
 
755
- All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials).
804
+ All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), daemon callback runs, uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials). Daemons additionally get a live table: running/error state, start time, active timers, Bank subscription health, listening flag, and callback run/failure counts.
756
805
 
757
806
  To point an agent at the MCP endpoint:
758
807
 
@@ -802,6 +851,117 @@ The handler receives `{ method, path, name?, headers, query, token, workspaceAut
802
851
 
803
852
  Function files themselves are trusted input, exactly like this config file. They run in a `node:vm` context with a clean global (no ambient `process` or `require`), but that is for clarity, not isolation — a function that is handed `spawn` can do anything the server process can. Only put code you wrote (or reviewed) in the functions directory.
804
853
 
854
+ ## Agents (Experimental)
855
+
856
+ Buttress can host **agents**: pi-based LLM loops that run inside the server
857
+ process, use **local functions** (and MCP servers) as their tools, and keep
858
+ **config-scoped sessions** on disk. The primary consumer is automation — a
859
+ local function or daemon calls `context.agents.run(...)` to add multi-step
860
+ reasoning to a server-side workflow; an interactive CLI exists for driving and
861
+ inspecting the same agents.
862
+
863
+ ```toml
864
+ [[agents]]
865
+ name = "ops-assistant" # unique; the session scope key
866
+ model = "buttress/ggml-org/gpt-oss-20b-GGUF" # split on the FIRST slash: provider/model-id
867
+ # model = "anthropic/claude-sonnet-5" # any pi-supported provider; API key from env
868
+ system_prompt_file = "./prompts/ops.md" # or inline: system_prompt = "..."
869
+ tools = ["get_server_status", "restart_service"] # local function names (explicit; no wildcard)
870
+ max_turns = 30 # assistant↔tool round-trips per run (default 30)
871
+ # max_tokens_per_run = 200000 # per-run token budget; unset = unlimited
872
+ # temperature = 0.2 # unrecognized keys pass through to generation
873
+
874
+ [agents.mcp_servers.github] # optional MCP servers (StreamableHTTP or stdio)
875
+ url = "https://api.githubcopilot.com/mcp/"
876
+ # headers = { Authorization = "Bearer ..." }
877
+ # optional = true # continue without this server if it won't connect
878
+
879
+ [agents_options] # optional; defaults shown
880
+ # sessions_dir = "./.buttress-agent/sessions" # relative to this config file
881
+ # session_max_age = "30d" # retention sweep; 0 disables
882
+ # session_max_count = 500 # per agent; 0 disables
883
+ # max_depth = 2 # function → agent → function → agent chain cap
884
+ # allow_unauthenticated = false # serve /agents on an UNBOUND server (see below)
885
+ ```
886
+
887
+ **Models.** `buttress/<repo_id>` targets a configured `[[generators]]` LLM
888
+ (ggml/mlx) and is validated at startup; traffic flows through an in-process
889
+ OpenAI-compat loopback (no socket, no extra config — `[openai_compat] enabled`
890
+ still only governs the external HTTP route). Any other provider prefix is a pi
891
+ built-in provider (`anthropic/…`, `openai/…`, `google/…`, …) authenticated by
892
+ its usual environment variable (`ANTHROPIC_API_KEY`, …) — set it via `[env]`
893
+ or the process environment. OAuth-based logins are not supported headless.
894
+
895
+ **Tools.** `tools` lists local function names explicitly. Each tool call runs
896
+ through the normal functions executor (same lazy reload, scratch dir, spawn
897
+ tracking, and the function's own `meta.timeout` deadline), and aborting the
898
+ run aborts in-flight tool calls and their spawned processes. A listed function
899
+ that is missing fails the run loudly rather than letting a headless automation
900
+ improvise around it. MCP tools get server-qualified names
901
+ (`mcp__github__create_issue`); MCP servers connect lazily on the first run and
902
+ fail closed unless marked `optional = true`.
903
+
904
+ **Sessions.** Each run returns a `sessionId`; pass it back to continue the
905
+ conversation, or add `fork: true` to branch it into a fresh session. Sessions
906
+ are JSONL files under `sessions_dir`, scoped by agent name (renaming an agent
907
+ orphans its sessions), written as the run streams — an aborted or timed-out
908
+ run still leaves a continuable transcript. Same-session runs queue; different
909
+ sessions run in parallel. A retention sweep prunes by age and count.
910
+
911
+ **From a local function** (the primary surface):
912
+
913
+ ```ts
914
+ export default async ({ service }, context) => {
915
+ const result = await context.agents.run('ops-assistant', {
916
+ prompt: `Investigate the '${service}' service and fix it if needed.`,
917
+ // sessionId, fork, onEvent are also accepted
918
+ })
919
+ return { conclusion: result.content, sessionId: result.sessionId }
920
+ }
921
+ ```
922
+
923
+ `context.agents.run` resolves within the caller's lifetime and deadline
924
+ (`context.signal` aborts it); long-running agent work belongs in a **daemon**
925
+ (no deadline) or a function with a raised `meta.timeout`. `context.agents.list()`
926
+ and `context.agents.sessions(name)` round out the surface. Chained invocations
927
+ (a tool function calling another agent) are capped by `agents_options.max_depth`.
928
+
929
+ **HTTP endpoints** (also what the CLI uses):
930
+
931
+ ```
932
+ GET /agents configured agent names
933
+ POST /agents/:name/run { prompt, sessionId?, fork? }; ?stream=1 for SSE
934
+ (a `session` event with the id arrives first,
935
+ then `agent` events, then `result`/`error`)
936
+ GET /agents/:name/sessions newest-first summaries (id, timestamps, preview)
937
+ GET /agents/:name/sessions/:id full transcript
938
+ POST /agents/:name/sessions/:id/abort abort the active run
939
+ ```
940
+
941
+ Auth mirrors the functions surface, not the open inference endpoints: a bound
942
+ server requires a workspace JWT; an **unbound server rejects remote calls**
943
+ unless `allow_unauthenticated = true`. The server also writes an ephemeral
944
+ internal token to `<sessions_dir>/../runtime-token` (mode 0600) at startup —
945
+ same-host CLIs authenticate with it automatically, bound or not.
946
+
947
+ **Interactive CLI**:
948
+
949
+ ```sh
950
+ bricks-buttress agent -c config.toml # list agents
951
+ bricks-buttress agent ops-assistant -c config.toml # chat (streams text, thinking, tool calls)
952
+ bricks-buttress agent ops-assistant --sessions -c config.toml
953
+ bricks-buttress agent ops-assistant --session <id> -c config.toml # continue
954
+ bricks-buttress agent ops-assistant --fork <id> -c config.toml # fork, then continue the fork
955
+ ```
956
+
957
+ On a real terminal the chat runs a pi-tui interface (markdown answers, dim
958
+ thinking, tool-call lines, Ctrl+C aborts the running turn); pipes/scripts —
959
+ or `--plain` — get a line-based renderer with identical semantics.
960
+
961
+ Like function files, agent definitions are trusted input: an agent is only as
962
+ safe as the functions and MCP servers you hand it.
963
+
964
+
805
965
  ## Session State Cache
806
966
 
807
967
  The server supports session state caching for ggml-llm generators, which saves KV cache state to disk after completions. This enables:
@@ -14,6 +14,8 @@ 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
+ | `bank-note.ts` | Reading/writing the workspace's remote Data Bank (`context.bank`) | `bricks buttress bank-key` run on this host |
18
+ | `bank-watch-daemon.ts` | A daemon (`meta.daemon = true`): `context.bank.subscribe`, `context.setInterval`, `context.onEvent` | `bricks buttress bank-key` run on this host |
17
19
  | `_auth.ts` | Custom auth: keep workspace tokens working, add static API keys | see the file header |
18
20
 
19
21
  `_auth.ts` is not a function: copying it changes how every `/functions` endpoint
@@ -0,0 +1,47 @@
1
+ // Read and write a property in the workspace's remote Data Bank.
2
+ //
3
+ // Needs stored Data Bank credentials: run `bricks buttress bank-key` from a
4
+ // workspace-authed CLI on this host, then restart the server. Without them
5
+ // every `context.bank` method throws.
6
+ //
7
+ // curl -X POST http://<host>:<port>/functions/bank-note \
8
+ // -H 'content-type: application/json' \
9
+ // -d '{"propertyId": "shared-note", "text": "hello from buttress"}'
10
+ //
11
+ // Omit `text` to read the property without writing.
12
+
13
+ export const meta: ButtressFunctionMeta = {
14
+ description: 'Read a Data Bank property, optionally replacing its text value first',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ propertyId: { type: 'string', description: 'Data Bank property id' },
19
+ text: { type: 'string', description: 'New value; omit to only read' },
20
+ },
21
+ required: ['propertyId'],
22
+ },
23
+ }
24
+
25
+ export default async function (
26
+ { propertyId, text }: { propertyId: string; text?: string },
27
+ context: ButtressFunctionContext,
28
+ ) {
29
+ if (text != null) {
30
+ // `update` replaces the stored value unconditionally (an omitted `value`
31
+ // clears it) — read-merge first when only part of an object should change.
32
+ await context.bank.update([
33
+ { propertyId, value: text, updateNote: 'Updated via bank-note function' },
34
+ ])
35
+ }
36
+
37
+ const property = await context.bank.get(propertyId)
38
+ if (!property) return { propertyId, exists: false }
39
+
40
+ return {
41
+ propertyId,
42
+ exists: true,
43
+ value: property.value,
44
+ updateAt: property.updateAt,
45
+ lastUpdateNote: property.lastUpdateNote,
46
+ }
47
+ }
@@ -0,0 +1,63 @@
1
+ // Daemon: watch Data Bank properties and keep a change journal.
2
+ //
3
+ // A file with `meta.daemon = true` runs as a daemon — a long-lived
4
+ // background function. Its default export runs ONCE when the daemon starts,
5
+ // and everything registered on the context keeps running after it returns.
6
+ // Daemons are not MCP tools and cannot be called over HTTP; their live
7
+ // status shows on the /status page.
8
+ //
9
+ // This one reacts to remote Data Bank changes (needs stored credentials —
10
+ // run `bricks buttress bank-key` on this host, then restart the server),
11
+ // flushes hourly, and accepts a `flush` event from other local functions:
12
+ //
13
+ // context.daemons.emit('bank-watch-daemon', 'flush')
14
+
15
+ export const meta: ButtressFunctionMeta = {
16
+ description: 'Journal changes to watched Data Bank properties',
17
+ daemon: true,
18
+ }
19
+
20
+ export default async function (context: ButtressDaemonContext) {
21
+ let journal: { at: string; propertyId: string; value: unknown }[] = []
22
+
23
+ const flush = async () => {
24
+ if (journal.length === 0) return
25
+ const entries = journal
26
+ journal = []
27
+ // `dontNotify` keeps this daemon from waking itself up with its own write.
28
+ await context.bank.update([{ propertyId: 'shared-note-journal', value: entries }], {
29
+ dontNotify: true,
30
+ })
31
+ context.log(`flushed ${entries.length} journal entr${entries.length === 1 ? 'y' : 'ies'}`)
32
+ }
33
+
34
+ // Changes to the watched ids arrive here over a shared, auto-reconnecting
35
+ // connection — a network drop never stops the daemon.
36
+ context.bank.subscribe(['shared-note'], (properties) => {
37
+ for (const property of properties) {
38
+ journal.push({
39
+ at: new Date().toISOString(),
40
+ propertyId: property.propertyId,
41
+ value: property.value,
42
+ })
43
+ }
44
+ context.log(`journaled ${properties.length} change(s), ${journal.length} pending`)
45
+ })
46
+
47
+ // A managed setInterval: cleared automatically when the daemon stops, and
48
+ // a callback that throws is logged without stopping the timer.
49
+ context.setInterval(flush, 60 * 60 * 1000)
50
+
51
+ // Other local functions can force a flush via context.daemons.emit.
52
+ context.onEvent(({ event, source }) => {
53
+ if (event === 'flush') {
54
+ context.log(`flush requested by ${source}`)
55
+ return flush()
56
+ }
57
+ })
58
+
59
+ context.log('watching shared-note')
60
+
61
+ // Runs when the daemon stops (file edited/removed, or server shutdown).
62
+ return () => flush()
63
+ }
@@ -0,0 +1,39 @@
1
+ // Local functions can drive a configured agent (see [[agents]] in the server
2
+ // config): context.agents.run executes a full tool-using LLM loop server-side
3
+ // and returns the final answer plus the session id for follow-ups.
4
+ //
5
+ // The run shares this call's lifetime — the function's deadline (meta.timeout)
6
+ // and client disconnects abort it, including in-flight tool calls. Long agent
7
+ // work belongs in a daemon (no deadline) instead.
8
+
9
+ export const meta = {
10
+ description: 'Ask a configured agent to investigate something and return its conclusion.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ agent: { type: 'string', description: 'Configured agent name' },
15
+ question: { type: 'string' },
16
+ sessionId: { type: 'string', description: 'Continue an earlier session (optional)' },
17
+ },
18
+ required: ['agent', 'question'],
19
+ },
20
+ timeout: '10m',
21
+ }
22
+
23
+ export default async (
24
+ { agent, question, sessionId }: { agent: string; question: string; sessionId?: string },
25
+ context: any,
26
+ ) => {
27
+ const result = await context.agents.run(agent, {
28
+ prompt: question,
29
+ sessionId,
30
+ // Progress mirrors onto this call's SSE stream automatically; onEvent is
31
+ // available for custom handling of the raw pi events.
32
+ })
33
+ return {
34
+ conclusion: result.content,
35
+ sessionId: result.sessionId,
36
+ turns: result.usage.totalTurns,
37
+ stopReason: result.stopReason,
38
+ }
39
+ }
@@ -45,6 +45,28 @@ enabled = true
45
45
  # api_base = "https://example.internal"
46
46
  # api_keys = ["a-long-random-string"] # e.g. consumed by function-samples/_auth.ts
47
47
 
48
+ # Agents (EXPERIMENTAL): pi-based LLM loops running in this server, with local
49
+ # functions (and MCP servers) as tools and config-scoped sessions on disk.
50
+ # Local functions call them via context.agents.run(...); an interactive CLI
51
+ # (`bricks-buttress agent <name> -c <config>`) drives the same agents.
52
+ # See the "Agents" section of README.md.
53
+ # [[agents]]
54
+ # name = "ops-assistant"
55
+ # model = "buttress/ggml-org/gpt-oss-20b-GGUF" # provider/model-id, split on the FIRST slash
56
+ # system_prompt = "You are an ops automation agent. Use the provided tools."
57
+ # tools = ["host-info"] # local function names (explicit; no wildcard)
58
+ # max_turns = 30
59
+ # [agents.mcp_servers.example]
60
+ # url = "https://example.com/mcp/"
61
+ # optional = true
62
+
63
+ # [agents_options]
64
+ # sessions_dir = "./.buttress-agent/sessions"
65
+ # session_max_age = "30d" # retention; 0 disables
66
+ # session_max_count = 500 # per agent; 0 disables
67
+ # max_depth = 2 # function -> agent -> function chain cap
68
+ # allow_unauthenticated = false # serve /agents on an UNBOUND server
69
+
48
70
  [runtime]
49
71
  cache_dir = "./.buttress-cache"
50
72
  # huggingface_token = "hf_xx"
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `bricks-buttress agent` — chat client for configured agents.
3
+ *
4
+ * A thin remote client of the /agents endpoints on a RUNNING server: the agent
5
+ * loop, tools, and sessions all live server-side. Reads the config file only
6
+ * to find the server port and the local runtime token (written 0600 by the
7
+ * server at startup), so the same-host flow needs zero extra setup.
8
+ *
9
+ * On a real terminal the pi-tui chat UI runs (see tui.ts); pipes/scripts (or
10
+ * --plain) get a line-based streaming renderer with identical semantics.
11
+ *
12
+ * bricks-buttress agent list configured agents
13
+ * bricks-buttress agent <name> chat (new session)
14
+ * bricks-buttress agent <name> --session <id> continue a session
15
+ * bricks-buttress agent <name> --fork <id> fork then chat
16
+ * bricks-buttress agent <name> --sessions list sessions
17
+ * Common: -c/--config <path|toml>, --url <server>, --token <token>, --plain
18
+ */
19
+ export declare const runAgentCommand: (argv: string[]) => Promise<void>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared client plumbing for the `bricks-buttress agent` front-ends (line mode
3
+ * and the pi-tui chat): config/token discovery, authenticated requests, SSE
4
+ * parsing, and the streaming run call. UI-free on purpose.
5
+ */
6
+ export type Connection = {
7
+ baseUrl: string;
8
+ token: string | null;
9
+ };
10
+ /** Same config-argument semantics as the server CLI: a path or inline TOML. */
11
+ export declare const loadClientConfig: (configArg: string | null) => {
12
+ config: import("../types").Config;
13
+ configDir: string;
14
+ agentsConfig: import("./types").AgentsConfig | null;
15
+ };
16
+ export declare const readRuntimeToken: (file: string) => string | null;
17
+ export declare const request: (connection: Connection, route: string, init?: RequestInit) => Promise<Response>;
18
+ /** Minimal text/event-stream reader: yields { event, data } frames. */
19
+ export declare function readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{
20
+ event: string;
21
+ data: string;
22
+ }, void, unknown>;
23
+ export type RunFrame = {
24
+ event: string;
25
+ payload: any;
26
+ };
27
+ export type RunOutcome = {
28
+ kind: 'result';
29
+ result: any;
30
+ } | {
31
+ kind: 'error';
32
+ message: string;
33
+ sessionId: string | null;
34
+ } | {
35
+ kind: 'aborted';
36
+ sessionId: string | null;
37
+ } | {
38
+ kind: 'disconnected';
39
+ sessionId: string | null;
40
+ };
41
+ export type StreamRunBody = {
42
+ prompt: string;
43
+ sessionId?: string;
44
+ fork?: boolean;
45
+ };
46
+ /**
47
+ * Whether a `--fork` chat has actually forked yet, i.e. whether the flag has
48
+ * been consumed and later prompts should continue rather than fork again.
49
+ *
50
+ * The server only forks once it accepts the run, and announces the new id
51
+ * (`session` event, or `result.sessionId` on an older server). A run that
52
+ * failed before that — a rejected token, an unknown agent, a dropped
53
+ * connection, an immediate Ctrl+C — leaves the client on the SOURCE session, so
54
+ * the flag has to survive: clearing it would make the user's retry append to
55
+ * the transcript `--fork` exists to keep untouched.
56
+ */
57
+ export declare const forkTookEffect: (startedFrom: string | null, sessionId: string | null) => boolean;
58
+ /**
59
+ * Run a prompt over the streaming endpoint, delivering every parsed frame to
60
+ * `onFrame` and returning the terminal outcome. Frame handler errors are the
61
+ * caller's problem — they propagate.
62
+ */
63
+ export declare const streamRun: (connection: Connection, name: string, body: StreamRunBody, { signal, onFrame }: {
64
+ signal?: AbortSignal;
65
+ onFrame: (frame: RunFrame) => void;
66
+ }) => Promise<RunOutcome>;
@@ -0,0 +1,15 @@
1
+ import type { Config } from '../types';
2
+ import type { AgentsConfig } from './types';
3
+ export declare class AgentsConfigError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ export type ResolveAgentsOptions = {
7
+ /** Directory relative paths resolve against — the `--config` file's directory. */
8
+ configDir?: string;
9
+ };
10
+ /**
11
+ * Resolve `[[agents]]` + `[agents_options]` into the runtime shape. Returns
12
+ * null when no agents are configured. Invalid definitions throw — a wrong
13
+ * agent config should stop startup, not silently drop an agent.
14
+ */
15
+ export declare const resolveAgentsConfig: (config: Config, { configDir }?: ResolveAgentsOptions) => AgentsConfig | null;
@@ -0,0 +1,11 @@
1
+ import type { AgentsFunctionApi, AgentsService } from './types';
2
+ /**
3
+ * The `context.agents` surface local functions receive. Binds the caller's
4
+ * abort signal (function deadline / client disconnect cascades into the run),
5
+ * its emit stream, and its agent-invocation depth.
6
+ */
7
+ export declare const buildAgentsFunctionApi: (service: AgentsService | null | undefined, { signal, emit, depth, }: {
8
+ signal: AbortSignal;
9
+ emit?: (event: string, data?: unknown) => void;
10
+ depth: number;
11
+ }) => AgentsFunctionApi;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * In-process loopback for agent → buttress model traffic, plus the internal
3
+ * bearer token that authorizes it (and same-host CLIs) without a cloud-minted
4
+ * workspace JWT — a bound server only holds the issuer *public* key and
5
+ * cannot mint its own JWTs.
6
+ */
7
+ export type ElysiaHandle = (request: Request) => Response | Promise<Response>;
8
+ export declare const generateInternalToken: () => string;
9
+ export declare const makeInternalTokenVerifier: (token: string) => (candidate: string | null | undefined) => boolean;
10
+ /**
11
+ * Write the internal token where a same-host `bricks-buttress agent` CLI can
12
+ * read it (0600, next to the sessions dir — config-scoped like everything
13
+ * else about agents). Rewritten on every startup: the token is ephemeral.
14
+ */
15
+ export declare const writeRuntimeTokenFile: (file: string, token: string) => void;
16
+ /**
17
+ * Fetch-shaped dispatcher into the local Elysia app. The request never touches
18
+ * a socket; the internal token replaces whatever Authorization the client
19
+ * library synthesized so the shared auth guard accepts it on bound servers.
20
+ */
21
+ export declare const createLoopbackFetch: (getHandle: () => ElysiaHandle | null, token: string) => typeof fetch;
@@ -0,0 +1,23 @@
1
+ import type { AgentMcpServerConfig } from './types';
2
+ export type McpAgentTool = {
3
+ name: string;
4
+ label: string;
5
+ description: string;
6
+ parameters: Record<string, any>;
7
+ execute: (toolCallId: string, args: any, signal?: AbortSignal) => Promise<any>;
8
+ _mcpServer: string;
9
+ _mcpToolName: string;
10
+ };
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
+ export type McpManager = {
14
+ /**
15
+ * Tools for every configured server of an agent. Fail-closed: a server that
16
+ * won't connect rejects the whole call unless it is marked `optional`.
17
+ */
18
+ toolsFor: (agentName: string, servers: Record<string, AgentMcpServerConfig>) => Promise<McpAgentTool[]>;
19
+ dispose: () => Promise<void>;
20
+ };
21
+ export declare const createMcpManager: ({ configDir }: {
22
+ configDir: string;
23
+ }) => McpManager;
@@ -0,0 +1,20 @@
1
+ import { type Model, type MutableModels } from '@earendil-works/pi-ai';
2
+ import type { Config } from '../types';
3
+ import type { AgentDefinition } from './types';
4
+ export declare const BUTTRESS_PROVIDER_ID = "buttress";
5
+ /**
6
+ * Loopback requests never leave the process: this host name only exists so the
7
+ * OpenAI client has a syntactically valid base URL to resolve paths against.
8
+ * The injected fetch dispatches the request straight into the Elysia app.
9
+ */
10
+ export declare const LOOPBACK_BASE_URL = "http://buttress.internal/oai-compat/v1";
11
+ /** Fetch-shaped dispatcher into the local Elysia app (see loopback.ts). */
12
+ export type LoopbackFetch = typeof fetch;
13
+ /**
14
+ * A `Models` collection with every pi built-in provider (env-var API keys)
15
+ * plus the `buttress` pseudo-provider whose models are the configured LLM
16
+ * generators, streamed over the in-process OpenAI-compat loopback.
17
+ */
18
+ export declare const buildAgentModels: (config: Config, loopbackFetch: LoopbackFetch) => MutableModels;
19
+ /** Resolve an agent's model reference against the registry, with clear errors. */
20
+ export declare const resolveAgentModel: (models: MutableModels, agent: AgentDefinition) => Model<any>;
@@ -0,0 +1,16 @@
1
+ import type { Config } from '../types';
2
+ import type { FunctionsService } from '../functions';
3
+ import type { FunctionRuntime } from '../functions/types';
4
+ import { type ElysiaHandle } from './loopback';
5
+ import { type AgentsConfig, type AgentsService } from './types';
6
+ export type CreateAgentsServiceOptions = {
7
+ config: Config;
8
+ agentsConfig: AgentsConfig;
9
+ /** Server runtime handed to function tool calls; `agents` is added here. */
10
+ runtimeBase: Omit<FunctionRuntime, 'agents'>;
11
+ /** Late-bound: the Elysia app exists only after the service (circular wiring). */
12
+ getHandle: () => ElysiaHandle | null;
13
+ /** Late-bound for the same reason (functions service needs the agents service). */
14
+ getFunctions: () => FunctionsService | null;
15
+ };
16
+ export declare const createAgentsService: ({ config, agentsConfig, runtimeBase, getHandle, getFunctions, }: CreateAgentsServiceOptions) => AgentsService;
@@ -0,0 +1,3 @@
1
+ import type { FileSystem } from '@earendil-works/pi-agent-core';
2
+ /** File storage for agent sessions that classifies errors structurally across vm realms. */
3
+ export declare const createSessionFs: (rootDir: string) => FileSystem;
@@ -0,0 +1,17 @@
1
+ import type { AgentMessage, Session } from '@earendil-works/pi-agent-core';
2
+ import type { AgentSessionSummary, AgentsConfig } from './types';
3
+ export type AgentSessionStore = {
4
+ create: (agentName: string) => Promise<Session<any>>;
5
+ /** Throws when the id is unknown within the agent's scope. */
6
+ open: (agentName: string, sessionId: string) => Promise<Session<any>>;
7
+ fork: (agentName: string, sessionId: string) => Promise<Session<any>>;
8
+ append: (session: Session<any>, message: AgentMessage) => Promise<string>;
9
+ close: (session: Session<any>) => Promise<void>;
10
+ list: (agentName: string, limit?: number) => Promise<AgentSessionSummary[]>;
11
+ /** Reconstruct the pi message history for continuing a session. */
12
+ messages: (session: Session<any>) => Promise<AgentMessage[]>;
13
+ /** Retention sweep across every configured agent scope. */
14
+ sweep: (agentNames: string[]) => Promise<number>;
15
+ dispose: () => Promise<void>;
16
+ };
17
+ export declare const createAgentSessionStore: (config: AgentsConfig) => AgentSessionStore;