vis - broad host facade.
This is the ONLY namespace extensions, channel adapters, embedded
callers, and tests should import. It deliberately re-exports host,
registry, runtime, persistence, prompt, diagnostic, and sandbox
helpers from com.blockether.vis.internal.*. The internal tree is
not stable; the names exposed here are the host contract.
Canonical runtime language: Session -> Turn -> Iteration -> Block.
A Turn is one user request plus assistant answer inside a Session. New code and documentation should use turn/user-request language.
Primary surfaces:
persistance; human-facing language is Persistence.Not every export is equally high-level. send!, extension maps,
registry builders, read-side persistence helpers, and Markdown export
are the preferred integration surface. Low-level sandbox, parse-repair,
dispatcher, and write-side db helpers are exported because this is a
host facade, but ordinary extensions should avoid depending on them
unless they are implementing host-level behavior.
Binary entry: -main (invoked by clojure -M:vis, AOT'd to a Java
entry class via :gen-class for the GraalVM native-image build).
vis - broad host facade.
This is the ONLY namespace extensions, channel adapters, embedded
callers, and tests should import. It deliberately re-exports host,
registry, runtime, persistence, prompt, diagnostic, and sandbox
helpers from `com.blockether.vis.internal.*`. The internal tree is
not stable; the names exposed here are the host contract.
Canonical runtime language:
Session -> Turn -> Iteration -> Block.
A Turn is one user request plus assistant answer inside a Session.
New code and documentation should use turn/user-request language.
Primary surfaces:
- Session / turn runtime: create!, send!, turn!, by-id,
by-channel, env-for, close!, delete!, set-title!.
- Environment runtime: create-environment, dispose-environment!,
get-router, rebuild-router!, resolve-effective-model.
- Extension contract: extension, symbol, value, render-prompt,
register-extension!, registered-extensions, discovery, reload.
- Registries: command, channel, provider, and backend registration
helpers for host-owned and embedded use.
- Persistence facade: db-* functions and connection helpers. The
implementation namespace / extension slot are spelled
`persistance`; human-facing language is Persistence.
- Prompt / Python-sandbox / formatting / cancellation /
notifications / doctor helpers shared by channels and extensions.
Not every export is equally high-level. `send!`, extension maps,
registry builders, read-side persistence helpers, and Markdown export
are the preferred integration surface. Low-level sandbox, parse-repair,
dispatcher, and write-side db helpers are exported because this is a
host facade, but ordinary extensions should avoid depending on them
unless they are implementing host-level behavior.
Binary entry: -main (invoked by `clojure -M:vis`, AOT'd to a Java
entry class via `:gen-class` for the GraalVM native-image build).Project-guidance discovery — internal, no extension required.
STACKED context files, pi-style: guidance is collected from THREE layers and all of them ride into the PROJECT-INSTRUCTIONS system block, outermost first:
~/.vis/AGENTS.md (or ~/.vis/CLAUDE.md)AGENTS.md / CLAUDE.md in every ancestor
directory of the workspace root (outermost first)AGENTS.md / CLAUDE.md at the workspace rootAGENTS.md / CLAUDE.md at each ADDED filesystem
root's own directory (folders granted beyond the
primary workspace — no ancestor walk)Per DIRECTORY precedence is strict: AGENTS.md wins; CLAUDE.md is only consulted when AGENTS.md is absent in that directory. Across directories nothing is dropped — nearer files are rendered LATER so they positionally override outer rules on conflict.
Size policy: NO truncation. Every file goes into the system prompt verbatim. Provider prompt caching amortizes the cost across every iter in the session; trimming would risk dropping the very rule the user is testing. The cwd + per-file (path, mtime, length) marker cache below ensures files are re-read at most once per change.
Failure modes (file unreadable, permissions, I/O error) land in the read-warning vec, NOT in the rendered prompt. The model isn't bound by rules it can't see, but the host knows something is broken.
This namespace replaces the foundation-core/environment/agents.clj that used to live in the extension. Project-guidance discovery is core functionality (drives the system prompt + slim ctx digest); the extension layer no longer owns it.
Project-guidance discovery — internal, no extension required.
STACKED context files, pi-style: guidance is collected from THREE
layers and all of them ride into the PROJECT-INSTRUCTIONS system
block, outermost first:
1. user-global `~/.vis/AGENTS.md` (or `~/.vis/CLAUDE.md`)
2. ancestors `AGENTS.md` / `CLAUDE.md` in every ancestor
directory of the workspace root (outermost first)
3. workspace `AGENTS.md` / `CLAUDE.md` at the workspace root
4. added roots `AGENTS.md` / `CLAUDE.md` at each ADDED filesystem
root's own directory (folders granted beyond the
primary workspace — no ancestor walk)
Per DIRECTORY precedence is strict: AGENTS.md wins; CLAUDE.md is
only consulted when AGENTS.md is absent in that directory. Across
directories nothing is dropped — nearer files are rendered LATER so
they positionally override outer rules on conflict.
Size policy: NO truncation. Every file goes into the system prompt
verbatim. Provider prompt caching amortizes the cost across every
iter in the session; trimming would risk dropping the very rule the
user is testing. The cwd + per-file (path, mtime, length) marker
cache below ensures files are re-read at most once per change.
Failure modes (file unreadable, permissions, I/O error) land in the
read-warning vec, NOT in the rendered prompt. The model isn't bound
by rules it can't see, but the host knows something is broken.
This namespace replaces the foundation-core/environment/agents.clj
that used to live in the extension. Project-guidance discovery is
core functionality (drives the system prompt + slim ctx digest); the
extension layer no longer owns it.Attachment storage-offload rail: a registry of storage BACKENDS plus the
pure OFFLOAD DECISION that routes one attachment's payload either INLINE
(bytes in the session_attachment.bytes BLOB) or EXTERNAL (bytes handed to
a backend, which returns a storage_uri -- scheme://... -- kept in the row
instead).
Zero SQL, zero schema change: the V4 session_attachment table already
carries a nullable storage_uri with an exactly-one(bytes, storage_uri)
CHECK. This namespace only decides WHICH of the two a given attachment takes,
PUTs/GETs the external bytes through the scheme-dispatched backend, and
hydrates a read-back envelope's :base64 from its :storage-uri on demand.
The decision is a PURE predicate hot? AND size (see default-offload?):
an image replays to a vision model every turn its iteration stays live, so it
is HOT -- kept inline even when large; a non-image artifact (PDF/CSV/wav/
download) is fetched at most once by a human, so it is COLD -- a good offload
candidate past a size floor. A backend may override the predicate wholesale
via :storage/offload?.
Precedence, engine-owned so the loop never learns a storage dialect:
:storage/offload? (the backend knows its own cost)default-offload? (engine default policy)Attachment storage-offload rail: a registry of storage BACKENDS plus the pure OFFLOAD DECISION that routes one attachment's payload either INLINE (bytes in the `session_attachment.bytes` BLOB) or EXTERNAL (bytes handed to a backend, which returns a `storage_uri` -- `scheme://...` -- kept in the row instead). Zero SQL, zero schema change: the V4 `session_attachment` table already carries a nullable `storage_uri` with an exactly-one(bytes, storage_uri) CHECK. This namespace only decides WHICH of the two a given attachment takes, PUTs/GETs the external bytes through the scheme-dispatched backend, and hydrates a read-back envelope's `:base64` from its `:storage-uri` on demand. The decision is a PURE predicate `hot? AND size` (see `default-offload?`): an image replays to a vision model every turn its iteration stays live, so it is HOT -- kept inline even when large; a non-image artifact (PDF/CSV/wav/ download) is fetched at most once by a human, so it is COLD -- a good offload candidate past a size floor. A backend may override the predicate wholesale via `:storage/offload?`. Precedence, engine-owned so the loop never learns a storage dialect: 1. active backend's `:storage/offload?` (the backend knows its own cost) 2. else `default-offload?` (engine default policy) 3. no active backend -> always inline (zero regression)
User-message image attachments.
Dropping a file onto the terminal pastes its PATH into the input (the terminal's drop behavior — same mechanism pi relies on). At turn start the engine scans the user message for path-shaped tokens that resolve to real image files, reads them, and attaches them to the initial user message as multimodal content blocks. Channel-neutral: TUI, web, and Telegram all get the same behavior because the scan runs in the engine, not the channel.
Only files the model can genuinely consume are attached: the MIME type
is sniffed from magic bytes (pi-parity: jpeg / non-animated png / gif /
webp / bmp), never trusted from the extension alone. Files over
max-image-bytes are skipped with a reason the prompt assembler
surfaces to the model (providers downscale server-side; vis does no
client-side resizing — AWT/ImageIO is unavailable under GraalVM
native-image on macOS).
User-message image attachments. Dropping a file onto the terminal pastes its PATH into the input (the terminal's drop behavior — same mechanism pi relies on). At turn start the engine scans the user message for path-shaped tokens that resolve to real image files, reads them, and attaches them to the initial user message as multimodal content blocks. Channel-neutral: TUI, web, and Telegram all get the same behavior because the scan runs in the engine, not the channel. Only files the model can genuinely consume are attached: the MIME type is sniffed from magic bytes (pi-parity: jpeg / non-animated png / gif / webp / bmp), never trusted from the extension alone. Files over `max-image-bytes` are skipped with a reason the prompt assembler surfaces to the model (providers downscale server-side; vis does no client-side resizing — AWT/ImageIO is unavailable under GraalVM native-image on macOS).
Cancellation token - leaf module.
The cancellation token is a tiny two-atom record that lets a UI
thread (TUI, Telegram bot, REPL caller) cooperatively abort an
in-flight turn! AND interrupt the worker future hosting the
blocking provider call. The cooperative side is checked at every
iteration boundary; the future side hard-cancels any HTTP call
that has already started.
Public API:
(cancellation-token) - fresh token
(cancellation-atom token) - cooperative flag atom (pass to turn!)
(cancellation-set-future! token fut) - register the worker future
(cancel! token) - set flag + interrupt registered future
(cancelled? token) - true once cancel! has been called
(cancellation? throwable) - true if exception was caused by cancel!
This namespace has zero side effects at load time and depends only on Java interop - channels and the runtime can require it directly without pulling in the rest of the SDK.
Cancellation token - leaf module. The cancellation token is a tiny two-atom record that lets a UI thread (TUI, Telegram bot, REPL caller) cooperatively abort an in-flight `turn!` AND interrupt the worker future hosting the blocking provider call. The cooperative side is checked at every iteration boundary; the future side hard-cancels any HTTP call that has already started. Public API: `(cancellation-token)` - fresh token `(cancellation-atom token)` - cooperative flag atom (pass to `turn!`) `(cancellation-set-future! token fut)` - register the worker future `(cancel! token)` - set flag + interrupt registered future `(cancelled? token)` - true once `cancel!` has been called `(cancellation? throwable)` - true if exception was caused by `cancel!` This namespace has zero side effects at load time and depends only on Java interop - channels and the runtime can require it directly without pulling in the rest of the SDK.
Process-local channel event bus.
Extensions use this to talk to mounted channels without depending on their implementation namespaces. Channels subscribe while running and translate events into their local state/events. No listener failure may take down the publisher or sibling listeners.
Process-local channel event bus. Extensions use this to talk to mounted channels without depending on their implementation namespaces. Channels subscribe while running and translate events into their local state/events. No listener failure may take down the publisher or sibling listeners.
CLI command parsing, lookup, help rendering, dispatch.
The command spec, builder, and global registry now live in
com.blockether.vis.internal.registry alongside the channel and provider
registries. This namespace provides the operations OVER those
command maps:
Lookup find-leaf walk the tree consuming tokens until a match find-named same, but ignoring the root command's name
Argument parsing
parse-args parse residual tokens against :cmd/args
validate-args nil on success, error string on missing required
Help rendering
render-command detailed help for one command
render-tree top-level overview shown on vis with no args
color-enabled? dynamic toggle (TTY auto-detect)
pad-right width-padding helper
pad-left width-padding helper
Dispatch
dispatch! resolve, parse, validate, invoke :cmd/run-fn
The registry surface (command, register-cmd!, deregister-cmd!,
registered-commands, registered-under, resolve-subcommands)
is reachable through com.blockether.vis.internal.registry.
CLI command parsing, lookup, help rendering, dispatch.
The command spec, builder, and global registry now live in
`com.blockether.vis.internal.registry` alongside the channel and provider
registries. This namespace provides the operations OVER those
command maps:
Lookup
find-leaf walk the tree consuming tokens until a match
find-named same, but ignoring the root command's name
Argument parsing
parse-args parse residual tokens against `:cmd/args`
validate-args nil on success, error string on missing required
Help rendering
render-command detailed help for one command
render-tree top-level overview shown on `vis` with no args
*color-enabled?* dynamic toggle (TTY auto-detect)
pad-right width-padding helper
pad-left width-padding helper
Dispatch
dispatch! resolve, parse, validate, invoke `:cmd/run-fn`
The registry surface (`command`, `register-cmd!`, `deregister-cmd!`,
`registered-commands`, `registered-under`, `resolve-subcommands`)
is reachable through `com.blockether.vis.internal.registry`.Configuration: paths, JVM lifecycle, provider presets, svar-native coercion, config file I/O, and the active-provider state every channel reads through.
Two halves:
~/.vis/: config.edn, vis.mdb/, vis.log.
init! / init-cli! / shutdown! redirect stdout/stderr into
the log file and bring up Telemere's file handler.active-config atom holds the
currently-selected provider config; current-config,
active-provider, active-model, provider-ids,
has-provider? are the read API. reload-config! re-reads
from disk.The ->svar-provider helper resolves :api-key lazily by calling
the registered provider's :provider/get-token-fn, so the
token-refresh policy stays inside each provider implementation
instead of leaking up here.
Configuration: paths, JVM lifecycle, provider presets, svar-native
coercion, config file I/O, and the active-provider state every
channel reads through.
Two halves:
- On-disk config under `~/.vis/`: `config.edn`, `vis.mdb/`, `vis.log`.
`init!` / `init-cli!` / `shutdown!` redirect stdout/stderr into
the log file and bring up Telemere's file handler.
- Live process state: the `active-config` atom holds the
currently-selected provider config; `current-config`,
`active-provider`, `active-model`, `provider-ids`,
`has-provider?` are the read API. `reload-config!` re-reads
from disk.
The `->svar-provider` helper resolves `:api-key` lazily by calling
the registered provider's `:provider/get-token-fn`, so the
token-refresh policy stays inside each provider implementation
instead of leaking up here.Pure helpers for the model-facing context snapshot.
The mutable session state lives outside this namespace; these functions only advance the turn/iteration cursor, project form envelopes for persisted result messages, and compute utilization metadata.
Pure helpers for the model-facing context snapshot. The mutable session state lives outside this namespace; these functions only advance the turn/iteration cursor, project form envelopes for persisted result messages, and compute utilization metadata.
Loop integration layer for context management.
The loop keeps a per-session :ctx-atom for stable model-facing context
and a separate :turn-state-atom for live turn/iteration/form counters.
This namespace stamps the cursor, enriches context with env/resources/routing,
and renders the standing context block.
Loop integration layer for context management. The loop keeps a per-session `:ctx-atom` for stable model-facing context and a separate `:turn-state-atom` for live turn/iteration/form counters. This namespace stamps the cursor, enriches context with env/resources/routing, and renders the standing context block.
Pure renderer for the standing agent-facing session snapshot.
render-ctx-static projects the session view with project-ctx-static and
prints it via the same Python pretty-printer path used to bind the live
sandbox session dict, emitting a fenced Python session = {…} block — so the
visible embed and the runtime value share one canonical shape, and
render-ctx-delta mutates that same session with session[...] = … lines.
Pure renderer for the standing agent-facing `session` snapshot.
`render-ctx-static` projects the session view with `project-ctx-static` and
prints it via the same Python pretty-printer path used to bind the live
sandbox `session` dict, emitting a fenced Python `session = {…}` block — so the
visible embed and the runtime value share one canonical shape, and
`render-ctx-delta` mutates that same `session` with `session[...] = …` lines.Embedded documentation subsystem.
Any artifact on the classpath may ship docs by placing markdown under
resources/vis-docs/ with a resources/vis-docs/vis-docs.edn manifest:
{:site {:title "Vis" :tagline "…" :repo "https://…"} ; optional, site-level :pages [{:file "index.md" :title "Introduction" :section nil :order 0} {:file "x.md" :title "X" :section "Runtime" :order 30}]}
Every vis-docs/vis-docs.edn on the classpath is discovered (the same
per-artifact auto-discovery native-image config uses), so an extension adds a
page by dropping a markdown file + a manifest entry — no central registry.
One renderer, two outputs:
build-site! writes a static, themed HTML bundle (for GitHub Pages).handle serves the same pages live (HTMX nav), mountable on the gateway
via its :gateway.slot/http-routes slot.Markdown → HTML uses commonmark-java (already a dependency); the theme is an enterprise-grade docs layout (sticky header, sidebar, on-this-page rail) in the VIS palette (cobalt on white, Hanken Grotesk + JetBrains Mono).
Embedded documentation subsystem.
Any artifact on the classpath may ship docs by placing markdown under
`resources/vis-docs/` with a `resources/vis-docs/vis-docs.edn` manifest:
{:site {:title "Vis" :tagline "…" :repo "https://…"} ; optional, site-level
:pages [{:file "index.md" :title "Introduction" :section nil :order 0}
{:file "x.md" :title "X" :section "Runtime" :order 30}]}
Every `vis-docs/vis-docs.edn` on the classpath is discovered (the same
per-artifact auto-discovery native-image config uses), so an extension adds a
page by dropping a markdown file + a manifest entry — no central registry.
One renderer, two outputs:
* `build-site!` writes a static, themed HTML bundle (for GitHub Pages).
* `handle` serves the same pages live (HTMX nav), mountable on the gateway
via its `:gateway.slot/http-routes` slot.
Markdown → HTML uses commonmark-java (already a dependency); the theme is an
enterprise-grade docs layout (sticky header, sidebar, on-this-page rail) in
the VIS palette (cobalt on white, Hanken Grotesk + JetBrains Mono).Doctor protocol: aggregates :ext/doctor-fn from every
registered extension into a single cross-cutting diagnostic
surface. vis doctor invokes run-checks then
format-output + exit-code.
Plan §1 Q19 + §10:
:check-id on each message when it
wants the formatter's per-section prefix.:ext/activation-fn. Doctor fns must
defensively handle missing env keys.Doctor protocol: aggregates `:ext/doctor-fn` from every
registered extension into a single cross-cutting diagnostic
surface. `vis doctor` invokes [[run-checks]] then
[[format-output]] + [[exit-code]].
Plan §1 Q19 + §10:
- One fn per extension. The fn returns a seq of message maps;
the extension self-stamps `:check-id` on each message when it
wants the formatter's per-section prefix.
- Output ordering: extensions in registration order; messages
in fn-return order. Levels NOT re-sorted within a section -
cause-and-effect narrative preserved.
- Activation contract: the fn runs for EVERY registered
extension regardless of `:ext/activation-fn`. Doctor fns must
defensively handle missing env keys.
- Exit codes: 0 if only :info or empty; 1 if any :warn (no
:error); 2 if any :error.
- TTY-detected ANSI colors. UTF-8 icons by default.Slim "session_env" digest. Internal, not extension-owned. STRING-KEYED —
crosses the Python boundary as session["env"], so keys AND enum values
(os/shell/kind/primary_language) are strings, never keywords.
Produces a bounded map the model reads each iter:
{"host" {"os" "shell" "clock"} ; cwd lives in session["workspace"]["root"] "project" {"kind" "primary_language"} "extensions" {"active_count" "aliases"}}
Each slice is small (~50 bytes), so the section costs <200 bytes/turn.
Extensions deep-merge their own slices via :ext/ctx-fn returning
{"session_env" {their-key {…}}}; the merge happens in
ctx-loop/render-block! so internal owns the base section, extensions
layer on top.
Heavy environment scans (full byte-counted language tables, polylith
brick listings, multi-repo git status) live in the foundation-core
focused project-shape helpers for explicit deep-dives. The digest never calls
into extensions — host facts come from System/getProperty,
project shape from a single directory peek. AGENTS.md / CLAUDE.md
contents ride in their own system block (internal.prompt), not here.
Slim `"session_env"` digest. Internal, not extension-owned. STRING-KEYED —
crosses the Python boundary as `session["env"]`, so keys AND enum values
(os/shell/kind/primary_language) are strings, never keywords.
Produces a bounded map the model reads each iter:
{"host" {"os" "shell" "clock"} ; cwd lives in session["workspace"]["root"]
"project" {"kind" "primary_language"}
"extensions" {"active_count" "aliases"}}
Each slice is small (~50 bytes), so the section costs <200 bytes/turn.
Extensions deep-merge their own slices via `:ext/ctx-fn` returning
`{"session_env" {their-key {…}}}`; the merge happens in
`ctx-loop/render-block!` so internal owns the base section, extensions
layer on top.
Heavy environment scans (full byte-counted language tables, polylith
brick listings, multi-repo git status) live in the foundation-core
focused project-shape helpers for explicit deep-dives. The digest never calls
into extensions — host facts come from `System/getProperty`,
project shape from a single directory peek. AGENTS.md / CLAUDE.md
contents ride in their own system block (`internal.prompt`), not here.Embedded-GraalPy sandbox machinery — the agent's action substrate. The agent
writes Python; this ns embeds a GraalPy org.graalvm.polyglot.Context,
marshals values across the Clojure↔Python boundary, wires the Clojure tool
fns into the Python globals as ProxyExecutables (so cat("x") in Python
runs the Clojure cat), and runs the model's code block as ONE whole-block
coroutine.
Public surface used by the loop:
create-python-context / set-python-binding! / bind-and-bump! / bind-and-bump-with-doc! / push-eval-result! / push-eval-error! / reset-eval-bindings! / count-top-level-forms / validate-non-empty-block! / validate-no-banned-defs! / restore-sandbox! / SYSTEM_VAR_NAMES / system-var-sym? / lru-atom / current-turn-position / fresh-lru-atom / run-python-block / map-polyglot-error / bind-ctx! / ctx->python-str
The :python-context slot holds the GraalPy Context; the Python top scope is
context.getBindings("python"). GraalPy ships in the default deps (runs on
Oracle GraalVM 25 → Truffle gets the Graal JIT).
Embedded-GraalPy sandbox machinery — the agent's action substrate. The agent
writes **Python**; this ns embeds a GraalPy `org.graalvm.polyglot.Context`,
marshals values across the Clojure↔Python boundary, wires the Clojure tool
fns into the Python globals as `ProxyExecutable`s (so `cat("x")` in Python
runs the Clojure `cat`), and runs the model's code block as ONE whole-block
coroutine.
Public surface used by the loop:
create-python-context / set-python-binding! / bind-and-bump! /
bind-and-bump-with-doc! / push-eval-result! / push-eval-error! /
reset-eval-bindings! / count-top-level-forms / validate-non-empty-block! /
validate-no-banned-defs! / restore-sandbox! / SYSTEM_VAR_NAMES /
system-var-sym? / *lru-atom* / *current-turn-position* / fresh-lru-atom /
run-python-block / map-polyglot-error / bind-ctx! / ctx->python-str
The `:python-context` slot holds the GraalPy `Context`; the Python top scope is
`context.getBindings("python")`. GraalPy ships in the default deps (runs on
Oracle GraalVM 25 → Truffle gets the Graal JIT).Error formatting - leaf module.
Pure functions for turning exceptions, anomaly maps, and ad-hoc error values into human-readable strings. Lives in its own namespace so any layer (SDK facade, channels, extensions, the iteration loop) can format an error without dragging in the rest of the SDK.
Public API:
error-message - raw text from a Throwable / map / string
format-error - prefix "ERROR: " idempotently
final-answer-code-error-message - the "Final-answer code error: ..." prefix
used by the iteration loop when an
(done ...) form's own code throws.
Error formatting - leaf module.
Pure functions for turning exceptions, anomaly maps, and ad-hoc
error values into human-readable strings. Lives in its own
namespace so any layer (SDK facade, channels, extensions, the
iteration loop) can format an error without dragging in the rest
of the SDK.
Public API:
`error-message` - raw text from a Throwable / map / string
`format-error` - prefix "ERROR: " idempotently
`final-answer-code-error-message` - the "Final-answer code error: ..." prefix
used by the iteration loop when an
`(done ...)` form's own code throws.Extension subsystem: spec, builders, hook execution, the global registry, parse-error rescue, and manifest namespace catalog.
An extension is the SINGLE entry point for everything a third-party
bundle contributes to vis. Whatever surfaces it populates - Python
sandbox symbols, CLI commands, channels, providers, persistence
backends - it does so by listing them in the matching :ext/<surface>
slot, and register-extension! dispatches each slot to its concrete
sub-registry. The same data feeds:
:ext.engine/symbols:ext/hooks checksChannel and provider registries live in internal.registry (the
sub-registry that lights up when this module dispatches their
contributions). Backend dispatch lives in internal.persistance.
Classpath manifest scanning lives in internal.manifest. This
module is the only consumer of all four.
Extension subsystem: spec, builders, hook execution, the global registry, parse-error rescue, and manifest namespace catalog. An extension is the SINGLE entry point for everything a third-party bundle contributes to vis. Whatever surfaces it populates - Python sandbox symbols, CLI commands, channels, providers, persistence backends - it does so by listing them in the matching `:ext/<surface>` slot, and `register-extension!` dispatches each slot to its concrete sub-registry. The same data feeds: - the active-extensions list every iteration consults - the system-prompt block rendered from `:ext.engine/symbols` - the per-iteration `:ext/hooks` checks - the parse-error rescue chain - the manifest id/namespace catalog used for extension metadata Channel and provider registries live in `internal.registry` (the sub-registry that lights up when this module dispatches their contributions). Backend dispatch lives in `internal.persistance`. Classpath manifest scanning lives in `internal.manifest`. This module is the only consumer of all four.
Extension-owned durable sidecar API.
Public ext-* helpers are for code running inside an extension callback. They never accept :extension-id from callers; the currently executing extension identity is supplied by com.blockether.vis.internal.extension.
The db-* persistence facade remains the privileged/admin surface for inspecting rows across extensions.
Extension-owned durable sidecar API. Public ext-* helpers are for code running inside an extension callback. They never accept :extension-id from callers; the currently executing extension identity is supplied by com.blockether.vis.internal.extension. The db-* persistence facade remains the privileged/admin surface for inspecting rows across extensions.
Shell out to the host OS opener so Vis can hand a URL or local file path off to the user's preferred external browser/viewer.
Responsibilities, in order:
Classify the candidate target into a whitelisted scheme keyword:
:http, :https, :file, :rel, or :rejected.
Resolve the target to a host-friendly form. Relative paths are
anchored at the current working directory and re-checked for
.. traversal. Returns nil when the path escapes.
Build the OS-appropriate command vector
(open / xdg-open / cmd /c start) for ProcessBuilder.
Spawn it with stdio redirected to /dev/null so a chatty opener cannot corrupt terminal output.
Pure-ish: every step except open! itself is a function of its
args plus os.name and the current working directory. open!
shells out and never throws; errors land in the returned result map.
Shell out to the host OS opener so Vis can hand a URL or local file
path off to the user's preferred external browser/viewer.
Responsibilities, in order:
1. Classify the candidate target into a whitelisted scheme keyword:
`:http`, `:https`, `:file`, `:rel`, or `:rejected`.
2. Resolve the target to a host-friendly form. Relative paths are
anchored at the current working directory and re-checked for
`..` traversal. Returns nil when the path escapes.
3. Build the OS-appropriate command vector
(`open` / `xdg-open` / `cmd /c start`) for `ProcessBuilder`.
4. Spawn it with stdio redirected to /dev/null so a chatty opener
cannot corrupt terminal output.
Pure-ish: every step except `open!` itself is a function of its
args plus `os.name` and the current working directory. `open!`
shells out and never throws; errors land in the returned result map.Backend for file-picking UIs.
This namespace owns the NON-UI parts of the @ picker:
git binaryChannels render and keybind on top of this data; they should not reimplement repository walking or git-status logic themselves.
Backend for file-picking UIs. This namespace owns the NON-UI parts of the `@` picker: - repository discovery - git dirty / ignored metadata via the native `git` binary - filesystem indexing via java.nio.file - filtering, ranking, sorting - compact display labels for size / age / git status Channels render and keybind on top of this data; they should not reimplement repository walking or git-status logic themselves.
The canonical per-form DISPLAY contract — ONE source of truth for the fields a channel reads to render an executed form, live (via the gateway) and restored (via the DB).
Why this exists: the SAME field set used to be hand-listed in ~7 independent
allowlists — the loop chunk, the persisted block, the gateway block.output
wire payload, the gateway→chunk client projection, the live progress form, the
DB-restored form, and the restored display form. A new field was invisible
until every one was edited, and whichever layer forgot it silently dropped the
field (the gateway dropping :tool-color-role so the live badge vanished was
exactly that). Now every layer projects the WHOLE set through ->display
(outbound) / <-wire (inbound, tolerant of the wire's snake_case + stringified
keyword values), so a new display field is a ONE-line change to display-keys
and form-roundtrip-test fails if a boundary stops carrying it.
Transformed fields (:stdout/:error bounded, :silent/:duration_ms
renamed) stay as explicit gateway overrides — they are not carried verbatim, so
they are NOT in this set.
The canonical per-form DISPLAY contract — ONE source of truth for the fields a channel reads to render an executed form, live (via the gateway) and restored (via the DB). Why this exists: the SAME field set used to be hand-listed in ~7 independent allowlists — the loop chunk, the persisted block, the gateway `block.output` wire payload, the gateway→chunk client projection, the live progress form, the DB-restored form, and the restored display form. A new field was invisible until every one was edited, and whichever layer forgot it silently dropped the field (the gateway dropping `:tool-color-role` so the live badge vanished was exactly that). Now every layer projects the WHOLE set through `->display` (outbound) / `<-wire` (inbound, tolerant of the wire's snake_case + stringified keyword values), so a new display field is a ONE-line change to `display-keys` and `form-roundtrip-test` fails if a boundary stops carrying it. Transformed fields (`:stdout`/`:error` bounded, `:silent`/`:duration_ms` renamed) stay as explicit gateway overrides — they are not carried verbatim, so they are NOT in this set.
Format helpers - leaf module.
Small, dependency-light formatters used by the SDK facade, the TUI footer, the CLI status output, and the Telegram bot. Each one is a pure transform over basic Clojure / Java values.
format-date - java.util.Date to dd-MM-yyyy HH:mm (local TZ)
format-clojure - pass-through (code is shown as written, not reformatted)
format-duration - millisecond duration to 2.3s, 1m 15s, etc.
format-tokens - :input/:output token counts to 'tok 11461→35'
format-cost - dollar cost to '~$0.006954'
format-iterations- iteration count to '1 iter' / '3 iters'
format-meta-line - canonical ' / '-joined turn-summary line
(used identically by CLI / TUI / Telegram)
No external pretty-printer: data renders through clojure.pprint
(built-in) and source code is shown verbatim. The namespace is free of
state - safe to require from any layer.
Format helpers - leaf module.
Small, dependency-light formatters used by the SDK facade, the
TUI footer, the CLI status output, and the Telegram bot. Each one
is a pure transform over basic Clojure / Java values.
`format-date` - `java.util.Date` to `dd-MM-yyyy HH:mm` (local TZ)
`format-clojure` - pass-through (code is shown as written, not reformatted)
`format-duration` - millisecond duration to `2.3s`, `1m 15s`, etc.
`format-tokens` - `:input`/`:output` token counts to 'tok 11461→35'
`format-cost` - dollar cost to '~$0.006954'
`format-iterations`- iteration count to '1 iter' / '3 iters'
`format-meta-line` - canonical ' / '-joined turn-summary line
(used identically by CLI / TUI / Telegram)
No external pretty-printer: data renders through `clojure.pprint`
(built-in) and source code is shown verbatim. The namespace is free of
state - safe to require from any layer.Foundation's contribution to the host vis doctor aggregator. ONE fn
(doctor-fn) returns the full message stream from two logical
sections, each stamping its own :check-id so the formatter
groups them under the same banner the original four-checks-vec
shape produced (plan §1 Q18 / §10):
::agents-md AGENTS.md presence / source / size; one :info line when found, one :warn line when neither AGENTS.md nor CLAUDE.md exists (rules silently absent is worth flagging even though it isn't an error per se).
These section fns are pure data -> message-seq; they don't mutate
anything and don't depend on the runtime environment beyond
what's needed to read the existing scanners. Activation
contract per plan: every registered extension's :ext/doctor-fn
runs regardless of :ext/activation-fn, so the section fns must
NOT assume :db-info or other env keys are present.
Foundation's contribution to the host `vis doctor` aggregator. ONE fn
(`doctor-fn`) returns the full message stream from two logical
sections, each stamping its own `:check-id` so the formatter
groups them under the same banner the original four-checks-vec
shape produced (plan §1 Q18 / §10):
::agents-md AGENTS.md presence / source / size; one
:info line when found, one :warn line
when neither AGENTS.md nor CLAUDE.md exists
(rules silently absent is worth flagging
even though it isn't an error per se).
These section fns are pure data -> message-seq; they don't mutate
anything and don't depend on the runtime environment beyond
what's needed to read the existing scanners. Activation
contract per plan: every registered extension's `:ext/doctor-fn`
runs regardless of `:ext/activation-fn`, so the section fns must
NOT assume `:db-info` or other env keys are present.Filesystem tools exposed as bare symbols in the Python sandbox.
Two layers:
Structured helpers for read / tree / search:
(cat path) ; -> {:path :anchors {<N:hash> text…} :next-offset N? :truncated? B}
(cat path n) ; first n lines from line 1
(cat path offset n) ; n lines starting at line offset (1-based)
(cat path :tail) ; last 400 lines (tail)
(cat path :tail n) ; last n lines
(ls path) ; -> nested dict tree (name/path/type/size/children)
(ls path opts) ; opts keys: depth / is_hidden / is_respect_gitignore
(rg query) ; -> content hits; query = a term or list of terms (OR),
; smart-case substring. Opts: paths/include/context/is_files_only
Cwd-safe wrappers over the babashka.fs file API. patch is
the canonical text edit surface:
(cat path) (patch [edit-map]) ; keys: path / from_anchor / replace (create-dirs path) (copy src dest) (move src dest) (delete path) (delete-if-exists path) (exists? path)
Hard guard: every path must stay inside the session's working
directory (fs/cwd); .. traversal is rejected before any I/O.
Filesystem tools exposed as bare symbols in the Python sandbox.
Two layers:
1. Structured helpers for read / tree / search:
(cat path) ; -> {:path :anchors {<N:hash> text…} :next-offset N? :truncated? B}
(cat path n) ; first n lines from line 1
(cat path offset n) ; n lines starting at line `offset` (1-based)
(cat path :tail) ; last 400 lines (tail)
(cat path :tail n) ; last n lines
(ls path) ; -> nested dict tree (name/path/type/size/children)
(ls path opts) ; opts keys: depth / is_hidden / is_respect_gitignore
(rg query) ; -> content hits; query = a term or list of terms (OR),
; smart-case substring. Opts: paths/include/context/is_files_only
2. Cwd-safe wrappers over the babashka.fs file API. `patch` is
the canonical text edit surface:
(cat path)
(patch [edit-map]) ; keys: path / from_anchor / replace
(create-dirs path)
(copy src dest)
(move src dest)
(delete path)
(delete-if-exists path)
(exists? path)
Hard guard: every path must stay inside the session's working
directory (`fs/cwd`); `..` traversal is rejected before any I/O.Structural outline: a high-level, line-ranged skeleton of a source file produced via tree-sitter (com.blockether/tree-sitter-language-pack, which sources Clojure from our own grammar fork).
Every item carries FULL anchors — the same <lineno>:<hash> anchors cat
emits and patch consumes — for its first and last line, so you can replace
a whole definition straight from the index with one
patch([{from_anchor start, to_anchor end, replace …}]), no intermediate
cat. Each line is:
<kind> <name> <signature> @<start-anchor>..<end-anchor>
e.g.
class Greeter @3:a1b..7:c2d function hello @6:1b2..7:c2d function main @9:3c4..10:5d6
Read it BEFORE cat: a cheap map of a file so you jump straight to the right
range (and its anchors) instead of reading the whole file.
Requiring this namespace also requires the native resolver, which selects the right per-platform FFI library at runtime.
Structural outline: a high-level, line-ranged skeleton of a source file
produced via tree-sitter (com.blockether/tree-sitter-language-pack, which
sources Clojure from our own grammar fork).
Every item carries FULL anchors — the same `<lineno>:<hash>` anchors `cat`
emits and `patch` consumes — for its first and last line, so you can replace
a whole definition straight from the index with one
`patch([{from_anchor start, to_anchor end, replace …}])`, no intermediate
`cat`. Each line is:
<kind> <name> <signature> @<start-anchor>..<end-anchor>
e.g.
class Greeter @3:a1b..7:c2d
function hello @6:1b2..7:c2d
function main @9:3c4..10:5d6
Read it BEFORE `cat`: a cheap map of a file so you jump straight to the right
range (and its anchors) instead of reading the whole file.
Requiring this namespace also requires the native resolver, which selects the
right per-platform FFI library at runtime.Fuzzy line-based matching toolkit used by patch exact-replace.
This namespace USED to also carry the Codex apply_patch envelope
parser, but envelope mode was retired — we consolidated to a single
per-intent mutation surface (patch exact-replace + write /
move / delete). The fuzzy matcher stays because it is what
lets multi-line :search blocks tolerate whitespace and typographic
drift before they fall through to :no-match.
Public surface (all pure): seek-sequence lines pattern start eof? -> start-index or nil seek-sequence-with-pass lines pattern start eof? -> {:start :pass :indent-delta?} or nil split-content-lines string -> vec of lines (no trailing \n element) char-offset-at-line content line-idx -> char offset apply-indent-delta delta lines -> re-indented lines
It also owns the reusable HASHLINE layer (content-addressed editing):
line-hash / lines->anchors text -> 6-hex anchor / {ln hash}
render-hashline-block / -range-block tuples -> <hash>| text gutter
indices-matching-hash / resolve-anchor-edit self-locating range replace
Fuzzy line-based matching toolkit used by `patch` exact-replace.
This namespace USED to also carry the Codex `apply_patch` envelope
parser, but envelope mode was retired — we consolidated to a single
per-intent mutation surface (`patch` exact-replace + `write` /
`move` / `delete`). The fuzzy matcher stays because it is what
lets multi-line `:search` blocks tolerate whitespace and typographic
drift before they fall through to `:no-match`.
Public surface (all pure):
seek-sequence lines pattern start eof? -> start-index or nil
seek-sequence-with-pass lines pattern start eof?
-> {:start :pass :indent-delta?} or nil
split-content-lines string -> vec of lines (no trailing \n element)
char-offset-at-line content line-idx -> char offset
apply-indent-delta delta lines -> re-indented lines
It also owns the reusable HASHLINE layer (content-addressed editing):
line-hash / lines->anchors text -> 6-hex anchor / {ln hash}
render-hashline-block / -range-block tuples -> `<hash>| text` gutter
indices-matching-hash / resolve-anchor-edit self-locating range replaceThin Clojure adapter over the pack's Java structural-edit engine
(dev.kreuzberg.treesitterlanguagepack.StructuralApi). All the work —
locate the definition by name from the tree-sitter outline, splice its line
span, and re-parse to refuse syntax-breaking edits — lives in Java so it is
language-neutral, reusable from any JVM consumer, and native-image clean.
This namespace only maps vis op keywords onto the Java API.
Thin Clojure adapter over the pack's Java structural-edit engine (`dev.kreuzberg.treesitterlanguagepack.StructuralApi`). All the work — locate the definition by name from the tree-sitter outline, splice its line span, and re-parse to refuse syntax-breaking edits — lives in Java so it is language-neutral, reusable from any JVM consumer, and native-image clean. This namespace only maps vis op keywords onto the Java API.
Language-neutral STRUCTURAL ZIPPER over the tree-sitter pack (306+ langs) —
the unified cursor the name-based structural ops were missing.
A node's location is a STATELESS PATH: a vector of NAMED-child indices from
the root (e.g. [2 0] = first named child of the third named child of the
file). Stateless means it round-trips cleanly through async tool calls — no
live native cursor to keep between calls. Relative moves (down/up/next/prev)
are pure path arithmetic on top, so the model navigates like a rewrite-clj
zipper but over EVERY language tree-sitter understands.
Edits splice the target node's UTF-8 byte range and RE-PARSE, refusing a
result that introduces a syntax error the original didn't have — the same
safety contract as structural. Pairs with the name-based ops: locate a def
by name, then walk into it by path.
All native handles (Parser/Tree/Node) are opened and closed inside each call; only plain Clojure data escapes.
Language-neutral STRUCTURAL ZIPPER over the tree-sitter pack (306+ langs) — the unified cursor the name-based `structural` ops were missing. A node's location is a STATELESS PATH: a vector of NAMED-child indices from the root (e.g. `[2 0]` = first named child of the third named child of the file). Stateless means it round-trips cleanly through async tool calls — no live native cursor to keep between calls. Relative moves (down/up/next/prev) are pure path arithmetic on top, so the model navigates like a rewrite-clj zipper but over EVERY language tree-sitter understands. Edits splice the target node's UTF-8 byte range and RE-PARSE, refusing a result that introduces a syntax error the original didn't have — the same safety contract as `structural`. Pairs with the name-based ops: locate a def by name, then walk into it by path. All native handles (Parser/Tree/Node) are opened and closed inside each call; only plain Clojure data escapes.
Thin re-export shim — project-guidance discovery moved to
com.blockether.vis.internal.agents. AGENTS.md / CLAUDE.md
handling is core functionality (drives system prompt + ctx
digest), so it now lives outside the foundation extension.
This shim keeps the legacy main-agent-instructions tool +
any downstream (:project ctx) :guidance consumers compiling
while they migrate to the internal namespace.
Thin re-export shim — project-guidance discovery moved to `com.blockether.vis.internal.agents`. AGENTS.md / CLAUDE.md handling is core functionality (drives system prompt + ctx digest), so it now lives outside the foundation extension. This shim keeps the legacy `main-agent-instructions` tool + any downstream `(:project ctx) :guidance` consumers compiling while they migrate to the internal namespace.
vis-foundation — the agent's environment-awareness layer.
Owns the environment facts: cwd, user, platform, shell, plus:
Model-facing VCS/workspace truth lives in :session/workspace CTX.
Remaining helpers cover coarse project shape (languages,
monorepo, repositories) and cache invalidation (refresh!).
Runtime facts are computed lazily on first access and cached per
working-directory. The cache is invalidated automatically when
cwd changes between calls and explicitly by (refresh!).
vis-foundation — the agent's environment-awareness layer.
Owns the environment facts: cwd, user, platform, shell, plus:
* git repository facts via the git binary (root, branch, dirty status,
submodules, worktree),
* a bounded language scan over the working tree (top languages
by total bytes, primary language),
* monorepo / multi-package shape detection (polylith, workspace,
submodules) by counting per-ecosystem manifests.
Model-facing VCS/workspace truth lives in `:session/workspace` CTX.
Remaining helpers cover coarse project shape (`languages`,
`monorepo`, `repositories`) and cache invalidation (`refresh!`).
Runtime facts are computed lazily on first access and cached per
working-directory. The cache is invalidated automatically when
`cwd` changes between calls and explicitly by `(refresh!)`.Git introspection for the environment block, backed by the native git
binary (via internal.git).
Returns a snapshot map for the repository that contains start
(typically the JVM working directory). nil when start is not inside any
git repository. Never throws — every git call is guarded; on any failure we
degrade gracefully to nil or a reduced-shape map.
The expensive call is git status (working-tree walk). When it fails or is
suppressed, the snapshot drops the dirty-status fields instead of stalling
the system-prompt build.
Git introspection for the environment block, backed by the native `git` binary (via `internal.git`). Returns a snapshot map for the repository that contains `start` (typically the JVM working directory). nil when `start` is not inside any git repository. Never throws — every git call is guarded; on any failure we degrade gracefully to nil or a reduced-shape map. The expensive call is `git status` (working-tree walk). When it fails or is suppressed, the snapshot drops the dirty-status fields instead of stalling the system-prompt build.
Host-side facts read from JDK system properties and process environment variables.
No I/O, no shell-out, no third-party deps. Cheap to compute, safe to call from any thread, never throws.
Host-side facts read from JDK system properties and process environment variables. No I/O, no shell-out, no third-party deps. Cheap to compute, safe to call from any thread, never throws.
Bounded language scan over a directory tree.
Walks the tree with Files/walkFileTree, skipping common
non-source subdirectories (.git, node_modules, target, ...)
via FileVisitResult/SKIP_SUBTREE. Counts files and bytes per
language using a small extension-to-language map.
The walk has TWO hard guards: a max-file count and a wall-time
deadline. Either one stops the walk via TERMINATE. Callers get
a possibly-partial result; on a small repo the result is exact.
No third-party deps. Reflection-clean.
Bounded language scan over a directory tree. Walks the tree with `Files/walkFileTree`, skipping common non-source subdirectories (`.git`, `node_modules`, `target`, ...) via `FileVisitResult/SKIP_SUBTREE`. Counts files and bytes per language using a small extension-to-language map. The walk has TWO hard guards: a max-file count and a wall-time deadline. Either one stops the walk via `TERMINATE`. Callers get a possibly-partial result; on a small repo the result is exact. No third-party deps. Reflection-clean.
Monorepo / multi-package detection.
Walks the tree once (bounded), counting per-language manifest files at any depth below the root. >=2 manifests of the same kind in distinct subdirectories signals a multi-package workspace; we report the kind, the count, and a best-guess shape label (e.g. "polylith", "workspace", "submodules").
Reflection-clean. Honors the same skip-directory list as the language scanner.
Monorepo / multi-package detection. Walks the tree once (bounded), counting per-language manifest files at any depth below the root. >=2 manifests of the same kind in distinct subdirectories signals a multi-package workspace; we report the kind, the count, and a best-guess shape label (e.g. "polylith", "workspace", "submodules"). Reflection-clean. Honors the same skip-directory list as the language scanner.
Build compact foundation environment data for ctx. No prompt labels.
Build compact foundation environment data for `ctx`. No prompt labels.
Bounded discovery of multiple Git repositories below the current
project root. This catches multirepo workspaces where the user's cwd
is a parent directory or a primary repo that vendors sibling/nested
repos outside .gitmodules.
Returns compact per-repo Git summaries for the system prompt. Full
status walks are bounded per repo by git/snapshot; the repository
scan itself is bounded by max files, max repos, and a wall-clock
deadline. Never throws.
Bounded discovery of multiple Git repositories below the current project root. This catches multirepo workspaces where the user's cwd is a parent directory or a primary repo that vendors sibling/nested repos outside `.gitmodules`. Returns compact per-repo Git summaries for the system prompt. Full status walks are bounded per repo by `git/snapshot`; the repository scan itself is bounded by max files, max repos, and a wall-clock deadline. Never throws.
The single git tool — a thin, honest proxy to the host git binary.
ONE built-in Python function, git, runs git <args…> in the active
workspace root and returns a lean, string-keyed result the model reads
directly: {"cmd", "args", "stdout", "duration_ms"} plus "exit"
(when the process finished), "stderr" (when non-empty), and
"timed_out" (when it blew the timeout). A non-zero exit is DATA, not a
tool failure — the model reads it like it would in a terminal.
This REPLACES the old JGit-backed git_* surface (foundation-git): no
embedded git implementation, no SSH/BouncyCastle stack — the only git is
the one already on the user's PATH, so behaviour matches their shell
exactly. Read-only workspace facts (branch/dirty/ahead-behind for the
footer, env block, file picker) still flow through
com.blockether.vis.internal.git; this namespace is purely the model-
facing command tool.
Built-in (bare git in the sandbox, next to cat/rg), gated to
activate only when the workspace sits inside a repository.
The single `git` tool — a thin, honest proxy to the host `git` binary.
ONE built-in Python function, `git`, runs `git <args…>` in the active
workspace root and returns a lean, string-keyed result the model reads
directly: `{"cmd", "args", "stdout", "duration_ms"}` plus `"exit"`
(when the process finished), `"stderr"` (when non-empty), and
`"timed_out"` (when it blew the timeout). A non-zero exit is DATA, not a
tool failure — the model reads it like it would in a terminal.
This REPLACES the old JGit-backed `git_*` surface (foundation-git): no
embedded git implementation, no SSH/BouncyCastle stack — the only git is
the one already on the user's PATH, so behaviour matches their shell
exactly. Read-only workspace facts (branch/dirty/ahead-behind for the
footer, env block, file picker) still flow through
`com.blockether.vis.internal.git`; this namespace is purely the model-
facing command tool.
Built-in (bare `git` in the sandbox, next to `cat`/`rg`), gated to
activate only when the workspace sits inside a repository.Programmatic introspection of the agent's own state from inside
:code. The public state surface is deliberately small:
(session-state [session-id]) -> data map, including raw LLM diagnostics(session-report-md [session-id]) -> Markdown rendered from that dataEverything else in this namespace is implementation detail. The agent
gets the data once, manipulates it via plain Clojure (get-in,
filter, map, etc.), and renders the same data to Markdown only
when presentation is needed.
Every function is a pure read off the same DB tables the projection layer reads from (or a classpath read for the doc accessors). Failures return nil/[], never throw, so a misbehaving introspection call cannot break iteration execution.
Opt-in: not auto-loaded by default. Add this jar to the classpath to enable.
Programmatic introspection of the agent's own state from inside `:code`. The public state surface is deliberately small: - `(session-state [session-id])` -> data map, including raw LLM diagnostics - `(session-report-md [session-id])` -> Markdown rendered from that data Everything else in this namespace is implementation detail. The agent gets the data once, manipulates it via plain Clojure (`get-in`, `filter`, `map`, etc.), and renders the same data to Markdown only when presentation is needed. Every function is a pure read off the same DB tables the projection layer reads from (or a classpath read for the doc accessors). Failures return nil/[], never throw, so a misbehaving introspection call cannot break iteration execution. Opt-in: not auto-loaded by default. Add this jar to the classpath to enable.
Language-neutral FORMAT / TEST / REPL_EVAL / START_REPL dispatch.
Language extensions register handlers under :ext/language-tools; this
foundation surface exposes stable bare tool names and dispatches to the
active handler for the requested/current language. REPL lifecycle is resource
backed: repl_start creates a language-owned session resource and repl_stop
stops one by id. Live REPLs also surface in the ctx resources block.
Language-neutral FORMAT / TEST / REPL_EVAL / START_REPL dispatch. Language extensions register handlers under `:ext/language-tools`; this foundation surface exposes stable bare tool names and dispatches to the active handler for the requested/current language. REPL lifecycle is resource backed: `repl_start` creates a language-owned session resource and `repl_stop` stops one by id. Live REPLs also surface in the ctx `resources` block.
SINK for artifacts the sandbox PRODUCES — matplotlib figures (plt.show() /
plt.savefig()), anything a tool hands to vis_attach, AND anything written
into the per-context OUTBOX directory ($VIS_OUTBOX) — so the engine OWNS the
bytes (a session_iteration_attachment row) captured AT THE SOURCE, with NO
re-parsing of the model-facing stdout fence.
The old flow rendered each figure to a $TMPDIR/vis-mpl temp file, printed a
vis-image fence carrying just that PATH, then at persist time re-parsed the
fence out of stdout and re-read the (possibly already-gone) file. We control the
whole boundary, so that round-trip is gone: a producer renders/reads the bytes
HOST-side (matplotlib's __vis_mpl_render_file__ Java2D backend; vis_attach's
sandbox-confined open; the outbox filesystem tap in sandbox-fs) and, right
where it already holds the bytes, calls record-attachment! (or record-file!).
run-python-block binds *attachment-sink* to a fresh collector around each
block's eval and drains it into the block outcome's :attachments; the loop
stamps each with the block's tool-call-id and hands them to db-store-iteration!'s
:attachments. The stdout fence now serves ONLY the inline TUI/web display +
ASCII fallback — never persistence.
Deliberately dependency-free (no AWT, no vis.core): safe to require from BOTH
a render shim and the hot engine loop without dragging Java2D or a require
cycle.
SINK for artifacts the sandbox PRODUCES — matplotlib figures (`plt.show()` / `plt.savefig()`), anything a tool hands to `vis_attach`, AND anything written into the per-context OUTBOX directory (`$VIS_OUTBOX`) — so the engine OWNS the bytes (a `session_iteration_attachment` row) captured AT THE SOURCE, with NO re-parsing of the model-facing stdout fence. The old flow rendered each figure to a `$TMPDIR/vis-mpl` temp file, printed a `vis-image` fence carrying just that PATH, then at persist time re-parsed the fence out of stdout and re-read the (possibly already-gone) file. We control the whole boundary, so that round-trip is gone: a producer renders/reads the bytes HOST-side (matplotlib's `__vis_mpl_render_file__` Java2D backend; `vis_attach`'s sandbox-confined `open`; the outbox filesystem tap in `sandbox-fs`) and, right where it already holds the bytes, calls `record-attachment!` (or `record-file!`). `run-python-block` binds `*attachment-sink*` to a fresh collector around each block's eval and drains it into the block outcome's `:attachments`; the loop stamps each with the block's tool-call-id and hands them to `db-store-iteration!`'s `:attachments`. The stdout fence now serves ONLY the inline TUI/web display + ASCII fallback — never persistence. Deliberately dependency-free (no AWT, no `vis.core`): safe to require from BOTH a render shim and the hot engine loop without dragging Java2D or a require cycle.
Pure-Java pseudo-terminal for shell_bg — NO JNA, NO extracted native helper,
NO external tmux. Everything is a java.lang.foreign (Panama FFM) downcall
into the platform libc, so it survives GraalVM native-image the same way the
rest of vis's FFM surface (fff / rift / ruff / tree-sitter) does.
Why FFM and not pty4j:
libpty that it extracts to
a temp dir at runtime — two native-image headaches (reflection metadata for
JNA + a resource-extraction dance for the .dylib/.so).forkpty then execve in the
child — does NOT work from a JVM: invoking an FFM downcall MethodHandle after
fork() is not async-signal-safe and SIGBUSes the child. (Verified.)posix_spawn sidesteps that entirely: libc does the fork+exec ATOMICALLY in
native code, so vis only ever issues ONE parent-side downcall and never runs
any JVM code in the child. Paired with openpty (master/slave fds) + a
dup2 of the slave onto the child's 0/1/2, the child gets a real TTY:
isatty() is true, $TERM is honoured, stdin is writable.Public surface — spawn! returns a PLAIN MAP (not a java.lang.Process; a
runtime proxy/gen-class would break native-image), shaped for the
internal.foundation.shell background pump:
{:pid <long> OS pid (a genuine child — ProcessHandle/of
works, unlike a pty4j process)
:in <java.io.InputStream> master-fd reader (a real piped stream)
:send (fn [^bytes b]) write bytes to the master (the stdin channel)
:wait (fn [] <int>) block until exit, reap, return the exit code
:alive? (fn [] <bool>)
:destroy (fn [force?]) SIGTERM (false) / SIGKILL (true) the child}
Pure-Java pseudo-terminal for `shell_bg` — NO JNA, NO extracted native helper,
NO external `tmux`. Everything is a `java.lang.foreign` (Panama FFM) downcall
into the platform libc, so it survives GraalVM native-image the same way the
rest of vis's FFM surface (fff / rift / ruff / tree-sitter) does.
Why FFM and not pty4j:
- pty4j drags in JNA *and* ships its own compiled `libpty` that it extracts to
a temp dir at runtime — two native-image headaches (reflection metadata for
JNA + a resource-extraction dance for the .dylib/.so).
- The obvious pure-FFM shortcut — call libc `forkpty` then `execve` in the
child — does NOT work from a JVM: invoking an FFM downcall MethodHandle after
`fork()` is not async-signal-safe and SIGBUSes the child. (Verified.)
- `posix_spawn` sidesteps that entirely: libc does the fork+exec ATOMICALLY in
native code, so vis only ever issues ONE parent-side downcall and never runs
any JVM code in the child. Paired with `openpty` (master/slave fds) + a
`dup2` of the slave onto the child's 0/1/2, the child gets a real TTY:
`isatty()` is true, `$TERM` is honoured, stdin is writable.
Public surface — `spawn!` returns a PLAIN MAP (not a `java.lang.Process`; a
runtime `proxy`/`gen-class` would break native-image), shaped for the
`internal.foundation.shell` background pump:
{:pid <long> OS pid (a genuine child — `ProcessHandle/of`
works, unlike a pty4j process)
:in <java.io.InputStream> master-fd reader (a real piped stream)
:send (fn [^bytes b]) write bytes to the master (the stdin channel)
:wait (fn [] <int>) block until exit, reap, return the exit code
:alive? (fn [] <bool>)
:destroy (fn [force?]) SIGTERM (false) / SIGKILL (true) the child}Passthrough bridge on top of the FFM pseudo-terminal (internal.foundation.pty).
The problem it solves: a shell_bg child runs INSIDE the vis process (the FFM
PTY master fd + reader thread live in vis's heap). That's great for the agent
(shell_send / shell_logs) but a HUMAN can't jump into the live terminal to
finish a step the agent can't — click through a browser OAuth, answer a prompt
only a person can. tmux gets that for free because its server is a separate
daemon you can tmux attach to; the FFM child is not.
This namespace restores that capability WITHOUT tmux: each background PTY
optionally exposes a per-shell UNIX-DOMAIN SOCKET. vis is the server (it holds
the master fd); vis ext shell attach <id> is a thin client the human runs in
their OWN Terminal.app. On connect the server (a) tees live master output to the
socket and (b) forwards the socket's bytes to the master (stdin) — a genuine
bidirectional passthrough. Multiple humans can attach at once; detaching just
drops the socket and leaves the child running (exactly like tmux detach).
Everything here is stdlib: java.nio.channels AF_UNIX sockets (JDK 16+,
already in vis's native-image reachability metadata) on the server side, and
stty for raw mode on the client side (the human's interactive shell always
has it). No JNA, no new dep, native-image clean.
Passthrough bridge on top of the FFM pseudo-terminal (internal.foundation.pty). The problem it solves: a `shell_bg` child runs INSIDE the vis process (the FFM PTY master fd + reader thread live in vis's heap). That's great for the agent (`shell_send` / `shell_logs`) but a HUMAN can't jump into the live terminal to finish a step the agent can't — click through a browser OAuth, answer a prompt only a person can. tmux gets that for free because its server is a separate daemon you can `tmux attach` to; the FFM child is not. This namespace restores that capability WITHOUT tmux: each background PTY optionally exposes a per-shell UNIX-DOMAIN SOCKET. vis is the server (it holds the master fd); `vis ext shell attach <id>` is a thin client the human runs in their OWN Terminal.app. On connect the server (a) tees live master output to the socket and (b) forwards the socket's bytes to the master (stdin) — a genuine bidirectional passthrough. Multiple humans can attach at once; detaching just drops the socket and leaves the child running (exactly like `tmux detach`). Everything here is stdlib: `java.nio.channels` AF_UNIX sockets (JDK 16+, already in vis's native-image reachability metadata) on the server side, and `stty` for raw mode on the client side (the human's interactive shell always has it). No JNA, no new dep, native-image clean.
Vis self-documentation lookup — the vis_docs sandbox tool.
Vis ships its documentation as embedded markdown pages under
vis-docs/ on the classpath (the same corpus the website and the
gateway /docs site render, discovered via each artifact's
vis-docs/vis-docs.edn manifest — so extensions' doc pages are
lookup-able too). This namespace exposes that corpus to the MODEL
through one observation tool so vis can answer questions about
ITSELF — features, configuration, how to write an extension — from
its real docs instead of guessing.
Progressive disclosure: a short always-on prompt fragment says the docs exist and when to reach for them; page content is only paid for when actually fetched.
Vis self-documentation lookup — the `vis_docs` sandbox tool. Vis ships its documentation as embedded markdown pages under `vis-docs/` on the classpath (the same corpus the website and the gateway `/docs` site render, discovered via each artifact's `vis-docs/vis-docs.edn` manifest — so extensions' doc pages are lookup-able too). This namespace exposes that corpus to the MODEL through one observation tool so vis can answer questions about ITSELF — features, configuration, how to write an extension — from its real docs instead of guessing. Progressive disclosure: a short always-on prompt fragment says the docs exist and when to reach for them; page content is only paid for when actually fetched.
Declarative session-level slash commands shared by every channel.
These are channel-agnostic: the engine dispatches them for the TUI, the
web, and Telegram alike through the same slash/dispatch path, and each
handler mutates state via the gateway so the change fans out everywhere.
/rename <new title> set this session's title
/rename routes through titling/set-title-with-broadcast! — the single
title mutation point.
Declarative session-level slash commands shared by every channel. These are channel-agnostic: the engine dispatches them for the TUI, the web, and Telegram alike through the same `slash/dispatch` path, and each handler mutates state via the gateway so the change fans out everywhere. /rename <new title> set this session's title `/rename` routes through `titling/set-title-with-broadcast!` — the single title mutation point.
shell/ compatibility extension — a DROPPABLE classpath plug-in (drop the
jar, drop the feature), gated behind the user-owned :shell/enabled toggle
(OFF by default; every call short-circuits into a refusal envelope until the
user flips it in settings).
Three model-facing bindings under alias shell (the flat Python sandbox
renders alias/name as <alias>_<name>, so the calls are
shell_run / shell_bg / shell_logs — same shape as git_status,
clj_eval, search_web):
SYNC shell_run(cmd) / shell_run(cmd, opts) — bash -lc in the
workspace root, waits up to a timeout, and returns a LEAN payload with
string keys cmd/stdout/duration_ms + conditional keys (exit when finished;
timed_out/timeout_secs on timeout; stderr when non-empty; truncation
flags when true; cwd when narrowed) — results ride every later prompt
as frozen <results> pins, so dead fields never ship. Output is bounded
at read time to a head+tail budget per stream — only the MIDDLE of a huge
stream is dropped, never its start or end (memory can't balloon on a
chatty-then-killed command). A non-zero exit is DATA the
model reads, not a tool error.
BACKGROUND shell_bg(id, cmd) — spawns the process, pumps its merged
output into a bounded ring buffer, and registers it as a session
RESOURCE in internal.resources: it shows up in the footer count, the
F4 dialog, and the resources ctx block, and the ONE stop path
is resource_stop(id) (model) / the footer dialog (user) — both land
on resources/stop!, which runs our :stop-fn (process-tree kill +
buffer drop). An exited process is NOT auto-pruned (its :alive-fn
reports true while the buffer entry exists) so shell_logs(id) can
still read its output + exit code until the resource is stopped.
shell_logs(id) / shell_logs(id, n) — tail of a background shell's
captured output as [seq, line] tuples plus status/exit/uptime.
The :shell/enabled toggle is registered HERE, extension-owned under the
extension's own namespace.
`shell/` compatibility extension — a DROPPABLE classpath plug-in (drop the jar, drop the feature), gated behind the user-owned `:shell/enabled` toggle (OFF by default; every call short-circuits into a refusal envelope until the user flips it in settings). Three model-facing bindings under alias `shell` (the flat Python sandbox renders `alias/name` as `<alias>_<name>`, so the calls are `shell_run` / `shell_bg` / `shell_logs` — same shape as `git_status`, `clj_eval`, `search_web`): 1. SYNC `shell_run(cmd)` / `shell_run(cmd, opts)` — `bash -lc` in the workspace root, waits up to a timeout, and returns a LEAN payload with string keys cmd/stdout/duration_ms + conditional keys (exit when finished; timed_out/timeout_secs on timeout; stderr when non-empty; truncation flags when true; cwd when narrowed) — results ride every later prompt as frozen <results> pins, so dead fields never ship. Output is bounded at read time to a head+tail budget per stream — only the MIDDLE of a huge stream is dropped, never its start or end (memory can't balloon on a chatty-then-killed command). A non-zero exit is DATA the model reads, not a tool error. 2. BACKGROUND `shell_bg(id, cmd)` — spawns the process, pumps its merged output into a bounded ring buffer, and registers it as a session RESOURCE in `internal.resources`: it shows up in the footer count, the F4 dialog, and the `resources` ctx block, and the ONE stop path is `resource_stop(id)` (model) / the footer dialog (user) — both land on `resources/stop!`, which runs our `:stop-fn` (process-tree kill + buffer drop). An exited process is NOT auto-pruned (its `:alive-fn` reports true while the buffer entry exists) so `shell_logs(id)` can still read its output + exit code until the resource is stopped. 3. `shell_logs(id)` / `shell_logs(id, n)` — tail of a background shell's captured output as `[seq, line]` tuples plus status/exit/uptime. The `:shell/enabled` toggle is registered HERE, extension-owned under the extension's own namespace.
Built-in sandbox SHIM: vis_attach / vis_attach_bytes — the GENERIC
producer twin of the matplotlib capture. A tool running in python_execution
writes any artifact (a PNG it rendered, a CSV/JSON/PDF/wav it built, whatever)
and hands it to vis_attach(path) (or vis_attach_bytes(data, name)); the
engine then OWNS the bytes as a durable session_iteration_attachment row,
exactly like a matplotlib figure — surviving a web/TUI restart and (for image
media-types) replayable to a vision model cross-turn.
No parsing, no round-trip through the model-facing stdout: we control the whole
boundary. The Python side reads the file through the sandbox's OWN confined
open (so filesystem-root confinement is enforced for free — a path outside
the roots raises the normal sandbox error), sniffs the media-type (magic bytes
then extension then utf-8 probe), base64-encodes, and calls the tiny host
bridge __vis_record_attachment__, which appends the attachment map to the
per-block *image-sink* (mpl-capture/record-attachment!). run-python-block
drains that sink into the block outcome's :attachments; the loop stamps each with
the producing block's tool-call-id and hands them to db-store-iteration!'s
:attachments.
Registered unconditionally as a foundation shim (like shim-yaml /
shim-matplotlib): its :ext/sandbox-shims entry autoloads vis_attach into
every sandbox (main + every sub_loop fork).
Built-in sandbox SHIM: `vis_attach` / `vis_attach_bytes` — the GENERIC producer twin of the matplotlib capture. A tool running in `python_execution` writes any artifact (a PNG it rendered, a CSV/JSON/PDF/wav it built, whatever) and hands it to `vis_attach(path)` (or `vis_attach_bytes(data, name)`); the engine then OWNS the bytes as a durable `session_iteration_attachment` row, exactly like a matplotlib figure — surviving a web/TUI restart and (for image media-types) replayable to a vision model cross-turn. No parsing, no round-trip through the model-facing stdout: we control the whole boundary. The Python side reads the file through the sandbox's OWN confined `open` (so filesystem-root confinement is enforced for free — a path outside the roots raises the normal sandbox error), sniffs the media-type (magic bytes then extension then utf-8 probe), base64-encodes, and calls the tiny host bridge `__vis_record_attachment__`, which appends the attachment map to the per-block `*image-sink*` (`mpl-capture/record-attachment!`). `run-python-block` drains that sink into the block outcome's `:attachments`; the loop stamps each with the producing block's tool-call-id and hands them to `db-store-iteration!`'s `:attachments`. Registered unconditionally as a foundation shim (like shim-yaml / shim-matplotlib): its `:ext/sandbox-shims` entry autoloads `vis_attach` into every sandbox (main + every `sub_loop` fork).
Built-in sandbox SHIM: a minimal matplotlib.pyplot-compatible module for the
model's Python sandbox, backed by a pure-JVM Java2D renderer. The agent
sandbox ships no CPython matplotlib wheel (it needs numpy's native core +
freetype, all blocked by the deny-by-default Context); this extension instead
contributes a :ext/sandbox-shims entry whose Python preamble accumulates the
familiar pyplot artists (plot/scatter/bar/hist/fill_between/step/
pie/axhline/axvline/title/xlabel/… plus the OO subplots/Axes
API) and whose savefig DELEGATES the whole figure spec across the boundary
to the host callable __vis_mpl_render__, which draws it with java.awt/
ImageIO and returns a base64 PNG. The Python side base64-decodes and writes
it (to a path, confined to the sandbox roots, or to any file-like buffer).
It is a SUBSET, not real matplotlib: line/scatter/bar/hist/fill/step/pie with title, axis labels, grid, legend, dashed line styles, markers, log scales and text annotations — enough for the model to visualize data. Rendering runs entirely on the JVM (no pip, no native wheels), and any render failure surfaces to Python as a catchable exception (never crashes the sandbox).
Together with shim-yaml this demonstrates the sandbox-shim mechanism: an
extension turns a host / JVM capability into a real importable Python module
while env-python stays completely generic about which shims exist.
Built-in sandbox SHIM: a minimal `matplotlib.pyplot`-compatible module for the model's Python sandbox, backed by a pure-JVM Java2D renderer. The agent sandbox ships no CPython matplotlib wheel (it needs numpy's native core + freetype, all blocked by the deny-by-default Context); this extension instead contributes a `:ext/sandbox-shims` entry whose Python preamble accumulates the familiar pyplot artists (`plot`/`scatter`/`bar`/`hist`/`fill_between`/`step`/ `pie`/`axhline`/`axvline`/`title`/`xlabel`/… plus the OO `subplots`/`Axes` API) and whose `savefig` DELEGATES the whole figure spec across the boundary to the host callable `__vis_mpl_render__`, which draws it with `java.awt`/ `ImageIO` and returns a base64 PNG. The Python side base64-decodes and writes it (to a path, confined to the sandbox roots, or to any file-like buffer). It is a SUBSET, not real matplotlib: line/scatter/bar/hist/fill/step/pie with title, axis labels, grid, legend, dashed line styles, markers, log scales and text annotations — enough for the model to visualize data. Rendering runs entirely on the JVM (no pip, no native wheels), and any render failure surfaces to Python as a catchable exception (never crashes the sandbox). Together with `shim-yaml` this demonstrates the sandbox-shim mechanism: an extension turns a host / JVM capability into a real importable Python module while `env-python` stays completely generic about which shims exist.
Built-in sandbox SHIM: a pytest-compatible module for the model's Python
sandbox, implemented PURELY in Python on the stdlib (ast, inspect,
linecache, traceback, warnings, tempfile) — NO host/JVM bridge, NOT a
line of Clojure or babashka. pytest is a third-party wheel that does not
ship in GraalPy, so agents writing full Python extensions and wanting to
TEST them inline would otherwise hit ModuleNotFoundError; this extension
contributes a :ext/sandbox-shims entry that
env-python/build-agent-context installs into every sandbox Context (main +
every sub_loop fork).
It is NOT real pytest: there is no pluggy/plugin system, no conftest.py
file discovery, no CLI, and no import-time assertion rewrite. Instead it
implements the subset that matters in an inline sandbox where the model
writes tests + pytest.main() in ONE block:
test_* functions and Test* classes (scoped to the
CURRENT block via __vis_src__, so leftovers from earlier blocks in the
shared globals are NOT swept in),assert 2 == 3 reconstructed with operand
values) done by registering __vis_src__ into linecache and walking
the failing frame's AST — the same UX as pytest's rewrite, via a
different mechanism,pytest.raises / warns / approx / fail / skip / xfail /
importorskip, @pytest.fixture (function/module/session scope,
yield-teardown, autouse, recursive injection), @pytest.mark.parametrize
/ skip / skipif / xfail (+ arbitrary marks), pytest.param,
builtin fixtures request / monkeypatch / capsys / capfd /
tmp_path, and a pytest.main() runner that prints progress + failure
reports + a summary line and returns an exit code.Unlike shim-yaml/shim-matplotlib there are NO :shim/bindings: the shim
is a self-contained Python preamble with zero host callables. It publishes a
pytest module into sys.modules (so import pytest works) and staples it
onto builtins (so pytest.raises(...) works with NO import, like
json/os/requests).
Built-in sandbox SHIM: a `pytest`-compatible module for the model's Python
sandbox, implemented PURELY in Python on the stdlib (`ast`, `inspect`,
`linecache`, `traceback`, `warnings`, `tempfile`) — NO host/JVM bridge, NOT a
line of Clojure or babashka. `pytest` is a third-party wheel that does not
ship in GraalPy, so agents writing full Python extensions and wanting to
TEST them inline would otherwise hit ModuleNotFoundError; this extension
contributes a `:ext/sandbox-shims` entry that
`env-python/build-agent-context` installs into every sandbox Context (main +
every `sub_loop` fork).
It is NOT real pytest: there is no pluggy/plugin system, no `conftest.py`
file discovery, no CLI, and no import-time assertion rewrite. Instead it
implements the subset that matters in an inline sandbox where the model
writes tests + `pytest.main()` in ONE block:
- collection of `test_*` functions and `Test*` classes (scoped to the
CURRENT block via `__vis_src__`, so leftovers from earlier blocks in the
shared globals are NOT swept in),
- RUNTIME assert introspection (`assert 2 == 3` reconstructed with operand
values) done by registering `__vis_src__` into `linecache` and walking
the failing frame's AST — the same UX as pytest's rewrite, via a
different mechanism,
- `pytest.raises` / `warns` / `approx` / `fail` / `skip` / `xfail` /
`importorskip`, `@pytest.fixture` (function/module/session scope,
yield-teardown, autouse, recursive injection), `@pytest.mark.parametrize`
/ `skip` / `skipif` / `xfail` (+ arbitrary marks), `pytest.param`,
builtin fixtures `request` / `monkeypatch` / `capsys` / `capfd` /
`tmp_path`, and a `pytest.main()` runner that prints progress + failure
reports + a summary line and returns an exit code.
Unlike `shim-yaml`/`shim-matplotlib` there are NO `:shim/bindings`: the shim
is a self-contained Python preamble with zero host callables. It publishes a
`pytest` module into `sys.modules` (so `import pytest` works) and staples it
onto builtins (so `pytest.raises(...)` works with NO import, like
json/os/requests).Built-in sandbox SHIM: a requests-compatible module for the model's Python
sandbox, backed PURELY by the stdlib urllib.request — NO host/JVM bridge,
NOT a line of Clojure or babashka. requests is a third-party wheel that does
not ship in GraalPy, so agents that reach for import requests out of habit
would otherwise hit ModuleNotFoundError; this extension contributes a
:ext/sandbox-shims entry that env-python/build-agent-context installs into
every sandbox Context (main + every sub_loop fork).
Because every call travels through the sandbox's OWN socket (urllib ->
http.client -> socket), it automatically honours the network toggle
(allowHostSocketAccess) AND the allow/deny + anti-SSRF network-guard-python
— a JVM babashka.http-client bridge would open an egress path OUTSIDE the
sandbox and disarm all of that, which is exactly why this stays 100% Python.
Unlike shim-yaml/shim-matplotlib there are NO :shim/bindings: the shim is
a self-contained Python preamble with zero host callables. It publishes a
requests module into sys.modules (so import requests works) and staples
it onto builtins (so requests.get(...) works with NO import, like json/os).
Built-in sandbox SHIM: a `requests`-compatible module for the model's Python sandbox, backed PURELY by the stdlib `urllib.request` — NO host/JVM bridge, NOT a line of Clojure or babashka. `requests` is a third-party wheel that does not ship in GraalPy, so agents that reach for `import requests` out of habit would otherwise hit ModuleNotFoundError; this extension contributes a `:ext/sandbox-shims` entry that `env-python/build-agent-context` installs into every sandbox Context (main + every `sub_loop` fork). Because every call travels through the sandbox's OWN socket (urllib -> http.client -> socket), it automatically honours the network toggle (`allowHostSocketAccess`) AND the allow/deny + anti-SSRF `network-guard-python` — a JVM `babashka.http-client` bridge would open an egress path OUTSIDE the sandbox and disarm all of that, which is exactly why this stays 100% Python. Unlike `shim-yaml`/`shim-matplotlib` there are NO `:shim/bindings`: the shim is a self-contained Python preamble with zero host callables. It publishes a `requests` module into `sys.modules` (so `import requests` works) and staples it onto builtins (so `requests.get(...)` works with NO import, like json/os).
Built-in sandbox SHIM: a PyYAML-compatible yaml module for the model's
Python sandbox, backed by the pure-Clojure YAMLStar loader
(org.yamlstar/yamlstar). No CPython PyYAML wheel ships in the sandbox; this
extension contributes a :ext/sandbox-shims entry that
env-python/build-agent-context installs into every sandbox Context (main +
every sub_loop fork): the host bridge callables are wired onto the globals,
then the Python preamble publishes a yaml module into sys.modules (so
import yaml works) and staples it onto builtins (so yaml.safe_load(...)
works with NO import).
This is the reference example of the sandbox-shim mechanism: a host / JVM capability surfaced to sandbox Python as a real importable module, with the engine staying completely generic about which shims exist.
Built-in sandbox SHIM: a PyYAML-compatible `yaml` module for the model's Python sandbox, backed by the pure-Clojure YAMLStar loader (`org.yamlstar/yamlstar`). No CPython PyYAML wheel ships in the sandbox; this extension contributes a `:ext/sandbox-shims` entry that `env-python/build-agent-context` installs into every sandbox Context (main + every `sub_loop` fork): the host bridge callables are wired onto the globals, then the Python preamble publishes a `yaml` module into `sys.modules` (so `import yaml` works) and staples it onto builtins (so `yaml.safe_load(...)` works with NO import). This is the reference example of the sandbox-shim mechanism: a host / JVM capability surfaced to sandbox Python as a real importable module, with the engine staying completely generic about which shims exist.
Full session transcript - DATA first, presentation second.
transcript returns one canonical Clojure map with every turn,
every iteration, every executed block plus the LLM-side context
(system prompt, message envelope, reasoning trace, top-level
provider error, per-iteration vars, answer-form pointer,
returned-empty-blocks flag) and the per-block forensic detail
(code, comment, result, error, duration, timeout?, repaired?).
Pure data. The agent can pattern-match on it; the CLI
renders Markdown on top; a future TUI screen, JSON exporter, or
analytics extension consumes the same shape.
Lives in foundation because it's an introspection surface, not host
plumbing. The sandbox-visible public surface is (session-state)
for data and (session-report-md) for Markdown; this namespace
owns the transcript portion behind that deeper interface.
Public Clojure surface:
(transcript db-info session-id) -> transcript data map
(transcript->md data) -> Markdown string
(transcript-md db-info session-id) -> DB lookup + Markdown string
Canonical data shape:
{:session {:id :title :channel :model :provider :created-at} :totals {:turns N :iterations N :tokens {:input :output :reasoning :cached} :cost-usd D} :dialog [{:role :turn-id :content}] :calls [{:kind :ref :parent-ref :turn-id :iteration-id :op :tool :var :code :status :duration-ms :command :target :result :result-summary :info}] :timeline [{:kind :ref :turn-id :iteration-id :content :code :status :duration-ms :result-summary}] :turns [{:id :user-request :status :prior-outcome :provider :model :iteration-count :failure-count :tokens :cost-usd :answer :iterations [{:id :position :status :duration-ms :provider :model :thinking :error :tokens :cost-usd :answer-position :returned-empty-blocks? :vars [{:name :code :value :version}] :blocks [{:position :code :comment :result :error :duration-ms :timeout? :repaired?}]}]}]}
The Markdown renderer renders thinking, iteration-level errors, vars, per-block forensic previews, and final answer text. Large fields are bounded so reports stay safe to open.
Full session transcript - DATA first, presentation second.
`transcript` returns one canonical Clojure map with every turn,
every iteration, every executed block plus the LLM-side context
(system prompt, message envelope, reasoning trace, top-level
provider error, per-iteration vars, answer-form pointer,
returned-empty-blocks flag) and the per-block forensic detail
(code, comment, result, error, duration, timeout?, repaired?).
Pure data. The agent can pattern-match on it; the CLI
renders Markdown on top; a future TUI screen, JSON exporter, or
analytics extension consumes the same shape.
Lives in foundation because it's an introspection surface, not host
plumbing. The sandbox-visible public surface is `(session-state)`
for data and `(session-report-md)` for Markdown; this namespace
owns the transcript portion behind that deeper interface.
Public Clojure surface:
`(transcript db-info session-id)` -> transcript data map
`(transcript->md data)` -> Markdown string
`(transcript-md db-info session-id)` -> DB lookup + Markdown string
Canonical data shape:
{:session {:id :title :channel :model :provider :created-at}
:totals {:turns N :iterations N
:tokens {:input :output :reasoning :cached}
:cost-usd D}
:dialog [{:role :turn-id :content}]
:calls [{:kind :ref :parent-ref :turn-id :iteration-id :op :tool
:var :code :status :duration-ms :command :target
:result :result-summary :info}]
:timeline [{:kind :ref :turn-id :iteration-id :content :code
:status :duration-ms :result-summary}]
:turns
[{:id :user-request :status :prior-outcome :provider :model
:iteration-count :failure-count
:tokens :cost-usd :answer
:iterations
[{:id :position :status :duration-ms
:provider :model :thinking :error
:tokens :cost-usd
:answer-position :returned-empty-blocks?
:vars
[{:name :code :value :version}]
:blocks
[{:position :code :comment :result :error
:duration-ms :timeout? :repaired?}]}]}]}
The Markdown renderer renders thinking, iteration-level errors,
vars, per-block forensic previews, and final answer text. Large
fields are bounded so reports stay safe to open.Pre-turn "session_workspace" CTX block (STRING-KEYED — crosses the
Python boundary as session["workspace"]).
Sessions may work directly in trunk or inside an isolated backend
workspace. That distinction is reported on "sandbox", NOT
as a VCS. "vcs_kind" reports the underlying repository VCS ("git"
when the root is inside a git repo, else "none") so it matches the
git/ extension surface, which activates on the same predicate. The
model reads the workspace block to know the active root and what it has
changed since the fork. The block is stamped once per turn at engine
start; ctx_renderer serialises it verbatim.
Pre-turn `"session_workspace"` CTX block (STRING-KEYED — crosses the Python boundary as `session["workspace"]`). Sessions may work directly in trunk or inside an isolated backend workspace. That distinction is reported on `"sandbox"`, NOT as a VCS. `"vcs_kind"` reports the underlying repository VCS (`"git"` when the root is inside a git repo, else `"none"`) so it matches the `git/` extension surface, which activates on the same predicate. The model reads the workspace block to know the active root and what it has changed since the fork. The block is stamped once per turn at engine start; ctx_renderer serialises it verbatim.
Declarative /draft … slash tree.
Drafts are OPT-IN. By default a session works directly in the user's
real cwd (trunk). /draft new <label> clones cwd into an isolated
draft (an isolated workspace named <label>) and enters it; /draft apply
lands the draft's changes into cwd and leaves the draft; /draft abandon discards it and leaves. The header shows <label> (DRAFT)
while you're in one.
/draft show whether you're on trunk or in a draft /draft new <label> clone cwd into a draft named <label>, enter it /draft apply land the draft's changes into cwd, leave the draft /draft abandon [why] discard the draft, leave it
Filesystem permissions (/fs, /root) — session-scoped, every channel:
/root [path] show / CHANGE the session's filesystem root /fs list the session's filesystem permissions /fs root <path> same as /root <path> /fs add <path> also let the session operate under <path> /fs remove <path> drop an added directory /fs create <path> mkdir + add it
Vis owns no git lifecycle — apply copies the changed files into the
user's real cwd, uncommitted. Handlers are PURE w.r.t. the channel.
Declarative `/draft …` slash tree. Drafts are OPT-IN. By default a session works directly in the user's real cwd (trunk). `/draft new <label>` clones cwd into an isolated draft (an isolated workspace named `<label>`) and enters it; `/draft apply` lands the draft's changes into cwd and leaves the draft; `/draft abandon` discards it and leaves. The header shows `<label> (DRAFT)` while you're in one. /draft show whether you're on trunk or in a draft /draft new <label> clone cwd into a draft named <label>, enter it /draft apply land the draft's changes into cwd, leave the draft /draft abandon [why] discard the draft, leave it Filesystem permissions (`/fs`, `/root`) — session-scoped, every channel: /root [path] show / CHANGE the session's filesystem root /fs list the session's filesystem permissions /fs root <path> same as /root <path> /fs add <path> also let the session operate under <path> /fs remove <path> drop an added directory /fs create <path> mkdir + add it Vis owns no git lifecycle — `apply` copies the changed files into the user's real cwd, uncommitted. Handlers are PURE w.r.t. the channel.
Cross-process gateway event bus.
The gateway's live event log + SSE fan-out (gateway.state) is a
PROCESS-LOCAL in-memory registry: append-event! only reaches
subscribers inside the SAME JVM. That is why a turn streaming in the
TUI process is invisible to a web/Telegram process watching the SAME
conversation — each process has its own registry, and the only thing
they share is the persisted DB (which lands whole turns, not the live
token stream). So two watchers never stream together.
This bus closes that gap with the simplest transport that needs no
schema change and no always-on daemon: a shared append-only journal
under ~/.vis/gateway/events/<sid>.ndjson. Every LOCALLY-produced
gateway event is publish!ed there (one JSON line, tagged with this
process's producer id). A background tailer in each process follows
those files and re-delivers FOREIGN events (producer != self) into the
local registry via a delivery fn wired by gateway.state — so every
process's subscribers see the same stream, live.
Ordering/seq: exactly ONE turn runs per session at a time, so at any
moment a single producer owns the stream and its monotonic :seq is
authoritative for every watcher. The producer truncates the journal at
each turn.started, bounding a file to one turn's worth of deltas;
consumers detect the truncation (offset past EOF) and rewind.
Degrades safely: any IO failure is swallowed and the process falls back to today's in-process-only behavior.
Cross-process gateway event bus. The gateway's live event log + SSE fan-out (`gateway.state`) is a PROCESS-LOCAL in-memory registry: `append-event!` only reaches subscribers inside the SAME JVM. That is why a turn streaming in the TUI process is invisible to a web/Telegram process watching the SAME conversation — each process has its own registry, and the only thing they share is the persisted DB (which lands whole turns, not the live token stream). So two watchers never stream together. This bus closes that gap with the simplest transport that needs no schema change and no always-on daemon: a shared append-only journal under `~/.vis/gateway/events/<sid>.ndjson`. Every LOCALLY-produced gateway event is `publish!`ed there (one JSON line, tagged with this process's `producer` id). A background tailer in each process follows those files and re-delivers FOREIGN events (producer != self) into the local registry via a delivery fn wired by `gateway.state` — so every process's subscribers see the same stream, live. Ordering/seq: exactly ONE turn runs per session at a time, so at any moment a single producer owns the stream and its monotonic `:seq` is authoritative for every watcher. The producer truncates the journal at each `turn.started`, bounding a file to one turn's worth of deltas; consumers detect the truncation (offset past EOF) and rewind. Degrades safely: any IO failure is swallowed and the process falls back to today's in-process-only behavior.
Gateway HTTP/SSE server.
Clojure-native stack: reitit-ring routes -> Ring middleware -> the
Ring Jetty adapter on JDK virtual threads (:virtual-threads? true).
SSE is a Ring StreamableResponseBody that parks its virtual thread
on the connection: replay rides first, live events fan in under the
same output-stream monitor, a heartbeat comment keeps the pipe warm
and detects dead clients.
This is internal plumbing, not a channel: it registers no channel
descriptor and owns no renderer - it ships canonical IR and the
client renders (§4.1). Any host process (the vis serve daemon, a
TUI run, an embedded caller) can start it alongside whatever else it
is doing via start!.
Gateway HTTP/SSE server. Clojure-native stack: reitit-ring routes -> Ring middleware -> the Ring Jetty adapter on JDK virtual threads (`:virtual-threads? true`). SSE is a Ring `StreamableResponseBody` that parks its virtual thread on the connection: replay rides first, live events fan in under the same output-stream monitor, a heartbeat comment keeps the pipe warm and detects dead clients. This is internal plumbing, not a channel: it registers no channel descriptor and owns no renderer - it ships canonical IR and the client renders (§4.1). Any host process (the `vis serve` daemon, a TUI run, an embedded caller) can start it alongside whatever else it is doing via `start!`.
Gateway session manager.
One process-global registry over the live session fleet: per-session
ordered event log (monotonic :seq, ring-buffered), SSE subscriber
fan-out, async turn submission with idempotency keys, cancellation,
and turn/cost metrics.
The engine is reached ONLY through the same internal surfaces the
TUI/Telegram channels use: loop/create!-send!-close! for the
lifecycle, :hooks {:on-chunk ...} phased chunks for the live
stream, ctx-loop/session-snapshot for the context. No engine state
lives here - this namespace owns wire bookkeeping (events, turn
records, subscribers), nothing else.
Gateway session manager.
One process-global registry over the live session fleet: per-session
ordered event log (monotonic `:seq`, ring-buffered), SSE subscriber
fan-out, async turn submission with idempotency keys, cancellation,
and turn/cost metrics.
The engine is reached ONLY through the same internal surfaces the
TUI/Telegram channels use: `loop/create!`-`send!`-`close!` for the
lifecycle, `:hooks {:on-chunk ...}` phased chunks for the live
stream, `ctx-loop/session-snapshot` for the context. No engine state
lives here - this namespace owns wire bookkeeping (events, turn
records, subscribers), nothing else.Wire encoding for the HTTP gateway.
One dumb, deterministic boundary: engine EDN -> JSON. Keyword/symbol
keys become snake_case strings (namespace dropped), keyword values
become their name, non-JSON leaves fall back to str. The walker
makes ZERO semantic decisions - no flattening, no rendering. Canonical
IR vectors pass through structurally ([:ir {...} ...] ->
["ir", {...}, ...]), which is exactly the ALWAYS-IR contract:
the client walks IR; the gateway never renders.
Wire encoding for the HTTP gateway.
One dumb, deterministic boundary: engine EDN -> JSON. Keyword/symbol
keys become snake_case strings (namespace dropped), keyword values
become their name, non-JSON leaves fall back to `str`. The walker
makes ZERO semantic decisions - no flattening, no rendering. Canonical
IR vectors pass through structurally (`[:ir {...} ...]` ->
`["ir", {...}, ...]`), which is exactly the ALWAYS-IR contract:
the client walks IR; the gateway never renders.Shared workspace inspection, backed by the native git binary.
UI surfaces and extensions depend on this namespace instead of embedding
their own git calls. It reports small, renderable facts about the current
repository (branch, dirty buckets, ahead/behind, porcelain entries) by
shelling out to git status --porcelain=v2 and git rev-parse, parsed
into stable Clojure maps. No JGit — the only git implementation is the one
already on the user's PATH, so behaviour matches their shell exactly.
Workspace lifecycle mutations stay in
com.blockether.vis.internal.workspace.
Shared workspace inspection, backed by the native `git` binary. UI surfaces and extensions depend on this namespace instead of embedding their own git calls. It reports small, renderable facts about the current repository (branch, dirty buckets, ahead/behind, porcelain entries) by shelling out to `git status --porcelain=v2` and `git rev-parse`, parsed into stable Clojure maps. No JGit — the only git implementation is the one already on the user's PATH, so behaviour matches their shell exactly. Workspace lifecycle mutations stay in `com.blockether.vis.internal.workspace`.
Minimal, pure-Clojure .gitignore matcher — the JGit-free replacement for
IgnoreNode. Parses ONE .gitignore file (the one at a walk root) into
ordered rules and evaluates a /-separated relative path against them with
git's semantics: last matching rule wins, ! negates, a trailing /
restricts to directories, a leading/embedded / anchors to the root, and a
slash-free pattern matches at any depth.
Minimal, pure-Clojure `.gitignore` matcher — the JGit-free replacement for `IgnoreNode`. Parses ONE `.gitignore` file (the one at a walk root) into ordered rules and evaluates a `/`-separated relative path against them with git's semantics: last matching rule wins, `!` negates, a trailing `/` restricts to directories, a leading/embedded `/` anchors to the root, and a slash-free pattern matches at any depth.
Channel-agnostic header layout & content spec.
Every channel — terminal TUI, web, Telegram, future surfaces — renders the same conceptual header band:
[LEFT 30%] [CENTER 40% workspace switcher] [RIGHT 30%]
The decisions a channel cannot make on its own (slot ratios, workspace switcher sizing/visibility caps, default labels, copy id length, glyphs) live here as plain Clojure data. A channel imports this namespace, reads the values, and projects them onto its medium — TextGraphics cells for Lanterna, divs/spans for HTML, a keyboard row for Telegram, etc.
No graphics. No I/O. No channel-specific deps. Pure data + tiny
pure helpers, written as .cljc so a ClojureScript web UI can
require it directly.
Channel-agnostic header layout & content spec.
Every channel — terminal TUI, web, Telegram, future surfaces —
renders the same conceptual header band:
[LEFT 30%] [CENTER 40% workspace switcher] [RIGHT 30%]
The decisions a channel cannot make on its own (slot ratios, workspace
switcher sizing/visibility caps, default labels, copy id length, glyphs)
live here as plain Clojure data. A channel imports this namespace,
reads the values, and projects them onto its medium — TextGraphics
cells for Lanterna, divs/spans for HTML, a keyboard row for
Telegram, etc.
No graphics. No I/O. No channel-specific deps. Pure data + tiny
pure helpers, written as `.cljc` so a ClojureScript web UI can
require it directly.In-house import-vars — re-export public vars from other namespaces under the
current namespace, carrying their :doc/:arglists so the facade reads like the
originals and doc/editor help work on a re-export. A tiny, dependency-free
stand-in for potemkin's import-vars (the project keeps no separate lib for it).
Value semantics are identical to a plain (def alias src): the alias captures
the source var's value at load time — it re-exports a fn/const, it does NOT
track later redefinitions, and (like a def-alias always did) it is not a
binding-rebindable handle for a dynamic source var.
In-house `import-vars` — re-export public vars from other namespaces under the current namespace, carrying their :doc/:arglists so the facade reads like the originals and `doc`/editor help work on a re-export. A tiny, dependency-free stand-in for potemkin's import-vars (the project keeps no separate lib for it). Value semantics are identical to a plain `(def alias src)`: the alias captures the source var's value at load time — it re-exports a fn/const, it does NOT track later redefinitions, and (like a def-alias always did) it is not a `binding`-rebindable handle for a dynamic source var.
Canonical iteration-entry shape — the single source of truth shared by
the LIVE progress tracker (internal/progress) and the RESUME projection
(channel-tui/chat).
Background: the live-vs-resume split was the root cause of every TUI regression. The live tracker accumulated chunks into one map shape; the resume path rebuilt a different map shape from persisted rows. This ns pins ONE shape both paths populate.
form envelope — one block = one form record (the engine :forms
BLOB), carrying :code, :result, :error, and
:stdout (what the block PRINTED — the single display
surface; op cards / render-fns are gone).
{:position n ;; 0-based display position of the iteration :scope "tN/iM" ;; BLOCK-level scope, never /fK :thinking string-or-nil ;; reasoning text for this iteration :code "<block source>" :forms [<form> ...] ;; form envelopes (engine :forms BLOB) :status :ok|:error|:running|:cancelled|:timeout :duration-ms long :error error-map-or-nil}
Canonical iteration-entry shape — the single source of truth shared by
the LIVE progress tracker (`internal/progress`) and the RESUME projection
(`channel-tui/chat`).
Background: the live-vs-resume split was the root cause of every TUI
regression. The live tracker accumulated chunks into one map shape; the
resume path rebuilt a *different* map shape from persisted rows. This ns
pins ONE shape both paths populate.
## Vocabulary
form envelope — one block = one form record (the engine `:forms`
BLOB), carrying `:code`, `:result`, `:error`, and
`:stdout` (what the block PRINTED — the single display
surface; op cards / render-fns are gone).
## Canonical iteration-entry
{:position n ;; 0-based display position of the iteration
:scope "tN/iM" ;; BLOCK-level scope, never /fK
:thinking string-or-nil ;; reasoning text for this iteration
:code "<block source>"
:forms [<form> ...] ;; form envelopes (engine :forms BLOB)
:status :ok|:error|:running|:cancelled|:timeout
:duration-ms long
:error error-map-or-nil}Channel-neutral {:dynamic {:limits [...]}} row formatters.
Hoisted from the TUI extension (channel_tui/limits_fmt.clj) so
every channel — TUI footer, TUI provider cards, web provider cards,
Telegram — renders the SAME compact account-quota summary from a
provider's normalized limits report. The TUI namespace now aliases
these vars; the web channel consumes them through vis.core.
The interesting account-level rows (:zai-coding-plan-5h,
:codex-7d, :premium_interactions, ...) live under
[:dynamic :limits]; static :rpm/:tpm are svar catalog
defaults, identical for every provider, useful only as fallback.
Channel-neutral `{:dynamic {:limits [...]}}` row formatters.
Hoisted from the TUI extension (`channel_tui/limits_fmt.clj`) so
every channel — TUI footer, TUI provider cards, web provider cards,
Telegram — renders the SAME compact account-quota summary from a
provider's normalized limits report. The TUI namespace now aliases
these vars; the web channel consumes them through `vis.core`.
The interesting account-level rows (`:zai-coding-plan-5h`,
`:codex-7d`, `:premium_interactions`, ...) live under
`[:dynamic :limits]`; static `:rpm`/`:tpm` are svar catalog
defaults, identical for every provider, useful only as fallback.vis CLI binary - :db Telemere handler, one-shot agent helper,
built-in CLI commands, and the -main dispatcher entry point.
Everything in this file is binary-only. The library surface
(iteration loop, turn engine, environment lifecycle, session
cache) lives in com.blockether.vis.internal.loop; this namespace requires
that one and wires it into the command tree the vis binary
exposes.
Public entry point:
(-main & args) - invoked by the :vis alias / bin/vis.
Configures logging, runs the unified extension
discovery scan, redirects stderr to ~/.vis/vis.log
for any TTY-owning channel, then dispatches to
the resolved command's :cmd/run-fn.
Built-in commands registered here: vis providers - provider inspection, auth, and limits vis sessions - list persisted sessions vis ext list - list registered extensions vis channels <name> - auto-mounted via the channel registry
vis doctor is host-owned. Extensions plug diagnostics into it
with :ext/doctor-fn; extension-owned CLI commands stay under
vis ext.
vis CLI binary - :db Telemere handler, one-shot agent helper,
built-in CLI commands, and the `-main` dispatcher entry point.
Everything in this file is binary-only. The library surface
(iteration loop, turn engine, environment lifecycle, session
cache) lives in `com.blockether.vis.internal.loop`; this namespace requires
that one and wires it into the command tree the `vis` binary
exposes.
Public entry point:
(-main & args) - invoked by the `:vis` alias / `bin/vis`.
Configures logging, runs the unified extension
discovery scan, redirects stderr to ~/.vis/vis.log
for any TTY-owning channel, then dispatches to
the resolved command's `:cmd/run-fn`.
Built-in commands registered here:
vis providers - provider inspection, auth, and limits
vis sessions - list persisted sessions
vis ext list - list registered extensions
vis channels <name> - auto-mounted via the channel registry
`vis doctor` is host-owned. Extensions plug diagnostics into it
with `:ext/doctor-fn`; extension-owned CLI commands stay under
`vis ext`.Classpath auto-discovery of vis extension manifests.
ONE resource per jar: META-INF/vis-extension/vis.edn. Each file
is an EDN map keyed by extension id symbol:
{git {:nses [com.acme.ext.git.core com.acme.channel.web.bot ...]}}
scan-extensions! is the primitive: it walks every URL, parses
each map, requires every namespace listed under :nses exactly
once across all URLs (so registrar side effects in those namespaces
fire), and returns the merged manifest map {<id> {:nses [...]}}.
Idempotent and memoized.
rediscover! is a test/REPL helper that drops the cache and
re-scans.
Classpath auto-discovery of vis extension manifests.
ONE resource per jar: `META-INF/vis-extension/vis.edn`. Each file
is an EDN map keyed by extension id symbol:
{git
{:nses [com.acme.ext.git.core
com.acme.channel.web.bot
...]}}
`scan-extensions!` is the primitive: it walks every URL, parses
each map, `require`s every namespace listed under `:nses` exactly
once across all URLs (so registrar side effects in those namespaces
fire), and returns the merged manifest map `{<id> {:nses [...]}}`.
Idempotent and memoized.
`rediscover!` is a test/REPL helper that drops the cache and
re-scans.GraalVM native-image build-time Feature: initialize Clojure namespaces the right way so build-time class initialization doesn't blow up.
The problem: graal-build-time registers every Clojure-generated package for
build-time class initialization. native-image then runs each <clinit> RAW on
a parallel analysis worker thread, with no Clojure thread-binding frame. Any
namespace whose body has a top-level (set! *warn-on-reflection* true) (most
libraries — babashka.fs, next.jdbc, rewrite-clj, honeysql, nippy, …) throws
java.lang.IllegalStateException: Can't change/establish root binding of:
*warn-on-reflection* with set
because set! on a dynamic var requires a thread binding. Core namespaces
(clojure.string, clojure.spec.alpha, …) survive only because clojure.core's
bootstrap loads them through require, which DOES push that binding. Libraries
reached directly by the analysis get no such courtesy.
The fix: in beforeAnalysis — which runs in the image-builder JVM, before the
analysis can raw-init anything — require every app + extension namespace with
the compiler vars bound. require initializes each class through Clojure's
loader (binding active), so its set! succeeds; by the time the analysis marks
the class build-time-initialized it is already initialized and is not re-run.
Wired via --features=com.blockether.vis.internal.nativeimage in main's
resources/META-INF/native-image/com.blockether/vis/native-image.properties,
alongside graal-build-time's feature. Build-time only — never loaded at runtime.
GraalVM native-image build-time Feature: initialize Clojure namespaces the
*right* way so build-time class initialization doesn't blow up.
The problem: `graal-build-time` registers every Clojure-generated package for
build-time class initialization. native-image then runs each `<clinit>` RAW on
a parallel analysis worker thread, with no Clojure thread-binding frame. Any
namespace whose body has a top-level `(set! *warn-on-reflection* true)` (most
libraries — babashka.fs, next.jdbc, rewrite-clj, honeysql, nippy, …) throws
java.lang.IllegalStateException: Can't change/establish root binding of:
*warn-on-reflection* with set
because `set!` on a dynamic var requires a thread binding. Core namespaces
(clojure.string, clojure.spec.alpha, …) survive only because clojure.core's
bootstrap loads them through `require`, which DOES push that binding. Libraries
reached directly by the analysis get no such courtesy.
The fix: in `beforeAnalysis` — which runs in the image-builder JVM, before the
analysis can raw-init anything — `require` every app + extension namespace with
the compiler vars bound. `require` initializes each class through Clojure's
loader (binding active), so its `set!` succeeds; by the time the analysis marks
the class build-time-initialized it is already initialized and is not re-run.
Wired via `--features=com.blockether.vis.internal.nativeimage` in main's
`resources/META-INF/native-image/com.blockether/vis/native-image.properties`,
alongside graal-build-time's feature. Build-time only — never loaded at runtime.Cross-channel ephemeral notifications.
A single in-memory pub-sub the host runtime, every extension, and every channel can use to surface a transient signal - "copied to clipboard", "verify.sh passed", "provider switched" - without embedding it in the answer body or polluting Telemere logs.
Surface (re-exported on com.blockether.vis.core):
(notify! text) (notify! text :level :info|:success|:warn|:error :ttl-ms <long>|nil) (notifications) ;; vec of currently-active entries (dismiss! id) ;; force-clear by id (clear-expired!) ;; prune; called on every read (watch! key (fn [vec] ...)) ;; cross-channel reactivity (unwatch! key)
Entry shape: {:id <uuid> :text <string> :level :info | :success | :warn | :error :created-at <inst> :until <epoch-ms> | nil ;; nil = sticky / manual dismiss
Levels are advisory metadata for channels: TUI uses them for
colour, Telegram could use emoji, CLI could prefix [notice] /
[warn]. The host stores them but never interprets them.
Why a flat module instead of a generic event bus: notifications are a single, narrow concern. A 50-line atom + watcher map serves it without introducing a generic pub-sub abstraction nobody asked for. If we ever grow more event types, this becomes one consumer of a richer system.
Cross-channel ephemeral notifications.
A single in-memory pub-sub the host runtime, every extension, and
every channel can use to surface a transient signal - "copied to
clipboard", "verify.sh passed", "provider switched" - without
embedding it in the answer body or polluting Telemere logs.
Surface (re-exported on `com.blockether.vis.core`):
(notify! text)
(notify! text :level :info|:success|:warn|:error
:ttl-ms <long>|nil)
(notifications) ;; vec of currently-active entries
(dismiss! id) ;; force-clear by id
(clear-expired!) ;; prune; called on every read
(watch! key (fn [vec] ...)) ;; cross-channel reactivity
(unwatch! key)
Entry shape:
{:id <uuid>
:text <string>
:level :info | :success | :warn | :error
:created-at <inst>
:until <epoch-ms> | nil ;; nil = sticky / manual dismiss
Levels are advisory metadata for channels: TUI uses them for
colour, Telegram could use emoji, CLI could prefix `[notice]` /
`[warn]`. The host stores them but never interprets them.
Why a flat module instead of a generic event bus: notifications
are a single, narrow concern. A 50-line atom + watcher map serves
it without introducing a generic pub-sub abstraction nobody asked
for. If we ever grow more event types, this becomes one consumer
of a richer system.Unified OAuth token-refresh facade, shared by every provider.
WHY THIS EXISTS — Providers whose token endpoint ROTATES the
refresh_token on every exchange (Anthropic, OpenAI Codex) must never run
two refresh exchanges at once: the second reuses an already-rotated
refresh token and the server answers HTTP 400 invalid_grant. Under a
401 "storm" — the turn loop's per-iteration retry PLUS a usage/limits
poll, all sharing one credential — that race killed whole turns (~10 refreshes/min: one lost the
rotation race and the turn died). Providers that mint a short-lived token from a STABLE
credential (GitHub Copilot) don't 400, but still benefit: concurrent
401s otherwise stampede the exchange endpoint with redundant calls.
THE MODEL — refresh is serialized PER CREDENTIAL STORE, never globally.
Each make-file-refresher / refresher call mints its OWN lock, so a
refresh to Anthropic and a refresh to Codex run fully in parallel; only
two refreshes to the SAME store (e.g. two sessions both hitting
Anthropic) serialize — which is the whole point. Once the lock is held,
a caller REUSES a result another thread just produced (creds persisted
within default-reuse-window-ms, or an already-valid cache) so a burst
of N concurrent 401s collapses into ONE exchange.
USE — file-backed rotating stores (Anthropic, Codex): make-file-refresher.
Cache-backed / bespoke stores (Copilot): refresher with custom
reuse/refresh fns. Both return a 0-arg fn yielding the provider-token
map, owning their own lock; drop them straight into
:provider/get-token-fn / :provider/refresh-token-fn.
Unified OAuth token-refresh facade, shared by every provider. WHY THIS EXISTS — Providers whose token endpoint ROTATES the refresh_token on every exchange (Anthropic, OpenAI Codex) must never run two refresh exchanges at once: the second reuses an already-rotated refresh token and the server answers HTTP 400 `invalid_grant`. Under a 401 "storm" — the turn loop's per-iteration retry PLUS a usage/limits poll, all sharing one credential — that race killed whole turns (~10 refreshes/min: one lost the rotation race and the turn died). Providers that mint a short-lived token from a STABLE credential (GitHub Copilot) don't 400, but still benefit: concurrent 401s otherwise stampede the exchange endpoint with redundant calls. THE MODEL — refresh is serialized PER CREDENTIAL STORE, never globally. Each `make-file-refresher` / `refresher` call mints its OWN lock, so a refresh to Anthropic and a refresh to Codex run fully in parallel; only two refreshes to the SAME store (e.g. two sessions both hitting Anthropic) serialize — which is the whole point. Once the lock is held, a caller REUSES a result another thread just produced (creds persisted within `default-reuse-window-ms`, or an already-valid cache) so a burst of N concurrent 401s collapses into ONE exchange. USE — file-backed rotating stores (Anthropic, Codex): `make-file-refresher`. Cache-backed / bespoke stores (Copilot): `refresher` with custom reuse/refresh fns. Both return a 0-arg fn yielding the provider-token map, owning their own lock; drop them straight into `:provider/get-token-fn` / `:provider/refresh-token-fn`.
Cheap heuristics that turn opaque parse / eval errors into precise diagnostic hints. Engine wires the catalogue into the per-form trailer so the model sees actionable repair instructions instead of a stack trace.
Catalogue today:
diagnose-quote-balance Odd number of unescaped double quotes in the source. Pinpoints the 1-based line where the running count first becomes odd. parinferish does indent-mode paren balancing only; string-quote imbalance needs its own walker.
diagnose-bracket-balance
Unbalanced (), [], {}. Walks a bracket stack skipping string/char
literals (incl. triple-quoted, with escapes) and # comments; reports
the FIRST wrong-type / extra / unclosed bracket + 1-based line/col.
repair-bracket-balance offers a single-candidate auto-fix, but ONLY
when one edit rebalances the WHOLE form. Python's mixed (), [], {} are
NOT indentation-determined, so parinfer (the Clojure paren repairer)
cannot be reused here.
unresolved-symbol-hint The Python eval raised a NameError for an undefined name X. Suggests the closest name(s) in the user's sandbox bindings (Levenshtein-style score), so the model sees 'did you mean ...?' instead of 'X is undefined'.
Cheap heuristics that turn opaque parse / eval errors into precise
diagnostic hints. Engine wires the catalogue into the per-form trailer so
the model sees actionable repair instructions instead of a stack trace.
Catalogue today:
diagnose-quote-balance
Odd number of unescaped double quotes in the source. Pinpoints the
1-based line where the running count first becomes odd. parinferish
does indent-mode paren balancing only; string-quote imbalance needs
its own walker.
diagnose-bracket-balance
Unbalanced (), [], {}. Walks a bracket stack skipping string/char
literals (incl. triple-quoted, with escapes) and `#` comments; reports
the FIRST wrong-type / extra / unclosed bracket + 1-based line/col.
`repair-bracket-balance` offers a single-candidate auto-fix, but ONLY
when one edit rebalances the WHOLE form. Python's mixed (), [], {} are
NOT indentation-determined, so parinfer (the Clojure paren repairer)
cannot be reused here.
unresolved-symbol-hint
The Python eval raised a NameError for an undefined name X. Suggests
the closest name(s) in the user's sandbox bindings (Levenshtein-style score),
so the model sees 'did you mean ...?' instead of 'X is undefined'.Cross-platform path helpers. A LEAF namespace (no project deps) so any layer — core, extensions, tests — can normalize without a require cycle.
Cross-platform path helpers. A LEAF namespace (no project deps) so any layer — core, extensions, tests — can normalize without a require cycle.
Persistence facade: backend registry, connection lifecycle, and every
delegated store-*/db-* fn.
Extensions register backend adapters through :ext/persistance. The
facade dispatches each delegated call by resolving the matching var on
the chosen backend namespace ((ns-resolve ns-sym 'db-store-iteration!)
etc.) and applying it to the original args. This keeps the facade
dialect-agnostic - every migration runner / driver-specific oddity stays
inside the backend adapter.
Frontends still call db-error->user-message here, but the actual
translation is offered by backend adapters. Same for store-staleness
checks used by the process-wide shared connection.
Persistence facade: backend registry, connection lifecycle, and every delegated `store-*`/`db-*` fn. Extensions register backend adapters through `:ext/persistance`. The facade dispatches each delegated call by resolving the matching var on the chosen backend namespace (`(ns-resolve ns-sym 'db-store-iteration!)` etc.) and applying it to the original args. This keeps the facade dialect-agnostic - every migration runner / driver-specific oddity stays inside the backend adapter. Frontends still call `db-error->user-message` here, but the actual translation is offered by backend adapters. Same for store-staleness checks used by the process-wide shared connection.
Streaming progress tracker - leaf module.
Channels (TUI, CLI agent, Telegram) consume the iteration loop's
PHASED chunks via this tracker. Every chunk carries a :phase
keyword that tells the tracker what to do with it; the tracker
accumulates the chunks into a per-iteration timeline that the
channel re-renders incrementally.
Phases (every chunk has exactly one):
:reasoning LLM is streaming reasoning text. Updates the
iteration entry's :thinking field.
:form-start One block is about to evaluate. Carries
:position and :code. The tracker writes the
code immediately so channels can show the
currently-running block before the result lands.
:form-result One block finished evaluating. Carries
:position, :code, :result/:error,
and :envelope timestamps. The tracker writes
the completed form record into :forms at the
chunk's display index. Chunks tagged :silent?
(or returning :vis/silent) keep their :silent?
flag so channels can toggle visibility.
:iteration-final Iteration is complete. Carries :final (nil
when the turn isn't done yet) and :done?
(true when this iteration produced the
turn-terminal answer). The block chunk has
already streamed; this is the trim
"iteration done" marker.
:iteration-error Iteration aborted before forms could run
(e.g. LLM call failed). Carries :thinking
and :error.
:provider-retry-reset
Provider stream failed before code eval and Vis is
retrying the provider call. Clears stale live
reasoning/content for this attempt and keeps a retry
recap in :provider-fallbacks.
Public API:
(make-progress-tracker) - fresh tracker, no callback
(make-progress-tracker {:on-update}) - invokes (on-update timeline chunk)
on every chunk
Returns {:on-chunk fn :get-timeline fn}. Pass the :on-chunk fn
under :hooks {:on-chunk ...} of sessions/send!. Each timeline
entry has the shape:
{:iteration N :thinking str-or-nil :forms [{:code str :comment str-or-nil :render-segments [{:kind ...} ...] ;; source classification :result-render str-or-IR-or-nil ;; pre-rendered tool result :result-kind :tool|:value|:error :result-detail map-or-nil ;; tool metadata :error map-or-nil :duration-ms int :success? bool :silent? bool :started-at-ms int-or-nil} ...] :provider-fallbacks [map ...] ;; routed provider fallback notices :activity nil-or-keyword ;; live coarse phase (:provider-call/:response-parse) :elided-form-idxs #{int ...} ;; original loop indices hidden from :forms :error nil-or-iteration-error :final nil-or-{:answer :iteration-count :status} :done? bool}
The pre-existing :events interleaving log was removed: it lived
only in memory (never persisted), and resumed bubbles re-render
from this single flat layout. One layout path is enough.
Streaming progress tracker - leaf module.
Channels (TUI, CLI agent, Telegram) consume the iteration loop's
PHASED chunks via this tracker. Every chunk carries a `:phase`
keyword that tells the tracker what to do with it; the tracker
accumulates the chunks into a per-iteration timeline that the
channel re-renders incrementally.
Phases (every chunk has exactly one):
:reasoning LLM is streaming reasoning text. Updates the
iteration entry's `:thinking` field.
:form-start One block is about to evaluate. Carries
`:position` and `:code`. The tracker writes the
code immediately so channels can show the
currently-running block before the result lands.
:form-result One block finished evaluating. Carries
`:position`, `:code`, `:result`/`:error`,
and `:envelope` timestamps. The tracker writes
the completed form record into `:forms` at the
chunk's display index. Chunks tagged `:silent?`
(or returning `:vis/silent`) keep their `:silent?`
flag so channels can toggle visibility.
:iteration-final Iteration is complete. Carries `:final` (nil
when the turn isn't done yet) and `:done?`
(true when this iteration produced the
turn-terminal answer). The block chunk has
already streamed; this is the trim
"iteration done" marker.
:iteration-error Iteration aborted before forms could run
(e.g. LLM call failed). Carries `:thinking`
and `:error`.
:provider-retry-reset
Provider stream failed before code eval and Vis is
retrying the provider call. Clears stale live
reasoning/content for this attempt and keeps a retry
recap in `:provider-fallbacks`.
Public API:
`(make-progress-tracker)` - fresh tracker, no callback
`(make-progress-tracker {:on-update})` - invokes `(on-update timeline chunk)`
on every chunk
Returns `{:on-chunk fn :get-timeline fn}`. Pass the `:on-chunk` fn
under `:hooks {:on-chunk ...}` of `sessions/send!`. Each timeline
entry has the shape:
{:iteration N
:thinking str-or-nil
:forms [{:code str
:comment str-or-nil
:render-segments [{:kind ...} ...] ;; source classification
:result-render str-or-IR-or-nil ;; pre-rendered tool result
:result-kind :tool|:value|:error
:result-detail map-or-nil ;; tool metadata
:error map-or-nil
:duration-ms int
:success? bool
:silent? bool
:started-at-ms int-or-nil} ...]
:provider-fallbacks [map ...] ;; routed provider fallback notices
:activity nil-or-keyword ;; live coarse phase (:provider-call/:response-parse)
:elided-form-idxs #{int ...} ;; original loop indices hidden from :forms
:error nil-or-iteration-error
:final nil-or-{:answer :iteration-count :status}
:done? bool}
The pre-existing `:events` interleaving log was removed: it lived
only in memory (never persisted), and resumed bubbles re-render
from this single flat layout. One layout path is enough.Prompt assembly.
Provider messages are explicit blocks in send order: core system rules,
project instructions (AGENTS.md / CLAUDE.md when present), extension
fragments, current user message. Per-iteration user-role context is the
engine snapshot rendered as a Python dict (session) by the loop.
Prompt assembly. Provider messages are explicit blocks in send order: core system rules, project instructions (AGENTS.md / CLAUDE.md when present), extension fragments, current user message. Per-iteration user-role context is the engine snapshot rendered as a Python dict (`session`) by the loop.
File-based prompt templates — pi-style slash-expandable markdown prompts.
A template is a *.md file whose body becomes the user message when
the user types /<name> [args…]. Discovery, project wins over global:
<workspace>/.vis/prompts/*.md (project)~/.vis/prompts/*.md (user-global)Frontmatter is the same minimal --- fenced key: value block the
harness discovery reads: name (defaults to the filename stem) and
description. The body is the template.
Argument handling matches the common harness convention: when the
body contains $ARGUMENTS every occurrence is substituted with the
raw argument string (empty when none given); otherwise non-blank
args are appended after the body on their own paragraph.
Extensions can contribute DYNAMIC templates through
register-provider! — e.g. the harness extension exposes every
discovered skill as /skill:<name>. File templates win on a name
collision; among providers, registration order wins.
Dispatch: the engine consults expand ONLY for slash texts no
registered extension slash claimed (slash/dispatch returned
:reason :unknown), so real slash commands always win — same
precedence pi uses.
File-based prompt templates — pi-style slash-expandable markdown prompts. A template is a `*.md` file whose body becomes the user message when the user types `/<name> [args…]`. Discovery, project wins over global: 1. `<workspace>/.vis/prompts/*.md` (project) 2. `~/.vis/prompts/*.md` (user-global) Frontmatter is the same minimal `---` fenced `key: value` block the harness discovery reads: `name` (defaults to the filename stem) and `description`. The body is the template. Argument handling matches the common harness convention: when the body contains `$ARGUMENTS` every occurrence is substituted with the raw argument string (empty when none given); otherwise non-blank args are appended after the body on their own paragraph. Extensions can contribute DYNAMIC templates through `register-provider!` — e.g. the harness extension exposes every discovered skill as `/skill:<name>`. File templates win on a name collision; among providers, registration order wins. Dispatch: the engine consults `expand` ONLY for slash texts no registered extension slash claimed (`slash/dispatch` returned `:reason :unknown`), so real slash commands always win — same precedence pi uses.
Single source of truth for provider-error presentation.
Both the turn engine's answer IR (provider-error-ir, rendered by the
Web channel and the TUI's final-answer bubble) and the TUI's
per-iteration trace error rows derive their wording and facts from
THIS namespace, so a provider failure reads IDENTICALLY everywhere it
surfaces — no more divergent PROVIDER_ERROR HTTP 400 vs the polished
WHAT HAPPENED: banner.
err is the error map carried on a trace entry / ex-info:
{:message .. :data {:status .. :body .. :request-id ..} ..}. Every
helper tolerates the bare ex-info shape too (via ex-message).
Single source of truth for provider-error presentation.
Both the turn engine's answer IR (`provider-error-ir`, rendered by the
Web channel and the TUI's final-answer bubble) and the TUI's
per-iteration trace error rows derive their wording and facts from
THIS namespace, so a provider failure reads IDENTICALLY everywhere it
surfaces — no more divergent `PROVIDER_ERROR HTTP 400` vs the polished
`WHAT HAPPENED:` banner.
`err` is the error map carried on a trace entry / ex-info:
`{:message .. :data {:status .. :body .. :request-id ..} ..}`. Every
helper tolerates the bare ex-info shape too (via `ex-message`).Normalized provider limits surface.
Providers may optionally expose :provider/limits-fn in the global
registry. The function returns provider-specific limit/quota data;
this namespace wraps it in one validated envelope and augments it
with static provider metadata from svar's catalog (currently RPM /
TPM).
Goals:
Normalized provider limits surface. Providers may optionally expose `:provider/limits-fn` in the global registry. The function returns provider-specific limit/quota data; this namespace wraps it in one validated envelope and augments it with static provider metadata from svar's catalog (currently RPM / TPM). Goals: - one host-level shape for all providers, - explicit support for providers that only know static limits, - spec validation of every returned report, - graceful error envelopes instead of exploding the caller when a provider-specific implementation is absent or malformed.
Channel-neutral provider management service.
Everything a channel needs to render and mutate the provider fleet
— status probing, account limits, live model catalogs, presets, and
config persistence — WITHOUT any UI. Hoisted from the TUI extension
(channel_tui/provider.clj) so the web channel, Telegram, and any
future surface manage the SAME fleet through the SAME primitives;
the channels keep only their interaction layer (lanterna dialogs,
HTMX fragments, ...).
Auth is classified, not implemented, here: auth-kind tells a
channel whether a provider wants an API key, an interactive OAuth
flow (owned by the provider extension + channel), or nothing
(local). The registry's :provider/*-fn contract stays the single
integration point for provider extensions, so a provider extension
automatically works in every channel.
Channel-neutral provider management service. Everything a channel needs to render and mutate the provider fleet — status probing, account limits, live model catalogs, presets, and config persistence — WITHOUT any UI. Hoisted from the TUI extension (`channel_tui/provider.clj`) so the web channel, Telegram, and any future surface manage the SAME fleet through the SAME primitives; the channels keep only their interaction layer (lanterna dialogs, HTMX fragments, ...). Auth is classified, not implemented, here: `auth-kind` tells a channel whether a provider wants an API key, an interactive OAuth flow (owned by the provider extension + channel), or nothing (local). The registry's `:provider/*-fn` contract stays the single integration point for provider extensions, so a provider extension automatically works in every channel.
Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via the JDK FFM API, black-compatible) before it is shown. Used by the gateway's code renderer so the trace shows tidy, consistently-wrapped Python instead of the model's raw one-liners.
CACHED: ruff output is deterministic for a given input, and the same code block is rendered many times (pinned trace + live SSE re-emits + reconnect replay), so an LRU memo means each distinct block formats exactly once.
SAFE: ruff/format-or returns the source verbatim if ruff is unavailable
(e.g. the native lib isn't bundled in a particular build) or the code doesn't
parse — the original is never lost.
Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via the JDK FFM API, black-compatible) before it is shown. Used by the gateway's code renderer so the trace shows tidy, consistently-wrapped Python instead of the model's raw one-liners. CACHED: ruff output is deterministic for a given input, and the same code block is rendered many times (pinned trace + live SSE re-emits + reconnect replay), so an LRU memo means each distinct block formats exactly once. SAFE: `ruff/format-or` returns the source verbatim if ruff is unavailable (e.g. the native lib isn't bundled in a particular build) or the code doesn't parse — the original is never lost.
Project-local Python extensions — trusted-context plug-ins.
Vis extensions are normally Clojure libraries baked into the binary at
build time. This namespace adds a second, fully dynamic authoring path:
drop a *.py file into
~/.vis/extensions/ (global — every project) <project>/.vis/extensions/ (project-local — this project only)
and it loads at startup (and on /reload) in BOTH the JVM and the
GraalVM native-image build — Python redefinition is pure Truffle
dynamism, no runtime class definition involved.
Each file is evaluated in its own TRUSTED GraalPy context. This is NOT
the model's sandbox: the model's context is untrusted, per-session and
deny-by-default; extension contexts are user-trusted (same trust level
as a Clojure extension on the classpath), process-wide, and get real
filesystem / network / environment access. The two share nothing — the
model can call an extension TOOL (through the host wrapper, envelope-
checked like any tool) but can never evaluate code in the extension's
context. Host capabilities are reachable ONLY through the bound vis
API (no arbitrary Java interop: allowAllAccess stays false and no
host classes are exposed).
Every context is built on env-python/shared-engine — the ONE
process-wide Engine that makes context creation safe even while a
sandbox eval is running (see the deadlock notes there). Calls into an
extension (tool, activation, prompt, slash, op hook) are serialized
with locking on its context, the same proven pattern as the printer
context.
The file's top-level vis.extension(...) call registers through the
ordinary register-extension! — from the registry's perspective a
Python extension is indistinguishable from a Clojure one (activation,
prompt assembly, slash dispatch, vis extensions list all just work).
A file that fails to load becomes a load-failure warning (surfaced via
vis doctor), never a crash.
Project-local Python extensions — trusted-context plug-ins. Vis extensions are normally Clojure libraries baked into the binary at build time. This namespace adds a second, fully dynamic authoring path: drop a `*.py` file into ~/.vis/extensions/ (global — every project) <project>/.vis/extensions/ (project-local — this project only) and it loads at startup (and on `/reload`) in BOTH the JVM and the GraalVM native-image build — Python redefinition is pure Truffle dynamism, no runtime class definition involved. Each file is evaluated in its own TRUSTED GraalPy context. This is NOT the model's sandbox: the model's context is untrusted, per-session and deny-by-default; extension contexts are user-trusted (same trust level as a Clojure extension on the classpath), process-wide, and get real filesystem / network / environment access. The two share nothing — the model can call an extension TOOL (through the host wrapper, envelope- checked like any tool) but can never evaluate code in the extension's context. Host capabilities are reachable ONLY through the bound `vis` API (no arbitrary Java interop: `allowAllAccess` stays false and no host classes are exposed). Every context is built on `env-python/shared-engine` — the ONE process-wide Engine that makes context creation safe even while a sandbox eval is running (see the deadlock notes there). Calls into an extension (tool, activation, prompt, slash, op hook) are serialized with `locking` on its context, the same proven pattern as the printer context. The file's top-level `vis.extension(...)` call registers through the ordinary `register-extension!` — from the registry's perspective a Python extension is indistinguishable from a Clojure one (activation, prompt assembly, slash dispatch, `vis extensions list` all just work). A file that fails to load becomes a load-failure warning (surfaced via `vis doctor`), never a crash.
Runs an extension author's Python tests (test_*.py / *_test.py) through
the built-in pytest-compat shim, each in its own TRUSTED GraalPy context
(same trust level as the extension it covers). Tests import the extension's
own package through the SAME sys.path sugar the loader gives extension.py,
so an author ships real Python tests next to the code and runs them with the
project's own tooling. Pure Python end to end — the shim is stdlib-only, no
host bridge, no pip.
Split out of python-extensions (which owns loading/registration) so the
runner is a single, testable responsibility. It depends on that namespace's
trusted-context builder; the reverse wiring (/test slash + vis ext test
CLI) is resolved lazily there to avoid a require cycle.
The source of truth for the outcome is the shim's PER-TEST record list (nodeid, outcome, message). Counts and pass/fail are DERIVED from those records on the host side — never a separate tally that could drift, and never scraped from stdout.
Runs an extension author's Python tests (`test_*.py` / `*_test.py`) through the built-in `pytest`-compat shim, each in its own TRUSTED GraalPy context (same trust level as the extension it covers). Tests import the extension's own package through the SAME `sys.path` sugar the loader gives `extension.py`, so an author ships real Python tests next to the code and runs them with the project's own tooling. Pure Python end to end — the shim is stdlib-only, no host bridge, no pip. Split out of `python-extensions` (which owns loading/registration) so the runner is a single, testable responsibility. It depends on that namespace's trusted-context builder; the reverse wiring (`/test` slash + `vis ext test` CLI) is resolved lazily there to avoid a require cycle. The source of truth for the outcome is the shim's PER-TEST record list (nodeid, outcome, message). Counts and pass/fail are DERIVED from those records on the host side — never a separate tally that could drift, and never scraped from stdout.
Three global registries in one place: channels, providers, commands.
Each is a small, self-contained piece - keyword id, a spec for the descriptor map, a process-level atom holding the registered entries, and a handful of fns for register / deregister / lookup. Putting them together means one file owns every "this is the canonical shape of an extension contribution" decision and one file owns every place an extension's contribution lands at runtime.
Channel registry (:channel/id keyword):
channel build + validate a descriptor
register-channel! register, idempotent on :channel/id
deregister-channel! remove by id
registered-channels all entries, vec
channel-by-id lookup by id
by-cmd lookup by :channel/cmd
Provider registry (:provider/id keyword):
provider build + validate a descriptor
register-provider! register, idempotent on :provider/id
deregister-provider! remove by id
registered-providers all entries, vec
provider-by-id lookup by id
Command registry ([:cmd/parent :cmd/name] tuple key):
command build + validate a descriptor
resolve-subcommands static vec or dynamic 0-arg fn -> vec
register-cmd! register, idempotent on [parent name]
deregister-cmd! remove by [parent name]
registered-commands all entries, vec (registration order)
registered-under filter by parent path
Channel mounting:
channel-subcommands compose vis channels subcommand vec
from the channel registry + any commands
registered with :cmd/parent ["channels"].
Loading this ns also registers the
vis channels parent itself.
Specs for keyword fields (:channel/id, :provider/id, :cmd/name,
..., plus the descriptor specs ::channel, ::provider, ::command,
::arg) live here too - the spec IS the registry's contract.
Parsing / help rendering / dispatch utilities live in
com.blockether.vis.internal.commandline. Classpath manifest
scanning lives in com.blockether.vis.internal.manifest; the
extension layer that wraps it (and re-exports discover-extensions!)
lives in com.blockether.vis.internal.extension.
Three global registries in one place: channels, providers, commands.
Each is a small, self-contained piece - keyword id, a spec for the
descriptor map, a process-level atom holding the registered entries,
and a handful of fns for register / deregister / lookup. Putting
them together means one file owns every "this is the canonical
shape of an extension contribution" decision and one file owns
every place an extension's contribution lands at runtime.
Channel registry (`:channel/id` keyword):
channel build + validate a descriptor
register-channel! register, idempotent on :channel/id
deregister-channel! remove by id
registered-channels all entries, vec
channel-by-id lookup by id
by-cmd lookup by :channel/cmd
Provider registry (`:provider/id` keyword):
provider build + validate a descriptor
register-provider! register, idempotent on :provider/id
deregister-provider! remove by id
registered-providers all entries, vec
provider-by-id lookup by id
Command registry (`[:cmd/parent :cmd/name]` tuple key):
command build + validate a descriptor
resolve-subcommands static vec or dynamic 0-arg fn -> vec
register-cmd! register, idempotent on [parent name]
deregister-cmd! remove by [parent name]
registered-commands all entries, vec (registration order)
registered-under filter by parent path
Channel mounting:
channel-subcommands compose `vis channels` subcommand vec
from the channel registry + any commands
registered with `:cmd/parent ["channels"]`.
Loading this ns also registers the
`vis channels` parent itself.
Specs for keyword fields (`:channel/id`, `:provider/id`, `:cmd/name`,
..., plus the descriptor specs `::channel`, `::provider`, `::command`,
`::arg`) live here too - the spec IS the registry's contract.
Parsing / help rendering / dispatch utilities live in
`com.blockether.vis.internal.commandline`. Classpath manifest
scanning lives in `com.blockether.vis.internal.manifest`; the
extension layer that wraps it (and re-exports `discover-extensions!`)
lives in `com.blockether.vis.internal.extension`.Vis answer IR — Hiccup-EDN, MDAST-equivalent, with strict canonical
form post-->ast.
Public surface: (->ast input) ; soft-normalize any input → [:ir & blocks] (render input flavor opts) ; one of :html :markdown :plain (extract-code ast) ; for vis --code (extract-text ast) ; for voice TTS (session->markdown db session-ref opts?)
Tags: ROOT :ir BLOCKS (11) :p :h{:level 1-6} :code{:lang} :ul :ol{:start} :li :quote :table :tr :th :td INLINES (11) :span{:preserve-ws? :nowrap?} :br :strong :em :c :a{:href} :img{:src :alt} :kbd :mark :sup :sub
Disclosure/collapsible answer blocks are intentionally unsupported:
no :details, no :summary, no HTML <details>/<summary> in answer IR.
─── Canonical form (invariant after ->ast) ───────────────────────────────
:ir children are exclusively block nodes.:span body (single string, no '\n').:code, :c, :kbd (single string, ws preserved).
Anywhere else, vector children only — no bare strings in the tree.[:br {}] nodes." \n continuation").:li children are either all blocks OR a single :p wrapping the
inline run.:ul/:ol children are exclusively :li.:table children are :tr; :tr children are :th/:td;
:th/:td children are inline.─── Coercion rules at the boundary ─────────────────────────────────────────
->ast is total and pure. Accepted inputs:
[:ir ...] — re-canonicalized (idempotent)
[:tag ...] (Hiccup, non-:ir) — wrapped in [:ir <node>]
"text" — wrapped in [:ir [:p [:span text]]]
sequential / vector of mixed — element-by-element coercion
anything else — surfaced as [:code {:lang "edn"} pr-str]
Vis answer IR — Hiccup-EDN, MDAST-equivalent, with strict canonical
form post-`->ast`.
Public surface:
(->ast input) ; soft-normalize any input → [:ir & blocks]
(render input flavor opts) ; one of :html :markdown :plain
(extract-code ast) ; for vis --code
(extract-text ast) ; for voice TTS
(session->markdown db session-ref opts?)
Tags:
ROOT :ir
BLOCKS (11) :p :h{:level 1-6} :code{:lang} :ul :ol{:start} :li
:quote :table :tr :th :td
INLINES (11) :span{:preserve-ws? :nowrap?} :br
:strong :em :c :a{:href}
:img{:src :alt} :kbd :mark :sup :sub
Disclosure/collapsible answer blocks are intentionally unsupported:
no `:details`, no `:summary`, no HTML `<details>/<summary>` in answer IR.
─── Canonical form (invariant after `->ast`) ───────────────────────────────
1. Every vector node has its attrs map at index 1 ({} when absent).
2. `:ir` children are exclusively block nodes.
3. Text lives ONLY in:
- `:span` body (single string, no '\n').
- raw bodies of `:code`, `:c`, `:kbd` (single string, ws preserved).
Anywhere else, vector children only — no bare strings in the tree.
4. Hard line breaks are explicit `[:br {}]` nodes.
5. Soft breaks (any '\n' inside a non-preserve-ws string) are collapsed
to a single space during canonicalization. This is the structural fix
for LLM output that emits cosmetic mid-paragraph indentation
(e.g. `" \n continuation"`).
6. `:li` children are either all blocks OR a single `:p` wrapping the
inline run.
7. `:ul`/`:ol` children are exclusively `:li`.
8. `:table` children are `:tr`; `:tr` children are `:th`/`:td`;
`:th`/`:td` children are inline.
─── Coercion rules at the boundary ─────────────────────────────────────────
`->ast` is total and pure. Accepted inputs:
[:ir ...] — re-canonicalized (idempotent)
[:tag ...] (Hiccup, non-:ir) — wrapped in [:ir <node>]
"text" — wrapped in [:ir [:p [:span text]]]
sequential / vector of mixed — element-by-element coercion
anything else — surfaced as [:code {:lang "edn"} pr-str]Canonical registry of VIS-MANAGED STATEFUL RESOURCES — the one interface every long-lived thing vis spawns (an nREPL, a daemon, a background shell, a file watch, a capture) registers under, so a single definition drives THREE spouts:
:session/resources in ctx + resource_stop/resource_restartSESSION-SCOPED. The registry is partitioned by session-id: a resource one
session registers is INVISIBLE to every other session, and ids only need to
be unique WITHIN a session (the (session, id) pair is the global key). Every
public verb takes the owning session as its first arg; the per-session
sandbox tools + ctx are closed over that id, so the agent in session A can
neither see nor stop session B's resources.
B-DISPATCH model. A resource is split into:
~/.vis/resources.edn so a
resource survives a vis restart (display + pid re-attach).:stop-fn :restart-fn :alive-fn.
Never serialized. Across a restart the OWNER re-registers them (e.g. the
Clojure pack re-attaches its nREPLs by pid on init) — exactly the pattern
repl-manager already uses, lifted here so EVERY kind gets it.:kind is OPEN-ENDED — a bare keyword, no closed enum. The registry never
switches on it; only owners do.
ctx stays PURE DATA: it advertises can_stop/can_restart but never carries a
callable. Killing goes through stop!/restart! (by session + id) — the
single path the agent tool AND the footer both call. id IS the binding.
Canonical registry of VIS-MANAGED STATEFUL RESOURCES — the one interface every
long-lived thing vis spawns (an nREPL, a daemon, a background shell, a file
watch, a capture) registers under, so a single definition drives THREE spouts:
1. the agent — `:session/resources` in ctx + `resource_stop`/`resource_restart`
2. the footer/TUI — a live count + a dialog that stops/restarts by id
3. the engine — teardown on shutdown
SESSION-SCOPED. The registry is partitioned by `session-id`: a resource one
session registers is INVISIBLE to every other session, and `id`s only need to
be unique WITHIN a session (the `(session, id)` pair is the global key). Every
public verb takes the owning `session` as its first arg; the per-session
sandbox tools + ctx are closed over that id, so the agent in session A can
neither see nor stop session B's resources.
B-DISPATCH model. A resource is split into:
- DATA (serializable, STRING-KEYED): id, kind, label, status, detail,
pid, owner, session, can_stop, can_restart, created_at. This is
what ctx carries (crossing the Python boundary as session[resources]),
what the footer renders, and what persists to `~/.vis/resources.edn` so a
resource survives a vis restart (display + pid re-attach).
- LIFECYCLE THUNKS (live, in-memory only): `:stop-fn :restart-fn :alive-fn`.
Never serialized. Across a restart the OWNER re-registers them (e.g. the
Clojure pack re-attaches its nREPLs by pid on init) — exactly the pattern
`repl-manager` already uses, lifted here so EVERY kind gets it.
`:kind` is OPEN-ENDED — a bare keyword, no closed enum. The registry never
switches on it; only owners do.
ctx stays PURE DATA: it advertises `can_stop`/`can_restart` but never carries a
callable. Killing goes through `stop!`/`restart!` (by session + id) — the
single path the agent tool AND the footer both call. `id` IS the binding.Per-eval / per-call runtime knobs for the loop: Python-sandbox eval timeouts
(with clamping and a shell-timeout-aware widener), the svar/ask-code! stream
watchdog defaults, and the dynamic vars the loop binds per call.
A LEAF — depends on nothing else in the engine, so the loop and its tests read these settings from one place instead of carrying them in the loop namespace.
Per-eval / per-call runtime knobs for the loop: Python-sandbox eval timeouts (with clamping and a shell-timeout-aware widener), the `svar/ask-code!` stream watchdog defaults, and the dynamic vars the loop binds per call. A LEAF — depends on nothing else in the engine, so the loop and its tests read these settings from one place instead of carrying them in the loop namespace.
A GraalPy FileSystem that gives the Python sandbox REAL filesystem access
CONFINED to the session's filesystem roots.
Security model — every path-accessing operation canonicalizes its arguments and refuses anything that does not resolve UNDER a current filesystem root:
.. traversal is defeated by normalize.roots-fn on every check, so
/fs add|remove takes effect immediately.GraalPy's own stdlib / internal resources live OUTSIDE the roots, so the
confined FS is wrapped with allowLanguageHomeAccess +
allowInternalResourceAccess (read-only access to the language home and
bundled resources) before it reaches the Context.
OUTBOX tap — an optional engine-managed capture directory ($VIS_OUTBOX,
distinct from the user /fs roots): the sandbox may WRITE there and every
file it closes is handed to on-close so the engine can persist it as a
session_iteration_attachment (the implicit twin of vis_attach). Reads,
and writes anywhere else, are untouched.
Empty/zero roots ⇒ DENY everything (fail closed).
A GraalPy `FileSystem` that gives the Python sandbox REAL filesystem access
CONFINED to the session's filesystem roots.
Security model — every path-accessing operation canonicalizes its arguments
and refuses anything that does not resolve UNDER a current filesystem root:
- `..` traversal is defeated by `normalize`.
- symlink escapes are defeated by resolving the path through the REAL path
of its nearest existing ancestor (so a symlink inside a root that points
outside is rejected, and a symlink whose target is inside is allowed).
- the root set is read LIVE via `roots-fn` on every check, so
`/fs add|remove` takes effect immediately.
GraalPy's own stdlib / internal resources live OUTSIDE the roots, so the
confined FS is wrapped with `allowLanguageHomeAccess` +
`allowInternalResourceAccess` (read-only access to the language home and
bundled resources) before it reaches the Context.
OUTBOX tap — an optional engine-managed capture directory (`$VIS_OUTBOX`,
distinct from the user `/fs` roots): the sandbox may WRITE there and every
file it closes is handed to `on-close` so the engine can persist it as a
`session_iteration_attachment` (the implicit twin of `vis_attach`). Reads,
and writes anywhere else, are untouched.
Empty/zero roots ⇒ DENY everything (fail closed).Persistent, channel-NEUTRAL per-session model preference.
ONE source of truth — session_soul.llm_pref_provider + llm_pref_model
in the DB — for every channel (web gateway + TUI), so a session routes
through the same PROVIDER + MODEL wherever it's opened and the choice
survives restarts. Provider + model (not just a model name) mirrors how a
turn records its route and disambiguates a model name shared by >1 provider.
The engine reads it at turn start (prepare-turn-context in loop.clj) as
the default route when the caller passes none; router-for-model hoists the
chosen model (the provider follows, since it's the one carrying that model).
DEBOUNCED WRITE-BACK: set-model! updates an in-memory value IMMEDIATELY
(footer + engine see it at once) and coalesces the DB write, so cycling the
model (TUI Ctrl+T) many times in a row produces a SINGLE write. Reads prefer
the pending in-memory value, falling back to the DB.
Values are {:provider <id-string-or-nil> :model <name>} or nil. Keyed by
the session-soul id (the gateway's sid and the engine env's :session-id).
Persistent, channel-NEUTRAL per-session model preference.
ONE source of truth — `session_soul.llm_pref_provider` + `llm_pref_model`
in the DB — for every channel (web gateway + TUI), so a session routes
through the same PROVIDER + MODEL wherever it's opened and the choice
survives restarts. Provider + model (not just a model name) mirrors how a
turn records its route and disambiguates a model name shared by >1 provider.
The engine reads it at turn start (`prepare-turn-context` in loop.clj) as
the default route when the caller passes none; `router-for-model` hoists the
chosen model (the provider follows, since it's the one carrying that model).
DEBOUNCED WRITE-BACK: `set-model!` updates an in-memory value IMMEDIATELY
(footer + engine see it at once) and coalesces the DB write, so cycling the
model (TUI Ctrl+T) many times in a row produces a SINGLE write. Reads prefer
the pending in-memory value, falling back to the DB.
Values are `{:provider <id-string-or-nil> :model <name>}` or nil. Keyed by
the session-soul id (the gateway's `sid` and the engine env's `:session-id`).Channel-agnostic slash dispatch.
Slashes are DECLARATIVE: every extension carries :ext/slash-commands
on its manifest; the engine derives the active slash set by walking
(active-extensions environment) at lookup time. NO global atom, NO
register-slash! imperative call.
Public surface (re-exported through core.clj):
(active-slashes env) -> vec of slash specs
(slash-by-path env path) -> slash spec or nil
(slash-children env parent) -> vec of slash specs whose
:slash/parent = parent
(parse text) -> {:path :args :raw} | nil
(raw tokenisation only;
does NOT consult any registry)
(dispatch env ctx text) -> envelope (see below)
The dispatch envelope is the contract every channel renders against:
{:handled? true :result <slash result map> :path path} {:handled? true :error msg :reason :unknown :tokens tokens} {:handled? true :error msg :reason :requires-failed :missing #{} :path} {:handled? true :error msg :reason :unavailable :path} {:handled? true :error msg :reason :no-run-fn :path} {:handled? true :error msg :reason :run-failed :ex t :path} {:handled? false} -- text was not a slash; channel forwards to LLM.
A slash text is any non-blank string starting with / followed by
at least one word. Plain prose without the leading / is ALWAYS
{:handled? false}.
Slash run-fns may return an EXTENDED :slash/* envelope that carries
a rendered result card back to the channel:
{:slash/status :ok | :error | :nothing-to-commit | :ff-failed :slash/title short headline (string, plain) :slash/body IR (vector starting with :ir ...) OR Markdown string :slash/actions [{:label :slash}] ;; optional follow-ups :slash/data arbitrary payload (workspace-id, sha, ...)}
Channel-agnostic slash dispatch.
Slashes are DECLARATIVE: every extension carries `:ext/slash-commands`
on its manifest; the engine derives the active slash set by walking
`(active-extensions environment)` at lookup time. NO global atom, NO
`register-slash!` imperative call.
Public surface (re-exported through `core.clj`):
(active-slashes env) -> vec of slash specs
(slash-by-path env path) -> slash spec or nil
(slash-children env parent) -> vec of slash specs whose
`:slash/parent` = parent
(parse text) -> {:path :args :raw} | nil
(raw tokenisation only;
does NOT consult any registry)
(dispatch env ctx text) -> envelope (see below)
The dispatch envelope is the contract every channel renders against:
{:handled? true :result <slash result map> :path path}
{:handled? true :error msg :reason :unknown :tokens tokens}
{:handled? true :error msg :reason :requires-failed :missing #{} :path}
{:handled? true :error msg :reason :unavailable :path}
{:handled? true :error msg :reason :no-run-fn :path}
{:handled? true :error msg :reason :run-failed :ex t :path}
{:handled? false} -- text was not a slash; channel forwards to LLM.
A slash text is any non-blank string starting with `/` followed by
at least one word. Plain prose without the leading `/` is ALWAYS
{:handled? false}.
Slash run-fns may return an EXTENDED `:slash/*` envelope that carries
a rendered result card back to the channel:
{:slash/status :ok | :error | :nothing-to-commit | :ff-failed
:slash/title short headline (string, plain)
:slash/body IR (vector starting with :ir ...) OR Markdown string
:slash/actions [{:label :slash}] ;; optional follow-ups
:slash/data arbitrary payload (workspace-id, sha, ...)}Shared tiny string helpers. A dependency-free leaf so any namespace can use it without risking a cycle.
Shared tiny string helpers. A dependency-free leaf so any namespace can use it without risking a cycle.
Language-neutral test-runner CONTRACT shared across vis language packs.
ONE vocabulary for selecting and reporting tests, modeled on lazytest's CLI (NoahTheDuke/lazytest): single test, many namespaces, ignore by name or by metadata tag. A future python / js language pack returns the SAME shaped result map and accepts the SAME selector keys, so the agent learns the words once and they carry across languages.
The selector vocabulary and the result shape are DEFINED with clojure.spec
(::selectors, ::result). selector-keys / result-keys are DERIVED from
those specs (via s/form) so the spec is the single source of truth - a key
never drifts out of sync with its documentation.
SELECTOR keys (all optional; the Python dict the tool receives): :ns string OR vector of namespace strings - which namespace(s) to run. One string = a single ns; a vector = many. (lazytest -n) :only vector of test-name strings - run ONLY these tests/vars within the selected namespace(s). (lazytest -v / source :focus) :include vector of metadata-tag strings - run only tests carrying one of these tags, e.g. "integration". (lazytest -i) :exclude vector of metadata-tag strings - skip tests carrying one of these tags, e.g. "slow". (lazytest -e)
PRECEDENCE (copied verbatim from lazytest):
RESULT keys (the uniform map every pack returns): :language "clojure" | "python" | ... :mode "repl" | "cli" - which execution path ran :framework "clojure.test" | "lazytest" | ... (repl path) :tool "clj" | "lein" | "bb" | ... (cli path) :ns the namespace(s) run :total test count actually run :pass passing count :fail failing + erroring count :selected count chosen by the selectors (before skips) :skipped count filtered out by :exclude / source :skip :failures [{:ns :test :message :file :line} ...] :errors the erroring-test subset of :failures :output captured run log (framework report + error/exception traces)
Language-neutral test-runner CONTRACT shared across vis language packs.
ONE vocabulary for selecting and reporting tests, modeled on lazytest's CLI
(NoahTheDuke/lazytest): single test, many namespaces, ignore by name or by
metadata tag. A future python / js language pack returns the SAME shaped
result map and accepts the SAME selector keys, so the agent learns the
words once and they carry across languages.
The selector vocabulary and the result shape are DEFINED with clojure.spec
(`::selectors`, `::result`). `selector-keys` / `result-keys` are DERIVED from
those specs (via `s/form`) so the spec is the single source of truth - a key
never drifts out of sync with its documentation.
SELECTOR keys (all optional; the Python dict the tool receives):
:ns string OR vector of namespace strings - which namespace(s) to
run. One string = a single ns; a vector = many. (lazytest -n)
:only vector of test-name strings - run ONLY these tests/vars within
the selected namespace(s). (lazytest -v / source :focus)
:include vector of metadata-tag strings - run only tests carrying one of
these tags, e.g. "integration". (lazytest -i)
:exclude vector of metadata-tag strings - skip tests carrying one of
these tags, e.g. "slow". (lazytest -e)
PRECEDENCE (copied verbatim from lazytest):
- :exclude OVERRIDES :include (a test tagged both is skipped).
- source-level :skip OVERRIDES :focus.
RESULT keys (the uniform map every pack returns):
:language "clojure" | "python" | ...
:mode "repl" | "cli" - which execution path ran
:framework "clojure.test" | "lazytest" | ... (repl path)
:tool "clj" | "lein" | "bb" | ... (cli path)
:ns the namespace(s) run
:total test count actually run
:pass passing count
:fail failing + erroring count
:selected count chosen by the selectors (before skips)
:skipped count filtered out by :exclude / source :skip
:failures [{:ns :test :message :file :line} ...]
:errors the erroring-test subset of :failures
:output captured run log (framework report + error/exception traces)Internal, channel-agnostic Vis theme data.
Keep this namespace pure data: no Lanterna, Swing, browser, or terminal backend imports. Channels adapt these tokens to their own render types.
Internal, channel-agnostic Vis theme data. Keep this namespace pure data: no Lanterna, Swing, browser, or terminal backend imports. Channels adapt these tokens to their own render types.
Session-title subsystem, lifted out of the loop namespace: the three listener
registries (per-session value, global, and the pending/spinner channel), the
single set-title-with-broadcast! mutation point, and the async auto-title
side-channel (an off-surface ask! that names a session on its first
real turn). A LEAF — depends only on persistance + svar + runtime-settings,
never back on the loop.
Session-title subsystem, lifted out of the loop namespace: the three listener registries (per-session value, global, and the pending/spinner channel), the single `set-title-with-broadcast!` mutation point, and the async auto-title side-channel (an off-surface `ask!` that names a session on its first real turn). A LEAF — depends only on persistance + svar + runtime-settings, never back on the loop.
Process-wide feature-toggle registry.
Replaces the two parallel toggle plumbings we had drifting apart:
:show-thinking, :show-iterations,
:show-silent) wired into state/default-settings,if (some-flag …) … else … checks scattered through
internal/render.clj and channel-tui's render layer.A toggle has stable metadata (id, label, description, default,
owner) and a current ON/OFF value. Anyone — internal modules,
extensions, channels — registers their toggles into the same
registry; any caller flips a toggle through the same set!. The
TUI settings dialog walks the registry to render the list, so
adding a new toggle from an extension shows up in the user's UI
without any TUI patch.
Persistence is opt-in: register-toggle! accepts :persist? true
and on set! the wrapper file writes {:toggles {id bool}} into
~/.vis/config.edn via vis.config/save-config!. Hydration
happens at process start (call hydrate-from-config! once after
config/load-config-raw).
Contract:
:vis/show-thinking,
:foundation-git/auto-commit, ...).enabled? is cheap (single atom deref + keyword lookup),
called per-paint per-row by the render layer; do not turn it
into a function-call indirection.state is left alone so a
user override survives a reload.Process-wide feature-toggle registry.
Replaces the two parallel toggle plumbings we had drifting apart:
- hard-coded TUI booleans (`:show-thinking`, `:show-iterations`,
`:show-silent`) wired into `state/default-settings`,
- per-render `if (some-flag …) … else …` checks scattered through
`internal/render.clj` and channel-tui's render layer.
A toggle has stable metadata (`id`, label, description, default,
owner) and a current ON/OFF value. Anyone — internal modules,
extensions, channels — registers their toggles into the same
registry; any caller flips a toggle through the same `set!`. The
TUI settings dialog walks the registry to render the list, so
adding a new toggle from an extension shows up in the user's UI
without any TUI patch.
Persistence is opt-in: `register-toggle!` accepts `:persist? true`
and on `set!` the wrapper file writes `{:toggles {id bool}}` into
`~/.vis/config.edn` via `vis.config/save-config!`. Hydration
happens at process start (call `hydrate-from-config!` once after
`config/load-config-raw`).
Contract:
- Toggle ids are namespaced keywords (`:vis/show-thinking`,
`:foundation-git/auto-commit`, ...).
- `enabled?` is cheap (single atom deref + keyword lookup),
called per-paint per-row by the render layer; do not turn it
into a function-call indirection.
- Defaults are immutable once registered. Re-registering the
same id is allowed (idempotent boot path) and merges over
prior metadata; the live VALUE in `state` is left alone so a
user override survives a reload.Backend-neutral workspaces, DB-pinned to session_state 1:1.
The user's real cwd is trunk — Vis never mutates it, and Vis no
longer requires it to be a git repo. A session works in trunk by
default; /draft new opts into an isolated workspace supplied by a
registered backend. Backends declare concrete capabilities such as
isolated fork, rollback, merge-back, retained revisions, and parallel
safety. Core never assumes which implementation provides them.
'What changed since the fork' is computed git-free: clonefile
preserves source mtimes, so files the agent touches in the clone get
a fresh mtime greater than the fork timestamp we capture at clone
time. apply! lands exactly those files back into cwd, uncommitted,
and leaves the user to commit with their own tools — Vis owns no
git/branch/commit/merge lifecycle whatsoever.
Vis never mutates JVM user.dir. Channels rebind workspace-root per
turn from the active workspace; tools resolve paths via
(workspace/cwd). There is NO process-cwd fallback in production -
the env carries :workspace/root from create-environment onward.
Backend-neutral workspaces, DB-pinned to session_state 1:1. The user's real cwd is *trunk* — Vis never mutates it, and Vis no longer requires it to be a git repo. A session works in trunk by default; `/draft new` opts into an isolated workspace supplied by a registered backend. Backends declare concrete capabilities such as isolated fork, rollback, merge-back, retained revisions, and parallel safety. Core never assumes which implementation provides them. 'What changed since the fork' is computed git-free: `clonefile` preserves source mtimes, so files the agent touches in the clone get a fresh mtime greater than the fork timestamp we capture at clone time. `apply!` lands exactly those files back into cwd, uncommitted, and leaves the user to commit with their own tools — Vis owns no git/branch/commit/merge lifecycle whatsoever. Vis never mutates JVM user.dir. Channels rebind *workspace-root* per turn from the active workspace; tools resolve paths via (workspace/cwd). There is NO process-cwd fallback in production - the env carries `:workspace/root` from `create-environment` onward.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |