Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .ai/03-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ Plan-limit windows relayed by `caprock statusline` are **validated before storag

What that file holds bounds what may ever be said about it: a timestamp and two window percentages. **No tokens, no cost, no conversation content**, so this can never state what the desktop app cost — only how much of a window it consumed. It is also written only while the app runs (27 samples in a day on one real machine, against ~290 at its five-minute interval), so a reading older than 20 minutes is flagged `stale` and the UI says the app has been closed since. Nothing is stored and nothing is polled.

`PUT /v1/settings` accepts `report_bot_token` and `report_chat_id` for the weekly report. The **token is write-only**: it is stored and never returned by `GET /v1/settings`, which instead carries `report_bot_set` (a bool), `report_last_error` and `report_last_sent_ms`. This is the only write-only field in the API and it exists because the settings response is read on every dashboard render and by `caprock report` — a credential should not ride along on either ([ADR-024](08-decisions.md)). Omitting `report_bot_token` from a PUT leaves the stored one alone, since a UI that reads settings and writes them back always omits it; sending `""` clears it. The chat id is not a credential and round-trips normally.

`GET /v1/gemini` reports whether asking Gemini is possible here: `{available, env_var, licensed, model}`. It performs **no network I/O** and **never returns the key** — `available` says only that one is present. `POST /v1/gemini/ask` takes `{prompt, model?}` and answers `{text, model, usage}`, where `usage` carries the response's own `promptTokenCount` / `candidatesTokenCount` / `cachedContentTokenCount` / `thoughtsTokenCount`. It is the one endpoint in the product that checks the licence **server-side** (402 without an active key) rather than leaving the paywall to the UI, because the call spends the user's Gemini quota and opens an outbound connection — the reasoning and its limits are in [ADR-023](08-decisions.md). With no key set it answers 412 with the variable to set, which is a different problem from 402 and is reported separately so the screen can say which. The key is read from `GEMINI_API_KEY` in the daemon's environment at call time; it is never stored, never accepted by `PUT /v1/settings`, and never present in `GET /v1/settings`.

`GET /v1/update` returns `{enabled, current, latest, update_available, command, url, checked_at, error, notes, notes_for}` from cache and **performs no network I/O** — a page load must never cause an outbound call. `POST /v1/update/check` performs one, and returns **403 while `update_checks` is false**: the opt-in is enforced by the server, not merely hidden in the UI, so no page or local script can make Caprock reach the network uninvited. Checks are throttled to once a day unless forced, the request carries no body or credentials, and a failure is reported in `error` rather than as an error status — not knowing about a release must not read as a broken dashboard. `command` is the upgrade command inferred from the running binary's path (Homebrew, Scoop, `go install`); when no package manager owns the binary it is empty and the UI offers `url` instead. `notes` is the published release's own description, taken from the same GitHub response as the tag — reading it costs no second request and no further exposure. It is trimmed to a dialog-sized excerpt (long bodies cut at a line boundary) and paired with `notes_for`, the version it describes, so a cached note can never be shown beside a different version after a failed check. `update_available` is never true for a `dev` or `git describe` build. Caprock does not install the update: replacing the running binary would mean the daemon killing the process executing the command, and running a package manager on the user's behalf from a web page is a surface a local tool should not open.
Expand Down
73 changes: 73 additions & 0 deletions .ai/08-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,76 @@ call; presenting a Caprock-side total as the user's Google bill.
reconciled rather than only counted), or if setting an environment variable
proves to be the thing that stops people using the feature — in which case the
question is a better handoff, not a key Caprock keeps.

---

## ADR-024 — The weekly report holds a bot token, and only reports what a baseline supports

