Commit Graph
106 Commits
Author SHA1 Message Date
vhaudiquet 95e2fcfd57 ci: port the rust, chart, docker, web and edge-publish gates from the rhema repository
android / debug (push) Successful in 2m20s
ci / checks (push) Successful in 2m1s
ci / test (push) Successful in 57s
ci / chart (push) Successful in 6s
ci / docker (push) Successful in 1m44s
ci / web (push) Successful in 13s
android / release (push) Successful in 2m7s
ci / publish-edge-images (deploy/docker/claw.Dockerfile, rhema-claw) (push) Successful in 1m25s
ci / publish-edge-images (deploy/docker/ui.Dockerfile, rhema-claw-ui) (push) Successful in 10s
ci / publish-edge-chart (push) Successful in 3s
The android workflow came over with the tree; the rest of the old
repo's CI was framework-shaped. What the claw needs is the rust
gate (fmt, clippy, release build with the binary artifact, offline
tests), the chart lint/render suite, the daemon image smoke test,
the web bundle, and the edge + release publishing pair (latest/sha
images on main, exact-version images and chart on a v* tag).

Dropped on the way: the crates.io publish-order and claw-boundary
checks (nothing here publishes to crates.io; the boundary is
enforced by the tree layout now), the cloudflare wasm gate, the
binary release legs, and the manifest/crates.io release jobs — all
framework concerns that stayed in the rhema repository.

Release tags are checked against the workspace version by a
verify-tag job that the image legs wait on.
2026-11-12 19:29:32 +01:00
vhaudiquet 12ba97c8f1 build: resolve the image manifests from crates.io
The Dockerfiles copied every framework crate's manifest because the
old root workspace path-patched crossterm and carried in-tree
resolution. The claw resolves from crates.io now, so the builder
copies only the claw's own manifests, and the vendored-crossterm
COPY (and its explanation) is gone.
2026-11-12 17:43:40 +01:00
vhaudiquet 552a76ba99 build: the claw stands alone
The claw product (the daemon, the feed integrations, the client
tools, and the web and android trees) extracted from the framework
repository, keeping its history. The framework crates the product
pulls are all on crates.io at 0.2.3, so the in-tree path
dependencies became versions and the workspace resolves without the
framework checkout.
2026-11-12 17:43:08 +01:00
vhaudiquet 36a79ced0e build: take mcp-to-cli from crates.io
The crate now lives in its own repository and publishes on its own
line, so rhema-mcp depends on 0.1.0 instead of the path. The publish
list drops it — the check only follows in-tree edges — and the two
Dockerfiles stop copying a manifest that no longer exists.
2026-11-12 13:23:54 +01:00
vhaudiquet 3efafd9c63 chore: bump the workspace to 0.2.3 2026-11-12 11:03:29 +01:00
kosmos b3bb026fab fix(web): the session inventory lists only the keys the chat can open
The sidebar grouped everything that was not `interactive:` under
"other keys", minus the phone's status keys. That included the coding
jobs' `job:<instance>#<id>` keys and forge keys — and those are dead
ends: the message, event and history routes answer only for the
resident prefixes (`is_resident_key`), so opening one 400s and the row
sits there looking busy with nothing inside. A finished job's key was
worse than useless while its dispatch spun: `reacquire`, forever.

`isResidentKey` names the rule once, beside `isInteractive` —
`interactive:`, `task:`, `calendar:` — and the inventory filters on it.
That leaves the phone's keys out as before, keeps scheduled tasks
(their turns have real transcripts), and stops offering keys the chat
surface cannot address. The shell's `nextActiveKey` follows the same
rule: after a delete the chat moves to the next key it can open, or to
none. The full list stays on the wire — the API's inventory is the
dispatcher's, not the client's, and the delete route still answers for
any key with a record.

Consequence worth naming: a `job:` or forge key has no row now, so it
cannot be deleted from the sidebar either.
2026-11-11 23:52:54 +00:00
kosmos bb475856a0 fix(claw): a job dispatch drains its inbox, so its key goes idle
A finished coding job's key never settles. The handler takes its work
from the job table and never polls the inbox, so the item that opened
the dispatch stays there; `release` reads a non-empty inbox as work
that arrived mid-turn, `end_lease` sends the key to re-acquire with a
fresh debounce window, and `admit` hands out a lease when it lapses.
The re-dispatched handler finds the row done, answers `Handled`, and
the key re-acquires again: one lease and two record writes per
debounce window, per finished job, for the rest of the process's life.

Seen on a live daemon: `job:rhema-claw#1..#3` sat in `reacquire` with
`inbox_depth` 1, revisions climbing two every ~40s, hours after the
jobs were done — and every surface that reads the busy rule
(`inbox_depth == 0 && state != reacquire`) called them busy.

Every other handler polls: `interactive.rs` folds the batch into the
turn's input, `handler.rs` drains the forge news. This one has no news
to fold — a job key carries the row's own delivery — so it drains and
drops. The poll's other half matters too: it renews the lease, which a
coding turn running for minutes needs. The keys already spinning on a
running daemon settle by themselves on their next dispatch.

Test: a queued job runs to done through the source and the driver, and
the key it leaves behind is idle with an empty inbox and nothing due
on the next admit pass — the state a leftover item cannot produce.
2026-11-11 23:52:54 +00:00
vhaudiquet 3415e27463 feat(code): ask the request shape on the custom-endpoint login, and honor the stored choice everywhere
The one kind whose dialect its route cannot imply is the openai kind:
OpenAI itself serves the Responses API, an arbitrary OpenAI-compatible
server only promises chat completions. That choice was implicit; now
the user can make it.

Config: ProviderInstance gains api (Option<ProviderApi>, chat |
responses), serde snake_case, defaulting to absent — every config
written before the field reads as unset and keeps its route rule.

Registry: build() takes the stored choice as its fifth argument. An
explicit chat/responses beats the route rule for the openai kind;
unknown spellings fall back to the rule. A wiremock test proves the
lift: build(openai, override_url, responses) lands on /responses.

Frontends: the TUI's add-instance flow inserts a ChooseApi stage
between the base-URL and key prompts for the custom-endpoint row only
— arrows choose, enter records and descends, esc returns to the URL
prompt; chat completions leads the list so enter-without-looking keeps
what the route rule would have picked. The choice rides the pending
login into the backend, the saved instance, and back out through
from_profile. The ACP resolver honors the stored field via
stored_api(); claw's ProviderParams gains the same field (config-file
writable; its login forms do not ask yet). Every other kind's dialect
stays its endpoint's own decision.

New tests: the stage's default and esc contract, the explicit Responses
pick, the end-to-end landing in the saved instance, the config serde
round-trip both spellings plus old-file absence, and the registry's
precedence rule. fmt, clippy -D warnings, and all 135 test groups
pass; the WIP in screens/settings.rs, ui/pane.rs, palette.rs, README
is untouched.
2026-11-12 00:29:21 +01:00
vhaudiquet 762b49eaf4 fix(rhema-code): indent list items' paragraphs in the transcript
The markdown renderer tracked list depth only to pick a marker. A
paragraph that follows a blank row inside an item started at column 0,
so a tool's catalog lines and its "Where it runs" paragraph read as
text after the list rather than part of the bullet. `rhema system show`
previews the prompt through this renderer, which is where it showed.

An item now records its content column, marker width included, and a
row the renderer starts inside one is padded to it. The paragraph that
introduces a sublist keeps it attached: the blank row the paragraph
asks for at its end is taken back, the hug a sublist already got when
no lead-in preceded it.