**Decided 2026-09-01.** *Third paid feature; amends the scope of [ADR-023](#adr-023--gemini-runs-on-a-key-caprock-never-holds-read-from-the-environment).*

A weekly message saying what moved, sent to the user's own Telegram bot. Three
decisions, and the first one walks back a line drawn yesterday.

**The bot token is stored, and the Gemini key still is not.** ADR-023 said
Caprock never holds a credential, and that remains true of the thing it was
written about. A Google AI Studio key and a Telegram bot token are not the same
object: the key is attached to a billing account and can spend real money, while
the token drives a bot the user created for this one purpose, which can send
messages to the chats it was invited to and nothing else. Leaking the first
costs money; leaking the second costs a stranger the ability to message you.
That difference is large enough to price differently.

The deciding argument is what the alternative does to the feature. Putting the
token in the environment means: talk to BotFather, find the chat id, edit a
launchd plist or a systemd unit, reinstall the service, restart the daemon — on
Windows, worse. The premium page promises "about two minutes", and a setup that
long would not be dishonest so much as unused. A feature nobody finishes setting
up is not a feature.

It is stored the way the licence key already is: `config.json`, mode `0600`,
inside a `0700` data dir. It is **write-only over HTTP** — accepted by
`PUT /v1/settings`, never returned by `GET /v1/settings`, which is a new pattern
in this codebase and exists because the settings response is read by the
dashboard on every render and by `caprock report`. What comes back instead is
whether a token is set, which is all any screen needs to know.

**A finding needs a baseline and a floor, or it is not reported.** The premium
page says "the repository that cost 3× its usual week". Two weeks compared give
a ratio, not a finding: a repository that cost $2 and then $6 is 3× and means
nothing. "Usual" is therefore the median of the preceding four weeks, not last
week, and no movement is reported at all unless the change also clears an
absolute floor — a few dollars, not a few cents. Below that the message says the
week was ordinary, which is a true and useful thing to say.

This follows what `assembleWork` already does in `caprock report`: withhold a
breakdown whose linkage is too weak rather than publish a confident wrong
ranking. A weekly message is worse than the dashboard for this, because the
reader cannot click into it to check.

**It is the first background outbound call, and that is the part to be careful
with.** ADR-023 ruled out "any background or speculative call" for Gemini, and
that stands for Gemini: it spends the user's money per call. This spends
nothing, goes only to Telegram's documented API, and carries figures the user
already sees on their own screen — no prompts, no replies, no tool output, no
file paths, on the same rule as the Gemini context. It sends only when the user
has configured a bot, which is the opt-in; with no token there is no timer and
nothing to disable.

**Scheduling is a comparison, not a countdown.** A laptop is closed at
weekends, so a ticker anchored to Monday 09:00 fires for nobody. The daemon
instead checks hourly whether the ISO week of the last sent report is behind the
current one, and sends on the first tick after the send time — which means a
machine opened on Wednesday gets Monday's report on Wednesday, labelled with the
week it covers. The marker lives in the `meta` table beside the tool-link
cursor, because an in-memory marker sends a second copy after every restart:
that is exactly the bug `cap.Guard.firedOn` has, tolerable for a cap and not for
a message.

**Rules out:** a token in the environment; a report that names a mover without a
baseline behind it; a fixed weekly timer; an in-memory sent-marker; sending
anything the dashboard does not already show the user.

**Revisit if** a user asks for a second channel (a webhook is the same shape with
a different URL), or if the token turns out to be worth more than this decision
assumes — a bot added to a company Slack-style group chat is a wider blast radius
than a personal one, and that would be the signal to move it out of the file.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ polish (plan-limit windows, orchestrator-lifecycle fixes, Homebrew formula, firs

## [Unreleased]

### Added

- **The weekly report, to your own Telegram bot.** What moved this week against
your usual, sent Monday morning — or the next day you open the lid, because a
timer set for Monday 09:00 fires for nobody whose laptop is shut at weekends.

A repository is only named when it clears both a real change in dollars and
twice its usual week, where *usual* is the median of the preceding four weeks
rather than last week: $2 to $6 is three times and is nothing, and a claim in
a message is one the reader cannot click into to check. Most weeks it says
nothing moved, which is true and worth saying. A week the machine was off is
reported as such rather than as a collapse in spending.

The bot token is stored on your machine and is the one thing this API accepts
but never returns. The message carries figures only — no prompts, no replies,
no tool output, no file names — and goes straight to Telegram; nothing passes
our server.

Phase 3 (Delight) has no plan by design.

## [0.41.0] - 2026-09-01
Expand Down
40 changes: 40 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,33 @@ type Settings struct {
// too old to have the field, and the panel cannot tell "you turned this
// off" from "this build cannot do it".
CapUSDPerDay float64 `json:"cap_usd_per_day"`
// ReportChatID is where the weekly report goes. Not a credential — a chat
// id identifies a conversation and grants nothing — so it round-trips like
// any other setting.
ReportChatID string `json:"report_chat_id"`
// ReportBotSet reports whether a bot token is stored, WITHOUT the token.
//
// The token is the first write-only field in this API: accepted by PUT,
// never returned by GET. Every other setting round-trips, and the licence
// key is echoed back plainly — but that key unlocks features on this
// machine, while a bot token can send messages as somebody's bot. This
// response is read on every settings render and by `caprock report`, and a
// credential should not ride along on either. What a screen needs is
// whether one is set, which is this.
ReportBotSet bool `json:"report_bot_set"`
// ReportBotToken is never serialised — the `-` tag is the mechanism that
// makes "write-only" true rather than merely intended. It is set by the PUT
// handler and read by the daemon; GET renders ReportBotSet instead.
ReportBotToken string `json:"-"`
// ReportLastError is why the last send failed, empty when it did not.
//
// A weekly message that silently stops arriving is the failure mode this
// feature has: nobody notices an absence. Telegram's own words are kept
// ("chat not found", "bot was blocked by the user") because both are things
// only the user can fix.
ReportLastError string `json:"report_last_error,omitempty"`
// ReportLastSentMs is when a report last went out, 0 for never.
ReportLastSentMs int64 `json:"report_last_sent_ms,omitempty"`
// BrowseRoot is the only directory the folder picker may look inside, and
// the boundary every path it returns is checked against. Empty means the
// user's home directory.
Expand Down Expand Up @@ -645,6 +672,10 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
LicenseKey *string `json:"license_key"`
CapUSDPerDay *float64 `json:"cap_usd_per_day"`
BrowseRoot *string `json:"browse_root"`
// The bot token goes in and never comes back out. An empty string is a
// deliberate clear, which is why it is a pointer like everything else.
ReportBotToken *string `json:"report_bot_token"`
ReportChatID *string `json:"report_chat_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&patch); err != nil {
s.failCode(w, http.StatusBadRequest, fmt.Errorf("parse body: %w", err))
Expand All @@ -671,6 +702,15 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
if patch.BrowseRoot != nil {
in.BrowseRoot = *patch.BrowseRoot
}
// Only touched when the caller named it. GET never returns the token, so a
// UI that reads settings and writes them back always omits it — treating
// absence as "clear it" would delete the token on the next unrelated save.
if patch.ReportBotToken != nil {
in.ReportBotToken = strings.TrimSpace(*patch.ReportBotToken)
}
if patch.ReportChatID != nil {
in.ReportChatID = strings.TrimSpace(*patch.ReportChatID)
}
if patch.LicenseKey != nil {
in.LicenseKey = *patch.LicenseKey
}
Expand Down
9 changes: 8 additions & 1 deletion internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,14 @@ func TestPaceForecastHonesty(t *testing.T) {
// fakeSettings is an in-memory SettingsController for the endpoint tests.
type fakeSettings struct{ cur Settings }

func (f *fakeSettings) Get() Settings { return f.cur }
// Get mirrors what the daemon's adapter does with the write-only token: it
// reports that one is set and does not hand it back. A fake that returned the
// token would let a leak pass its own test.
func (f *fakeSettings) Get() Settings {
out := f.cur
out.ReportBotSet = f.cur.ReportBotToken != ""
return out
}
func (f *fakeSettings) Set(s Settings) error { f.cur = s; return nil }

// The cap is a number that stops work, so it has to survive a save and it has
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/api/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
} catch (e) {}
})();
</script>
<script type="module" crossorigin src="/assets/index-CkGNaCrc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AgZMyFDM.css">
<script type="module" crossorigin src="/assets/index-Cr7Ws9HV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JzCSeTjz.css">
</head>
<body>
<div id="root"></div>
Expand Down
35 changes: 35 additions & 0 deletions internal/api/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,38 @@ func TestEmptyPromptIsRefusedBeforeTheNetwork(t *testing.T) {
t.Error("an empty prompt reached the client")
}
}

// The bot token is the first write-only field in this API. GET /v1/settings is
// read on every settings render and by `caprock report`, so a credential must
// not ride along on either — what comes back is whether one is set.
func TestBotTokenIsWriteOnly(t *testing.T) {
e := newGeminiEnv(t)

cur := e.settings.Get()
cur.ReportBotToken = "123456:SECRET-BOT-TOKEN"
cur.ReportChatID = "-1009999"
if err := e.settings.Set(cur); err != nil {
t.Fatal(err)
}

res, err := http.Get(e.srv.URL + "/v1/settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var raw bytes.Buffer
_, _ = raw.ReadFrom(res.Body)

if bytes.Contains(raw.Bytes(), []byte("SECRET-BOT-TOKEN")) {
t.Fatalf("GET /v1/settings returned the bot token:\n%s", raw.String())
}
var out map[string]any
_ = json.Unmarshal(raw.Bytes(), &out)
// What a screen needs instead: that one is set, and where messages go.
if out["report_bot_set"] != true {
t.Errorf("report_bot_set should be true with a token stored: %v", out)
}
if out["report_chat_id"] != "-1009999" {
t.Errorf("the chat id is not a credential and should round-trip: %v", out["report_chat_id"])
}
}
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ type Config struct {
// default: a threshold nobody chose would eventually stop work for a
// reason its owner could not explain. See internal/cap.
CapUSDPerDay float64 `json:"cap_usd_per_day,omitempty"`
// ReportBotToken and ReportChatID configure the weekly report's delivery to
// the user's own Telegram bot.
//
// The token is stored here, unlike the Gemini key, and ADR-024 is where
// that difference is argued: a bot token drives a bot the user made for
// this, with no billing attached, while an AI Studio key spends money.
// Putting it in the environment instead would mean editing a launchd plist
// to turn on a feature sold as two minutes of setup. The file is 0600
// inside a 0700 data dir, and the token is never returned over HTTP.
ReportBotToken string `json:"report_bot_token,omitempty"`
ReportChatID string `json:"report_chat_id,omitempty"`
// BrowseRoot is where the folder picker may look. Empty means $HOME.
BrowseRoot string `json:"browse_root,omitempty"`
}
Expand Down
32 changes: 23 additions & 9 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,19 @@ type Daemon struct {
bus *bus.Bus
table *cost.Table
rec *rollup.Recorder
det *loop.Detector
tail *ingest.Tailer
ocIn *opencode.Ingester
mgr *agents.Manager
board *board.Board
orch *orchestrator.Orchestrator
api *api.Server
rt config.Runtime
start time.Time
// report holds the weekly report's last outcome, so a message that never
// arrived can be explained on the settings screen instead of being an
// absence nobody notices.
report reportState
det *loop.Detector
tail *ingest.Tailer
ocIn *opencode.Ingester
mgr *agents.Manager
board *board.Board
orch *orchestrator.Orchestrator
api *api.Server
rt config.Runtime
start time.Time

// cap is the daily spend guard. Nil until run() builds it, because it needs
// the owned-session manager.
Expand Down Expand Up @@ -384,6 +388,7 @@ func (d *Daemon) run(ctx context.Context) error {
}()
}
go d.sweep(ctx)
go d.weeklyLoop(ctx)
go d.backfillToolLinks(ctx)
if d.config().RetentionDays > 0 {
go d.pruneLoop(ctx)
Expand Down Expand Up @@ -863,6 +868,13 @@ func (a *settingsAdapter) Get() api.Settings {
LicenseKey: c.LicenseKey,
CapUSDPerDay: c.CapUSDPerDay,
BrowseRoot: c.BrowseRoot,
ReportChatID: c.ReportChatID,
// The token itself never crosses this boundary — only whether one
// exists, which is what a screen needs to render a state.
ReportBotSet: c.ReportBotToken != "",
ReportBotToken: c.ReportBotToken,
ReportLastError: a.d.reportLastError(),
ReportLastSentMs: a.d.reportLastSent(),
}
}

Expand All @@ -882,6 +894,8 @@ func (a *settingsAdapter) Set(in api.Settings) error {
capChanged := in.CapUSDPerDay != a.d.opt.Config.CapUSDPerDay
a.d.opt.Config.CapUSDPerDay = in.CapUSDPerDay
a.d.opt.Config.BrowseRoot = strings.TrimSpace(in.BrowseRoot)
a.d.opt.Config.ReportBotToken = strings.TrimSpace(in.ReportBotToken)
a.d.opt.Config.ReportChatID = strings.TrimSpace(in.ReportChatID)
cfg := a.d.opt.Config
a.d.cfgMu.Unlock()
if capChanged && a.d.cap != nil {
Expand Down
Loading
Loading