The tool sections put a blank row between the row and its description
so the description is a paragraph of the item — the shape the indent
applies to, where a soft break would have joined it to the schema.
2026-11-11 18:13:17 +01:00
vhaudiquet 1663dee661 fix(agent-tools): render tool schemas without HTML-safe escapes
minijinja's tojson escapes `<`, `>`, `&` and `'` so its output can ride
a script tag. A system prompt is not a script tag, so every apostrophe
in a field description reached the model as \u0027 and every
`<provider>` as \u003c: harder to read, and more tokens for the same
text.

The renderer registers a json filter that serializes through serde_json
and leaves those characters alone, and both tool sections use it.
tojson stays for a user template whose output does feed a page. The
serialization is also compact, which drops the spaces tojson puts
after every separator.

The tool row is `name | arguments` with the description indented
underneath, so a multi-line description can no longer push the schema
column below another tool's prose, where it read as part of that tool.
2026-11-11 18:13:17 +01:00
kosmos cfb3178346 fix(test): read the turn store through events_from and rustfmt the delete tests
The forget test called `TurnStore::events`, which no longer exists — the
store reads through `events_from(key, after, limit)`, so the assertion now
spells the `(seq, event)` pairs it actually returns. The new assertions
were also wider than rustfmt allows, here and in the http/rsd delete
tests; `cargo fmt --all` reformatted them.
2026-11-11 16:44:58 +00:00
kosmos 994df09c8f feat(web): delete a session from the sidebar
A session could be created from the sidebar but never removed, so the
chat surface accumulated every experiment anyone ever named. Each row
now carries a small delete affordance that only names the key: the
confirmation is the shell's, and it says the turn history goes with
the session before calling the new route. On confirm the chat moves to
the first remaining session — the stream's watcher follows the key —
so nothing keeps streaming a session that no longer exists.

The row had to stop being a single button, since a nested button is
invalid HTML: the select button and the delete button now sit in a
container that owns the hover and active surface.
2026-11-11 16:11:07 +00:00
kosmos 8db1e2aa11 feat(claw): delete a session and its turn history over http
A session the human is done with has to be removable, and removing it
must mean forgetting it: `DELETE /v1/sessions/{key}` drops the dispatch
record and the turn history recorded under the key, so re-creating the
same name starts an empty chat instead of replaying someone else's
turns. The route refuses a leased key, because the running turn's own
save would either fail against the missing record or write the session
back after the delete; the inventory is not narrowed to the
`interactive:` prefix, so a forge key deletes on the same route.

The history purge rides the hub, which owns the turn table, and it
lands after the record does: the purge is the one step this side cannot
undo, so it never runs for a delete the dispatcher refused. Purging on
the no-record path as well is what makes a half-done delete repairable
— a retried DELETE finishes the purge instead of 404-ing with the old
turns still under the key, where a session created again under the same
name would replay them.

The sessions routes stay off the agent token's allowlist in
tools/contract: the agent must not be able to delete the conversation
it is serving.
2026-11-11 16:11:07 +00:00
vhaudiquet 263184ff8e chore: bump the workspace to 0.2.2 2026-11-10 19:44:04 +01:00
vhaudiquet 3e2ff7599d feat(chatgpt): add the ChatGPT subscription adapter and login
Rhema had no way to spend a ChatGPT subscription: the models were reachable only through an OpenAI API key, which bills the API account instead. The new adapter signs in through OpenAI's browser OAuth flow with PKCE and a loopback callback, stores the returned token JSON as the instance credential, and sends turns to the Codex Responses backend with the subscription's live model catalog.

The flow runs on the existing InteractiveLogin seam, so the wizard, the settings dialog, and the headless frontend all drive it. The TUI shows the link's host with an open button and a copy button: enter opens the default browser, and c copies the full URL, which keeps a long authorization query off the screen and out of a manual selection. The provider registry, config, ACP, claw, and the web settings page gain the new provider kind.
2026-11-10 18:06:00 +01:00
vhaudiquet 05045e4ee5 fix(claw): carry a kind's stored route into construction 2026-11-09 23:38:53 +01:00
vhaudiquet ddb26bf34d build(deploy): copy the OpenCode manifest into the images 2026-11-09 23:32:18 +01:00
vhaudiquet 8e76d26c5e feat(claw): discover OpenCode credentials 2026-11-09 23:32:18 +01:00
vhaudiquet fa0e3d4470 feat(android): resume from the seq, and walk back through the session
The chat screen asked the claw for everything the cache did not hold by
counting events, and a session that ran for weeks cost the whole record
the first time it opened here. The stream now names each frame's seq and
the cache stores it, so a reopening screen sends since=<seq> and receives
only the gap. The cache migrates in place: the old count of events is
exactly the last seq plus one, because the record numbers densely.

A screen with no cache opens on the newest turn and walks back through
the new history route when the reader reaches the top, one whole turn
per page, so the phone does not hold a weeks-long transcript to show the
last few messages. Pages are folded through the same reducer the stream
uses and prepended, since the list runs oldest first: appended, they
would land at the newest end. A reset comment (the record no longer
holds the cursor) drops the mirror, and the tail that follows rebuilds
it. The outcome tag colours a steered turn as an accent rather than a
failure, matching the web UI.
2026-11-08 18:01:52 +01:00
vhaudiquet 70d9bb0d34 fix(claw): a resumed stream numbers its live frames from the cursor
The frame pump counted the live seqs from the last replayed event, which
is right only when the replay carried something. A client whose cursor
already sat at the record's head replays nothing, so the pump restarted
at zero: the client's next resume then named seq 0 and replayed the
whole record, and every event it printed in between carried a number
that named something else.

Base the count on the cursor when the gap is empty. The test resumes at
the head of a finished turn and asserts the next live frame names the
record's next seq.
2026-11-08 18:01:42 +01:00
vhaudiquet ed79c0cf1b feat(web/claw): open on the newest turn, walk back, and resume from the seq
A reload of a weeks-old session replayed the whole record before the
first pixel, and every reconnect reset the mirror and replayed it
again. The stream now names each frame's seq and the client keeps it,
so a retry sends since=<seq> and receives only the gap, and the mirror
is left alone: its items are still true.

A first open with no cursor gets the tail, cut to a turn boundary,
which paints the latest conversation at once. The turns above it come
from the history route, one whole turn per page, fetched when the
reader nears the top or when the transcript is too short to fill the
screen; a page folds through the same reducer as the live stream and
joins above, and the scroll position moves down by the page's height so
the reader keeps their place. A reset comment (the record no longer
holds the cursor) drops the mirror; the tail that follows rebuilds it.

The dev proxy rebuilt the upstream URL from the pathname alone, so
since and before never reached the daemon under nuxt dev: every resume
was a full replay and every history page was the tail. It forwards the
query string now.
2026-11-08 18:01:30 +01:00
vhaudiquet 744ba8b06c feat(claw): the event stream names its seqs, so a client resumes instead of replaying
A session that runs for weeks made every reconnect cost the whole
record: the events route replayed all of it, and the only cursor was a
count of events held, which cannot survive a gap. The replay is now
addressed by seq.

Every frame carries the record's seq as its SSE id, and since is that
seq, so a reconnecting client reads exactly the events it missed. With
no cursor the stream opens on the tail (the newest 400 events, cut back
to the first turn boundary in them) and goes live; the turns above it
come from a new history route, one whole turn per page, each ending
where the previous page began, with a more flag to stop the walk. A
cursor the record can no longer serve comes back stale with a reset
comment and the tail, read under the same lock as the live receiver so
the reset has no hole either.

The transcription fold in the store already had the seq column dense
per key; this exposes it as three range reads (after a cursor, the
newest window, the window below a point) instead of one whole-record
read, and keeps the in-memory record's seqs dense the same way, so a
store-less assembly answers the same protocol.
2026-11-08 18:01:10 +01:00
vhaudiquet 94b82e2636 fix(web): the bugs a chat that never closes for a turn exposes
An investigation pass over the client, driven by steering's new shape —
a composer that is always live makes adjacent latent bugs visible.

The event stream now watches for silence: the claw keeps the
connection alive with a comment every fifteen seconds, and three
missed ones abort the fetch and reconnect, so a half-open TCP after a
laptop sleep no longer reads as 'live' forever. A reconnect clears the
attempted-attachment set with the transcript, or an image whose fetch
failed during the outage never retries against the recovered stream.
Saving a token reloads the identity and accounts the 401 froze: the
banner cleared, but the pages stayed empty until a browser reload.

A failed POST retracts its optimistic sent row (and its pending echo,
so a message the claw did take still renders from the stream's echo);
before, a send into an unreachable claw left a phantom row waiting for
a reply that could never come. Drafts are per-session now — the
composer's text belonged to no session, so a draft typed into one
session sent into whichever was selected at Enter. The providers
login form distinguishes a landed login (the roster carries its id)
from a failed one and no longer closes on an unrelated state refresh.

Two cost fixes that read as jank on long turns: the streamed reply's
markdown re-parsed the whole text per delta (quadratic) and now
re-renders on a short trailing debounce; the attachment-image watcher
walked the whole transcript on every streamed chunk and now scans only
the items it has not seen. The accounts page drops a stray Rust-ism
comment, and the avatar row's meta line admits webp like its accept
attribute always did.
2026-11-08 18:00:56 +01:00
vhaudiquet 041240f706 feat(claw): a message into a live turn steers it
The resident dispatch's turn now runs under a steer select: the
messages route, after its ingest, signals a per-key interrupt registry,
the drop lands at the turn's current await (an in-flight shell command
dies with it), the dangling calls close, and the dispatch loops on the
same lease — the steered message runs as the next round, same session,
same stream. The interrupted turn closes on the stream with turn_done
carrying outcome "steered", and the shim's whole and streamed
answers both wait through steered end markers for the round that
actually replies.

The messages route answers 202 the moment the message is accepted; the
drain pass runs detached, so a turn's minutes never hold the request
open. The web composer leans on that: it never closes for a running
turn, the placeholder and the send button say that sending steers, a
steered outcome gets its own tone, and the sent-row echo dedup pops a
pending list instead of comparing the last item — a steered chain's
extra end marker can no longer double the row.
2026-11-08 18:00:39 +01:00
kosmos 853c5a547c fix(claw): the account-watch test names the id the store assigned, and clippy's then_some
The test fabricated `acct-1` as the watch's account id, but `put`
assigns its own, so the dispatch refused with "names account acct-1,
which is gone" — the test proved nothing about the path it covered.
Capture the assigned id instead.

`(secret.kind == SecretKind::Token).then(|| WatchCredential { .. })`
constructs a value with no work, so clippy's unnecessary_lazy_evaluations
failed `checks`; `then_some` says the same thing.
2026-11-08 17:54:15 +01:00
kosmos 875b2f0ed8 fix(claw): an account-watched thread resolves its client without a repo watch
`watch_forge` looked the dispatch key up in the `watches` table and
nothing else, so a thread that arrived through an *account* watch — the
daemon's own account notifications, which cover the whole instance, not
one repo — failed with "no watch configured" unless the same repo also
had a repo watch row. The dispatch is created by the account poll; the
client it needs is the account's. Nothing could hand it over, because the
payload did not name the account either.

The triaged payload now carries `account_id`, the dispatch drains the
inbox *before* resolving the client (the inbox is where that id lands),
and `watch_forge_as` falls back to the account watch that delivered the
news: its host, its credential, its login. A repo watch row still wins
when there is one — a thread with its own watch keeps its own identity —
and `watch_forge` keeps its signature for callers that know nothing about
accounts, falling back to the kind's first account watch.

The client cache is now one helper over `(forge, host, credential)`, so
the dispatch's replies ride the same pool the poll built.
2026-11-08 17:54:15 +01:00
kosmos 838f71738e feat(claw): record every watch's poll health, and say so when a streak starts
A watch whose poll fails was invisible: `detect` turned the failure into
one `eprintln!` per tick and nothing else. A watch that had 401'd every
sixty seconds since the day it was created was indistinguishable — on
`/v1/watches`, in the agent's own `list_watches`, everywhere — from a
repository that had simply been quiet. That is how a dead credential
survives for weeks: the log line scrolls, and nothing else remembers.

`watch_health` is a third table beside `watches` and `account_watches`,
keyed by `(scope, forge, subject)`, holding `last_ok_at`, `last_error`,
`last_error_at` and `consecutive_failures`. Every detect pass records
each poll's outcome — repo watches as `repo`, account watches as
`account` — and the pass that *starts* a streak (the first failure after
a success, or after never having succeeded) pushes one device
notification, naming the watch and quoting the forge's error. The rest of
the streak stays quiet: the row carries the count, so the tick's log line
no longer has to.

Readable where the question gets asked: `/v1/watches` answers a `health`
array alongside the two watch lists, and the `list_watches` tool reports
`last_ok_at`, `last_error` and `consecutive_failures` per repo, so "is
this watch working?" is answerable without the daemon's stderr. A watch
that has never polled has no row and reports empty health, which is what
it is.
2026-11-08 17:54:15 +01:00
kosmos 6468ae9638 fix(claw): a borrowed watch credential carries the account's login
`resolve_watch_token` handed the adapter a bare string, so the client
could only ever try it as a token — and a `secret_kind` is a label the
human typed at `identity_save`, not a fact: a login password saved as a
token resolved as one, 401'd on every poll, and the watch stayed dead
with nothing said.

`resolve_watch_credential` returns the value *and* the login of the
account it came from (`WatchCredential`), the client cache keys on both
(one value sent two ways is two credentials), and the gitea client is
built with `with_login`, so the adapter's 401 retry can reach the
instance's own Basic-auth form.

A row token or the forge's environment variable names no login, so those
tiers resolve exactly as before: `WatchCredential::bare`, `login: None`.
`resolve_watch_token` stays as the token half, for callers with no use
for the login.
2026-11-08 17:54:15 +01:00
kosmos d31dfeae27 fix(forge): a gitea 401 retries once as basic auth
A self-hosted Gitea answers 401 to `Authorization: token <password>` and
200 to the same value as `user:password`, and the account store's
`secret_kind` cannot tell the two apart — it is a label the human typed
at `identity_save`, so a login password saved as a token is
indistinguishable from a real PAT until the forge answers.

`GiteaForge` had one auth path: the token header, on every call. A
watch whose credential resolved to a password therefore 401'd on every
poll, forever, while the same secret worked for `git push` over https
(the credential helper sends it as a password). The watch looked quiet
instead of broken.

`GiteaForge::with_login` names the login that owns the secret, and
`send` tries the token header first, then — on a 401 only — the same
value as Basic auth. The retry is cloned before the token header lands,
so the fallback request carries one `Authorization` header and not two.
No login (a pasted watch token names none) or an unclonable body leaves
the 401 to surface exactly as it did before.

The login reaches the client in the next commit; this one is the adapter
half, tested against a mock that refuses the token header and accepts
the Basic one.
2026-11-08 17:54:15 +01:00
kosmos 6ae21568d0 fix(android): gate the wifi read on ACCESS_WIFI_STATE, and declare it
Reported from the phone's diag log:

  status: crashed: SecurityException: WifiService: Neither user 10519 nor
  current process has android.permission.ACCESS_WIFI_STATE.
  at android.os.Parcel.createExceptionOrNull

`PhoneSensors.wifiSsid` read `WifiManager.connectionInfo` behind nothing
but the location grant, but WifiService gates every WifiManager call on
ACCESS_WIFI_STATE — and it enforces that by throwing SecurityException,
not by answering null. The permission was never declared, so the read
always threw.

The cost is larger than the missing SSID: the throw travels out of
`readSnapshot` into the worker's catch, which logs "status: crashed" and
returns failure — so one undeclared permission took the battery, network,
DND and location topics down with it, and the fleet's rows went stale
while the app looked healthy.

Two halves, both needed:

- the manifest declares ACCESS_WIFI_STATE (a normal permission: declaring
  it is the whole grant, no runtime prompt). An install that predates the
  line keeps the grant set it was installed with, so this alone does not
  stop the crash on an existing phone;
- `wifiSsid` returns null unless the permission is actually held, and
  wraps the read in `runCatching`, so the status report degrades to "no
  wifi name" instead of dying whole. The check is a `hasWifiState` beside
  the existing `hasLocation`, same idiom.
2026-11-08 17:54:15 +01:00
vhaudiquet 52ee642eee chore: bump the workspace to 0.2.1 2026-11-08 17:54:12 +01:00
vhaudiquet f5b4d35d99 fix(deploy): the claw image manifest gains rhema-provider-registry
The fetch stage parses the workspace before the sources land, off each
member's Cargo.toml COPY'd one row at a time. The new registry crate
rode in through the crates/* member glob, rhema-acp grew a path
dependency on it, and the enumerated list had no row for it, so cargo
died at manifest parse looking for
/build/crates/rhema-provider-registry/Cargo.toml. The row joins the
list in alphabetical place.
2026-11-08 17:53:04 +01:00
vhaudiquet 5aed7b6a3d refactor(provider-registry): the fleet's construction recipes move into one build
Every frontend spelled the same recipes in its own backend assembly,
and rhema-code spelled them three times: deepseek is the generic
adapter at DeepSeek's endpoint, zai is the Anthropic adapter at Z.ai's
route under bearer auth, each keyed kind takes its own adapter — with
the DeepSeek endpoint a string literal in three crates and the zai
recipe hand-rolled in each. The construction could not centralize
while the model catalog lived on inherent adapter methods: claw's
AnyProvider enum existed to box listings a trait object could not
reach, and code holding only a provider name had to construct
concrete types to list models at all.

rhema-provider gains the ModelListing seam, implemented by every
adapter, so the catalog is reachable through a trait object. A new
rhema-provider-registry owns the wiring: build() takes a
rhema-config PROVIDERS spelling plus whatever the caller resolved —
a key, the llama.cpp server address, a stored route override, blank
reading as unset — and returns the turn provider and the catalog
listing. What stays with the frontend is policy: credential
resolution, the retry wrapper, the default model. The base constants
and the named zai route constructor stay on the adapters they
describe. claw's AnyProvider died with its last reason to exist, ACP
no longer defaults an unknown catalog kind to google's, and adding a
provider is now adapter plus one registry row instead of three
frontend edits that had already drifted apart.
2026-11-08 17:53:04 +01:00
vhaudiquet 12182492ea refactor(config): the family's state and cache roots join Home
Two hand-rolled XDG resolutions survived the settings refactor: claw's
state_dir matched on XDG_STATE_HOME and HOME itself, and the ACP
adapter's session cache matched on XDG_CACHE_HOME itself — same class
as the global dir helpers this crate already buried. And the family
rule they implement was nowhere written down: the claw has its own
keychain namespace since the last refactor, yet its config, state, and
cache live under rhema's, with nothing saying that is deliberate.

Home carries the state and cache roots now, resolved with the config
base in discover and stated as the family rule in its doc: the
family's products share the XDG bases and nest below `rhema/` — claw
at `rhema/claw.yaml` and `rhema/claw`, the ACP session cache at
`rhema/acp-sessions`. A frontend from outside the family relocates
the whole set instead: relocated derives the frontend's name from its
config base's final component, dot stripped, and puts its state and
cache under that name, so godcode's artifacts never land under
`rhema/`. claw and the ACP adapter ask Home now; claw's NoStateHome
error variant died with its last user, and the ACP store's embedder
seam (AcpOptions::session_dir) was already there. SPEC records the
decision beside the keychain one it mirrors: the namespace is per
frontend, the nesting is per family.
2026-11-08 17:53:04 +01:00
vhaudiquet c3188af8bc refactor(config): the provider registry owns the default models
The per-provider fallback ids were spelled in three crates that had
already drifted. claw's resolved_model carried a stale match that
answered deepseek, copilot, and zai with google's fallback while its
own backends.rs knew better; acp and the headless one-shot still used
the copy-pasted google default for openrouter, which claw's module doc
records as deliberately replaced by openrouter/auto — a router
deserves its own router id. Keeping three tables honest was a
comment-level contract ("the same defaults the headless one-shot
uses"), and the comments lied.

ProviderInfo gains default_model, every row fills it, and
rhema_config::default_model(name) answers with google's default for
an unknown spelling, the same fallback every caller hand-rolled. The
acp resolve, claw's backends and resolved_model, and the headless
one-shot read the registry now; the three copies are gone. claw's
resolved_model test pins the arms that had silently dropped off.

Behavior changes, both toward the recorded decisions: the headless
openrouter fallback becomes openrouter/auto, and claw's deepseek,
copilot, and zai fallbacks become their real ids instead of google's.
2026-11-08 17:53:04 +01:00
vhaudiquet df4cf87a20 refactor(config): make the settings contract frontend-agnostic
rhema-config held both vocabularies: the stored settings and the
terminal UI's theme, palette, and onboarding state, under one Config
type with a global directory. A second frontend had to take the UI
settings to get the profiles, and every path helper resolved
$XDG_CONFIG_HOME/rhema no matter who called.

The crate now ships only the framework's contract. Settings is the
stored section, flattenable as one serde field. Home carries the
directory conventions as a value, so a frontend keeps its settings,
skills, subagents, and SYSTEM.md under its own base while
.agents/skills stays machine-global. Store is the seam a foreign
frontend implements over its own file and format, YamlStore is the
reference impl, and Platform collects home, store, and keychain
namespace at one construction site. KeyringSecretStore::named gives
each frontend its own OS keychain service, so two frontends never
share credentials by accident. examples/godcode.rs builds the whole
shape runnable: its own directory, its own JSON document, its own
namespace.

rhema-code's store::Document flattens the section beside its UI
fields, the layout on disk unchanged. serde's flatten silently drops
unknown keys, so the load path re-checks every leftover key against
the section: a typo'd profiles: stays loud inside the bigger
document. CodingAgentConfig gains home: Option<Home>, so a frontend
redirects skill discovery without calling the framework. claw and
acp name their keychain namespaces, and the mcp, coding-agent, and
subagents crates read conventions from the Home they are handed.
2026-11-08 17:53:04 +01:00
vhaudiquet d067f4632f refactor(tools): rename rhema-tools-contract to rhema-claw-tools-contract
The crate binds the claw to its client tools, not tools at large,
so it carries the claw's name: package, lib and dep names, the
mock's binary, and the conformance suite's use paths. Specs and
README follow. The tools/contract directory keeps its name, and
no reference to the old name remains.
2026-11-06 23:05:46 +01:00
kosmos 9e132fdf71 fix(claw): read a blank since as unset instead of failing the request
The phone's notification poll sent `since` unconditionally, and before
the first ack there is no cursor at all, so it went out as `?since=`.
Axum's `Query` hands a present-but-empty parameter through as an empty
string, and `Option<i64>` only forgives a *missing* key, so the whole
poll answered `400 Failed to deserialize query string: since: cannot
parse integer from empty string` and the phone showed an error instead
of an empty queue.

Both ends: the client omits `since` unless it has a positive id to send,
and a `blank_as_none` deserializer makes a blank value mean "not set"
for `NotificationQuery` and `EventStreamQuery` alike — an empty `since`
reads as "from the start" however it got there.
2026-11-06 20:39:53 +00:00
vhaudiquet 3283d92227 chore: bump the workspace to 0.2.0 2026-11-06 15:32:41 +01:00
vhaudiquet 1467f98c96 docs: the phone reports are state, not conversation
The claw README claimed the phone ingest dispatches battery, network,
and DND flips to the agent; the ingest door stores topic-keyed
payloads and answers "observed", and the Android worker posts nothing
but topic keys. The README now says state-only with the bare
phone:<device> key as the dispatch path, and the worker's comment
matches what it actually posts.
2026-11-05 19:12:18 +01:00
vhaudiquet c7adc6404a fix(deploy): the image manifests stage the mcp members
The manifest-first COPY list predates the mcp crates: mcp-to-cli and
rhema-mcp had no manifest line, so the registry cache populated
without them and the source-copy build resolved their deps cold —
the cache layer the staging exists to provide never covered two
workspace members. Both Dockerfiles stage the pair.
2026-11-05 19:08:05 +01:00
vhaudiquet 5a8be19b4c fix(calendar): fed slots stay fed across a restart
The reminder source skipped an already-fed slot only while the slot
sat in the dispatcher's pending inbox; a claw restart drained that
inbox, and every future lead of the same occurrence re-fed — a
reminder fired two or three times per calendar event. The source
keeps its own fed-slot ledger in the calendar store now (keyed by
calendar, occurrence, and lead), records each slot right after a
successful ingest, and prunes rows whose occurrence is an hour past
every lead, so a pruned row can never resurrect a reminder. The
store-backed source's ledger survives a restart; the static-list
constructor keeps an in-memory one for the process lifetime, said so
in the docs that previously claimed dedup was someone else's problem.
Store failures surface loudly (a silently lost row is a re-fired
reminder); the prune warns and continues. Offline tests cover the
once-only rule, the restart via a tempdir reopen, and the prune.
2026-11-05 19:06:41 +01:00
vhaudiquet dad99c7664 fix(calendar): the ICS address parser survives a multibyte prefix
bare_address sliced trimmed[..7] after a len() >= 7 check — byte
count against a byte slice is fine, but the guard and the slice
disagree once the text is lossy-decoded elsewhere; an ATTENDEE line
whose value opens with a multibyte run panicked the reminder source
on a feed the operator does not control. The slice is get(..7) now,
and a test feeds multibyte prefixes through both the ATTENDEE and
ORGANIZER paths.
2026-11-05 19:05:16 +01:00
vhaudiquet 00adf092ff fix(forge,claw): forge keys separate the issue from the pull
The watch key encoded source, repo, and number but not the thread
kind, and on GitLab an issue and a merge request share one number
space: iid 7 twice produced one key, one snapshot, and comments on
the merge request landing in the issue (or a 404 fallback that
misrouted the other way). The key carries the kind now —
{source}:{kind}:{owner}/{name}#{number}, with GitLab spelling the
pull variant mr — comment and snapshot route outright by kind, and a
kind-less payload parses to the unparsable key instead of guessing
issue. The claw's callers thread the kind from the payload and strip
the segment where the bare repo is wanted; pre-kind stored keys parse
as None rather than misroute. Four route-pinning wiremock tests cover
the GitLab split, and the GitHub/Gitea clients take the kind without
using it (their issues and PRs share the same REST space).
2026-11-05 19:03:52 +01:00
vhaudiquet 10efe85f38 feat: the see macro turns a command's output into images
There is no native tool for this, and images are shell data like any
other, so the surface is a bash function the model pipes into:
curl -sL https://example.com/photo.png | see, or see path.png. The
name reads as a sentence in the pipe position, keeps working as a
plain argument for local files, and is one const in vision.rs if it
ever needs to change.

Two halves, joined by a staging directory. Provision writes the
BASH_ENV init file one non-interactive bash sources; see() execs the
rhema binary's hidden __vision mode, which sniffs magic bytes, stages
them under a 0700 per-process dir, and says so on stdout. 5 MB per
image (the strictest API ceiling), png/jpeg/gif/webp only, and a
refusal exits 2 with the reason on stderr so the model reads the
limit in-band rather than losing the bytes to a silent drop. Attach
runs after every bash command that reaches an exit: ShellTools drains
the staging dir into ToolResult.images, a new core field. Bytes never
ride stdout, so output caps and truncation cannot eat them, and a
staged image attaches even when the command failed. The model staged
it, so it belongs in the context. The caps: 5 MB staged, 8 images per
result, the rest named in the result's text, because a for loop
staging 30 screenshots must not cost a context window.

serde keeps the field default, so old checkpoints load unchanged.
Anthropic carries native image blocks in the tool_result content
array, text first. OpenAI-dialect tool messages (llama.cpp, Copilot,
OpenRouter, DeepSeek) and Gemini carry text only, so each withheld
image is named in a trailing note the model can act on. The
transcript's byte accounting and summaries count the images; the
coding-agent config carries the prompt section, and the render adds
the section only when wired.

Frontends provision only where their bash runs locally: the TUI's
session_agent_config, the headless one-shot, the ACP adapter, the
claw's daemon, the same gate the MCP wire applies, because a session
whose bash runs elsewhere cannot write where the drain reads. The
vision wiring lands before the MCP wire so BASH_ENV names the MCP
file last and its baked predecessor line reaches the vision init;
both functions end up defined. Subagent inner shells inherit the
parent's wiring, so a spawned agent keeps the macro in its own
commands. SPEC's decision bullet, AGENTS.md's scope line, and the
README row land with the feature.
2026-11-05 18:52:37 +01:00
vhaudiquet 99ecceacb5 feat(claw): the daemon wires MCP into its agents
The claw's side of §10: one bridge per process, established at first
agent build and never re-read — the claw is single-instance and its
servers are stateful across calls, so a mid-process rewire would
orphan running sessions' socket paths. The global `mcp.yaml` is the
claw's config scope (no profiles here); the off switch is
`disabled_mcp_servers` beside the rest of claw.yaml, and both agent
construction sites — chat turns and `coding_agent` jobs — layer the
wiring on: the init file and socket and token into `shell_env`, the
post-filter lines into the prompt's `mcp` section, the persisted set
into the config. A frontend without wiring changes nothing, so the
daemon without `mcp.yaml` behaves exactly as before.

The test configs gain the new field; the fd-limit discoveries from
the workspace run are already handled by the harness runs above.
2026-11-04 23:13:12 +01:00
vhaudiquet d5328667e4 feat(code,claw): the rhema mcp route, frontend wiring, disabled persistence
The spec's §10 consumer steps land. The rhema binary routes `mcp`
and everything unrecognized (the external subcommand, which is the
road the init file's stubs take: `rhema __server <name> ...`) to
rhema_mcp::cli::run, whose exit code owns the process; outside a
wired shell it exits 4 silently, exactly the spec's contract. The
claw gains its first subcommand the same way (`claw mcp ...`).

The frontends wire: session_agent_config (the TUI's three call
sites) and the headless one-shot merge the wiring's shell_env into
the agent config and the lines into the prompt's mcp section --
always behind the sandbox gate, so a session whose bash runs
elsewhere advertises nothing it cannot run. The registry keys by
profile name: a profile switch rewires, staying put reuses the
bridge and its already-spawned servers. The bridge itself moved to
a dedicated thread with its own runtime in this pass, which is what
makes wire() synchronous -- a dedicated runtime dropped at return
took the accept loop with it the first time, and a blocking CLI read
starving a current-thread runtime taught the same lesson from the
other side.

Muting persists: disabled_mcp_servers lands beside skills_disabled
in the config file, the settings toggle writes both the live set and
the config through save_config, and the running agent rebuilds so
the very next turn sees the change -- the MCP twin of the skill
toggle, with the round-trip test extended to assert the config.

The process-level pin the spec's §9 asked for: a bash child sources
the provisioned init file from BASH_ENV, calls the generated
function, the stub execs the CLI bin (the same dispatch the rhema
binary routes to), and the envelope prints -- plus the unknown-tool
path exiting 2 through clap's own text. provision and wire grow
provision_with_bin/wire_with_bin test seams so the exec chain can
run without building the frontend; the standalone rhema-mcp-cli
bin target is the seam's exec side.
2026-11-04 22:58:55 +01:00
vhaudiquet abcafccc8e refactor: remove rhema-vercel-agent
The Vercel frontend existed to prove the shared harness extraction:
two frontends speaking the same wire protocol, one assembly, one
gate. The proof landed; the deployment didn't, and the crate cost a
full axum server, a Postgres store, and a Sandbox REST client to
keep carrying. It leaves the tree; git history keeps it for the day
a second frontend returns.

The Cloudflare DO is now the harness's one consumer, and nothing
downstream depended on the crate. README, AGENTS.md, and the two
dockerfiles lose their vercel entries; the cloud-agent and
cloudflare-code doc comments name the remaining frontend. SPEC.md
section 11 still tells the two-frontend story; it stays until the
direction settles.
2026-11-03 22:31:45 +01:00
vhaudiquet 6e641102c6 feat(rhema-provider): the retry ladder follows the failure
The old ladder doubled from one second and capped at two: three
attempts, three seconds — enough for a hiccup, nothing for a
reassociation or a real outage. The policy now takes two explicit
ladders and picks by error class. A dead link (Network) is the local
machine's problem and waiting can fix it, so it walks 1s, 3s, 30s,
60s, 300s — six attempts, six and a half minutes all told. A
struggling server (Timeout, 408, 5xx, an exchange that closed before
any content) fixes itself in seconds or not at all inside a turn,
and every timeout attempt also burns the request's own timeout, so
its ladder stays at 1s and 2s. Each ladder's length sets its attempt
count, so the policy can no longer disagree with itself.

Each wait is announced as an Event::RetryWait (error, delay,
attempt, total) before it runs. rhema-code carries it as a deadline
and the status strip counts it down — the error in the alarm color,
the attempt, the seconds left — while any event from the next
attempt clears it. Esc still cancels mid-wait: the executor drops
the future at the sleep. ACP, claw, subagents, and the cloud wire
ignore the event; from_event returns Option now, so a frameless
event sends no frame. The hosted frontends keep the short ladder on
both classes: their invocation outlives no five-minute wait.
2026-10-31 18:56:13 +01:00
vhaudiquet 951430e44b build(deploy): the docker images copy the ecrou manifest
The workspace manifest globs crates/*, so cargo fetch parses every
member's Cargo.toml; rhema-sandbox-ecrou joined the tree without its
manifest joining the COPY list, and both image builds died at manifest
parse ("failed to read /build/crates/rhema-sandbox-ecrou/Cargo.toml").
Add the line to claw.Dockerfile and sandboxd.Dockerfile, in the
alphabetical position the list keeps.
2026-10-29 18:12:12 -05:00