adolf: bearer-authenticate the agap MCP server, fix audio config schema

openclaw.json now sends `Authorization: Bearer ${AGAP_MCP_TOKEN}` to the agap
MCP server, which requires it as of kb#180. The token is injected from
openai/.env via docker-compose.yml and only substituted here, never inlined.
It maps to agent id `adolf`, which is also what the kb#147 vault gate reads.

Fixes tools.media.audio, which had been added but never restart-validated:
the per-entry `apiKey: "not-needed"` is rejected by the schema
("tools.media.audio.models.0: Invalid input"), and an invalid config makes the
gateway refuse to start outright -- adolf crash-looped on the first restart
after the block landed. The old comment claimed the schema requires a
non-empty apiKey; it is the opposite, apiKey is not a valid per-entry key at
all. Isolated with `openclaw config validate` against the running image
(2026.6.11): {provider, model} and {provider, model, baseUrl} validate, and
adding apiKey alone reproduces the failure. baseUrl is kept -- that is the
per-entry override pointing the openai-shaped provider at the local
faster-whisper server. Provider auth follows the normal model auth order per
docs/nodes/audio.md, and faster-whisper-server has no auth to satisfy anyway.

Two lessons encoded in the comments: `enabled: false` does NOT exempt an entry
from schema validation, and a config edit is not done until a restart boots
healthy -- this sat invalid but latent because the running gateway still held
an older loaded config. The block stays enabled: false; turning STT on is
still a kb#175/#191 decision (GTX 1070 co-residency).

Also adds the proactive-prioritization and todoist-capture design notes and
the vw-mcp prototype.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 04:41:13 +00:00
parent fc4e1c75ed
commit a5c625b9b6
12 changed files with 1049 additions and 3 deletions

View File

@@ -0,0 +1,345 @@
# DESIGN — Proactive action impact/cost prioritization (kb#123)
Status: **draft, for review** — written 2026-07-22
Owner: alvis · Written with Claude
Scope: kanboard Adolf task **#123**. This is a design/ruleset only — no wiring
into `openclaw.json`, no code. Claude implements it in a follow-up task.
Related, not duplicated here:
- **#122** (utility/ROI metrics via Langfuse) — that's the after-the-fact
"was Adolf worth it" readout across all of Adolf's spend. This doc is the
**before-the-fact gate** on one specific class of spend: proactive sends.
#122's Langfuse data is a candidate future input to the acceptance-rate term
below (§3.3), but this design does not depend on #122 landing first.
- **#124** (proactive cadence / cron schedule) — decides **when Adolf looks**
(daily/weekly/monthly audit cadence, adapted to quota). This design decides
**whether a specific candidate action fires** once #124 (or an ad-hoc
trigger, e.g. reacting to a calendar change) has already produced one.
#124 is the scheduler; this is the gate every candidate passes through
regardless of what triggered it.
- **#125** (feedback loop) — this design's acceptance-probability term (§3.3)
is a **consumer** of #125's feedback log. #125 is not built yet; §5 below
specifies exactly what it needs to log, as a dependency, not an assumption.
---
## 1. Problem
Adolf can generate a proactive message (reminder, nudge, digest item) from
several places: the cadence jobs in #124, a reactive trigger (calendar event
changed, HA sensor fired, a task went overdue), or background reasoning
noticing something. Not every candidate should be sent — some are low-value,
some are redundant with something already said, some cost real Kimi quota to
formulate and the window is nearly exhausted.
This design is a **gate function**: given a candidate proactive action, decide
fire / suppress / defer, using a score computed from four inputs the task
description names, plus a threshold that scales with remaining quota.
## 2. Where the gate sits
```
[trigger: #124 cadence job | reactive event | background noticing]
|
v
candidate proactive action
(draft content + metadata)
|
v
┌─────────────────────────┐
│ IMPACT/COST GATE │ <-- this design
│ (score, threshold) │
└─────────────────────────┘
| |
fire suppress / defer
| |
send via Matrix log decision + reason
log outcome slot (no send; feedback loop
for #125 has nothing to attach to)
```
The gate is a pure function of the candidate + current state. It does not
decide *what* to consider sending (#124's job) or *how* to learn from
responses (#125's job) — only whether a given candidate clears the bar right
now.
## 3. Scoring model
For each candidate action `a`, compute:
```
score(a) = (benefit(a) * accept_prob(a) * urgency(a)) / cost(a)
```
Ratio form, not a weighted sum: cost is a genuine denominator (token cost is
literally what you're trading against benefit), and the three numerator terms
are gates on each other, not additive alternatives — a high-benefit,
low-acceptance-probability action *should* be suppressed even if urgency is
high, not partially compensated the way a sum would allow. All four terms are
normalized to comparable ranges as defined below so no single term dominates
by scale alone.
### 3.1 Benefit — `benefit(a) ∈ [0, 1]`
"How important is this to the user, if accepted." Estimated by Adolf itself
(the model doing the drafting) using a small fixed rubric — this is a
judgment call, not a measurement, so keep the rubric coarse enough to be
stable across repeated runs:
| Band | Value | Examples |
|---|---|---|
| Critical | 1.0 | Hard deadline today/tomorrow, safety/health-adjacent, financial penalty if missed |
| High | 0.7 | Real deadline this week, blocks another person, irreversible if missed |
| Medium | 0.4 | Useful reminder, no hard deadline, low cost if ignored |
| Low | 0.15 | "Might be nice to know", trivia-adjacent, no consequence |
Adolf assigns the band as part of drafting the candidate (one extra field in
the same generation pass — no separate LLM call). This is inherently noisy;
it is corrected over time by the acceptance-probability term (§3.3), which is
grounded in actual logged outcomes rather than self-assessment.
### 3.2 Cost — `cost(a) ∈ (0, 1]`, token cost normalized
Raw cost is estimable *before* sending: `tokens_estimate(a)` = prompt tokens
to formulate (system + context already loaded for the turn, since it's
piggy-backing on an existing generation) + estimated output tokens for the
message itself. For a message that requires its **own** dedicated Kimi call
(true incremental cost) vs. one riding along inside an already-scheduled
cadence turn (near-zero marginal cost), these are very different costs — the
estimate must distinguish "marginal call I wouldn't otherwise make" from
"free byproduct of a call happening anyway":
```
cost_tokens(a) = marginal_prompt_tokens(a) + marginal_output_tokens(a)
```
where `marginal_*` is 0 (or near-0, e.g. a few output tokens) if the action
rides inside a scheduled #124 audit turn that would run regardless, and the
full call cost if it requires spinning up a fresh Kimi turn.
Normalize against a reference ceiling (a "typical expensive proactive send",
empirically ~2-3K tokens per the #122 baseline measurements of ~32.8K for a
full reply turn — a standalone proactive nudge should be far cheaper than a
full conversational turn, since it's one-directional with no back-and-forth):
```
cost(a) = clamp(cost_tokens(a) / COST_CEILING, floor=0.05, cap=1.0)
```
`COST_CEILING` = 3000 tokens (tunable constant, revisit once #122 gives real
distributions). The 0.05 floor stops a literally-free riding action from
dividing by ~0 and producing a runaway score — even "free" actions carry some
opportunity cost (attention, message-count against the 60/5h ceiling, not
just tokens).
### 3.3 Acceptance probability — `accept_prob(a) ∈ [0, 1]`
**This term has no data source yet.** It depends entirely on #125 (feedback
loop) being built and logging outcomes. Until then, use a flat prior:
```
accept_prob(a) = 0.5 # uninformative prior, pending #125
```
Once #125 logs `(action_class, outcome)` pairs (see §5's exact schema
requirement — this design does not invent history, it specifies what must
exist), compute a per-class empirical rate with Laplace smoothing so a class
with zero or few samples doesn't overfit to noise:
```
accept_prob(class) = (accepted_count(class) + 1) / (total_count(class) + 2)
```
`class` is a coarse bucket, not per-message: e.g. `{calendar_reminder,
task_overdue, ha_anomaly, family_wiki_gap, digest_item, ...}` — one row per
class, not per exact message text, since exact-text history rarely repeats
but the class does. A candidate's class is assigned at draft time (same pass
as §3.1's benefit band).
Recency matters more than total count — a user who started dismissing
`calendar_reminder` last week should pull that class's rate down faster than
five-year-old acceptances prop it up. Use a decayed count (e.g. half-life of
30 days, or simply windowing to the trailing N=50 outcomes per class) rather
than an all-time average, once enough volume exists to make decay
meaningful.
### 3.4 Urgency — `urgency(a) ∈ [0, 1]`
Distinct from benefit: benefit is "how much it matters", urgency is "how soon
it stops being actionable". A time-decay curve against the nearest relevant
deadline (`due_at`) known for the candidate (calendar event start, task
`date_due`, HA-derived risk window):
```
hours_to_deadline = (due_at - now) in hours
urgency(a) =
1.0 if hours_to_deadline <= 1
1.0 - 0.6 * (h - 1) / 23 if 1 < h <= 24 (1.0 -> 0.4 over the day)
0.4 * exp(-(h - 24) / 168) if h > 24 (decays over the following week)
0.2 if no deadline (informational-only action)
```
Concretely: something due within the hour scores 1.0, something due
tomorrow ~0.4-1.0 depending on how close, something a week out trails off
toward the 0.2 floor for undated nudges. This is a simple monotonic decay, not
a precise model — tune the constants once real cadence data exists (#124).
### 3.5 Putting it together
```
score(a) = (benefit(a) * accept_prob(a) * urgency(a)) / cost(a)
```
Range: numerator ∈ [0, 1], denominator ∈ [0.05, 1], so
`score(a) ∈ [0, 20]` in the degenerate cheapest/most-urgent/most-beneficial
case. In practice, typical scores cluster well below that ceiling — the
threshold (§4) is calibrated empirically against observed scores, not derived
analytically from the range.
## 4. Firing rule
```
fire(a) iff score(a) >= threshold(current_quota_state)
else suppress(a) # or defer(a), see below
```
### 4.1 Quota signal
Read `adolf-llm:8010/usage` (confirmed live shape, sampled 2026-07-22):
```json
{
"weekly": {"pct": 32, "used": 32, "limit": 100, "remaining": 68, "resets": "..."},
"window_5h":{"pct": 79, "used": 79, "limit": 100, "remaining": 21, "resets": "..."},
"stale": false
}
```
Use the **tighter** of the two windows — whichever pct is higher is the
binding constraint right now:
```
quota_pressure = max(weekly.pct, window_5h.pct) / 100 # ∈ [0, 1]
```
If `stale: true` (Kimi login/session broken, per the adolf-llm fallback
behavior), treat as `quota_pressure = 1.0` (most conservative) — an unknown
quota state should suppress non-critical sends, not fire them.
### 4.2 Threshold as a function of quota pressure
```
threshold(quota_pressure) = T_BASE + (T_MAX - T_BASE) * quota_pressure^2
```
- `T_BASE` = 0.3 — threshold when quota is abundant (pressure ~0): let most
medium-benefit things through.
- `T_MAX` = 3.0 — threshold when quota is nearly exhausted (pressure ~1):
only near-maximal score (critical benefit, high acceptance history, urgent,
cheap) still fires.
- Squaring `quota_pressure` keeps the threshold flat and permissive through
low-to-mid pressure (nothing changes until quota actually gets tight) and
then rises steeply as the window approaches exhaustion — matching the
actual failure mode (403 usage-limit) which is a cliff, not a slope.
This gives a single tunable curve with two constants, both revisitable once
#122 supplies real score distributions and false-negative/positive rates.
### 4.3 Fire / suppress / defer
- **fire**: send now.
- **suppress**: below threshold and no deadline pressure — drop it. Log the
decision (§5) but do not re-surface it later; if it's still relevant, the
next cadence pass (#124) will regenerate it as a fresh candidate with
updated urgency.
- **defer**: below threshold *only because of quota pressure*, but
`urgency(a) >= 0.8` (i.e., something time-critical got starved by a quota
cliff, not by low benefit). Requeue for immediate re-evaluation once
`quota_pressure` drops (next window reset, per `resets` timestamp in the
usage payload) rather than silently dropping it. This is the one exception
to "gate is stateless" — a deferred item carries state (its own candidate
record) until it either fires or its deadline passes, at which point it is
logged as a missed/expired suppression, not silently lost.
## 5. Dependency: what #125's feedback log must contain
This design's accept_prob term (§3.3) is inert without it. #125 owns building
the collection mechanism (reactions, "+/-/неактуально" replies); this design
only specifies the **shape** the gate needs to consume, so the two tasks
don't diverge on schema:
```
proactive_outcome {
action_class: string # matches the class taxonomy in §3.3, e.g. "calendar_reminder"
sent_at: timestamp
benefit_band: float # the benefit(a) value used at send time, for later calibration
cost_tokens: int # actual cost, for calibrating COST_CEILING
urgency_at_send: float
outcome: enum { accepted, dismissed, ignored, irrelevant }
responded_at: timestamp | null
}
```
`ignored` (no response within some window, e.g. 24h) must be distinguished
from `dismissed` (explicit "") — an ignored item is weaker negative signal
than an explicit rejection and should decay the acceptance rate less
aggressively. Without this distinction the Laplace-smoothed rate in §3.3
conflates "user didn't care" with "user was just busy."
Every **suppressed** and **deferred** candidate should also be logged (not
just fired ones) with `outcome: not_sent` — this is what lets a later audit
(#122) compute false-suppression rate (was a suppressed item actually needed?
only knowable in hindsight, e.g. if the same underlying deadline later caused
a problem) as well as false-fire rate.
## 6. Worked example
Candidate: "reminder that the Seafile SSL cert renews in 3 days" (from a
#124 daily cadence audit, riding along inside that scheduled call).
- `benefit`: Medium band → 0.4 (annoying if missed, not critical — auto-renew
likely already configured, this is a check not a fire drill).
- `cost`: marginal — rides inside the already-running daily audit call, say
~150 marginal output tokens → `150/3000 = 0.05` → floored at 0.05.
- `accept_prob`: no #125 data yet → flat prior 0.5.
- `urgency`: `hours_to_deadline` = 72h → falls in the `h > 24` branch:
`0.4 * exp(-(72-24)/168) = 0.4 * exp(-0.286) ≈ 0.4 * 0.751 ≈ 0.30`.
`score = (0.4 * 0.5 * 0.30) / 0.05 = 0.06 / 0.05 = 1.2`
At `quota_pressure = 0` (abundant quota), `threshold = 0.3` → **1.2 ≥ 0.3,
fires.** At `quota_pressure = 1` (window nearly exhausted, matching the
measured 79% 5h-window sample above, rounding up toward the cliff),
`threshold = 3.0`**1.2 < 3.0, suppressed** (not deferred: urgency 0.30 is
well under the 0.8 defer bar) — correctly deprioritized under quota pressure
in favor of anything more urgent or already proven to land well.
## 7. Open parameters to tune post-implementation
Everything with a concrete numeric constant above (`COST_CEILING`, `T_BASE`,
`T_MAX`, the urgency decay constants, the defer bar) is a starting guess
consistent with the measurements already on hand (#122's token baseline, the
live `/usage` sample). None of it is load-bearing on the *shape* of the
model — only on where the dial sits. Revisit once:
- #125 supplies real `accept_prob` data (replacing the flat 0.5 prior is the
single highest-value follow-up — everything else is a reasonable guess,
this term is currently a placeholder).
- #122's Langfuse integration supplies real per-action token costs to
recalibrate `COST_CEILING`.
- A few weeks of fire/suppress/defer logs (§5) exist to check the threshold
isn't systematically over- or under-firing.
## 8. Acceptance check against kb#123
- Scoring formula with each term defined, ranged, and its estimation method
stated: §3.
- Firing rule (fire only if impact/cost exceeds a threshold): §4.
- Threshold tunable to current quota, against the real `adolf-llm:8010/usage`
signal: §4.1-4.2.
- Acceptance-probability term flagged as dependent on unbuilt history
(#125), with the exact log schema it needs specified rather than
fabricated: §3.3, §5.
- Cross-references to sibling tasks #122, #124, #125 without duplicating
their scope: header + inline.

View File

@@ -0,0 +1,237 @@
# DESIGN — Todoist capture + AI classification (kb#170)
Status: **v1, components 1/2 built + proven; components 3/4 designed, not
built** — written 2026-07-23
Owner: alvis · Written with Claude
Scope: kanboard Adolf task **#170**. Related, not duplicated: Welfare
#102 (proactive-secretary umbrella), #105 (idea capture, pre-Todoist
version of the same need), #106 (people/events reminders).
---
## 1. Why Todoist, and why encoder-only classification
Todoist becomes Adolf's **inbox for ideas and quick tasks** — the place a
thought gets captured immediately, before it's clear whether it's a
one-liner or a project. Two things must stay true per
`DESIGN-a2a-agents.md` v2.1:
- **No metered API by default** (§3a): classification must not spend a
Kimi/gemma turn per capture. The stack already keeps **bge-m3** resident
(Hindsight's embedder, never-evict, `model-registry.yaml`) — reusing it
for classification is ~0 marginal cost, same reasoning §3a already
applies to LiteLLM's Auto Router.
- **Native commands run before the agent** (proven pattern:
`quota-command-openclaw-plugin`, kb#62): a `/idea` command that never
invokes Kimi at all keeps the entire capture path — not just the
classification step — off the metered/quota-gated path.
So: **nearest-centroid classification over bge-m3 embeddings**, not a
classifier LLM call and not hard tag rules. This is genuinely an
encoder-only model in the literal sense (bge-m3 is an encoder, not a
generative LLM) — no fine-tuning, no training loop, because **no labelled
dataset exists** (inventing one would be guessing scope that wasn't
asked for). The "training data" is a small, git-editable exemplar list per
class (`agap-mcp/src/classifier.js`) — extending accuracy later means
adding exemplars, not retraining.
## 2. Architecture
```
Matrix "/idea <текст>" Adolf prose ("запомни идею...")
| |
v v
todoist-capture-plugin Adolf (Kimi) -> MCP tool call
(native command, 0 Kimi calls) todoist_capture_idea (agap-mcp)
| |
+--------------------+-------------------+
v
POST /capture-idea (agap-mcp, plain REST)
|
v
classifier.js: embed(text) via bge-m3
(1 embedding call, reused for all 3 axes)
|
+----------------+----------------+
v v v
area centroid urgency centroid decompose centroid
(5 classes) (3 classes) (2 classes)
| | |
+----------------+------------------+
v
capture.js: label + priority mapping
|
v
todoistCreateTask() -> real Todoist task
labels: area-*, urgency-*, [decompose], [area-uncertain]
```
Two entry points converge on one pipeline (`capture.js`'s
`todoistCaptureIdea`), reachable either as an MCP tool
(`todoist_capture_idea`, for Adolf's/Claude's model-driven path — "запомни
идею: ...") or as a plain REST route (`POST /capture-idea`, for the native
`/idea` command, which cannot speak MCP JSON-RPC). Both call the exact
same function — no duplicated classification/creation logic.
## 3. Component 2 — AI classification (built, proven)
Three independent axes per idea, one bge-m3 embedding shared across all
three:
| Axis | Classes | Source of exemplars |
|---|---|---|
| **area** | `adolf`, `welfare`, `дом`, `семья`, `здоровье` (kb#170 spec, verbatim) | `AREA_EXEMPLARS` |
| **urgency** | `high`, `medium`, `low` | `URGENCY_EXEMPLARS` |
| **decompose** | `simple-task`, `needs-decomposition` | `DECOMPOSE_EXEMPLARS` |
Classification = cosine similarity of the idea's embedding against each
class's centroid (mean of that class's exemplar embeddings), argmax per
axis. Each result also carries a **margin** (gap between the top two
scores) and an `ambiguous: true` flag when the margin is small
(< 0.03, an empirical starting threshold — same "tune later" posture as
`DESIGN-proactive-prioritization.md`'s constants). Ambiguous area
classifications get an extra `area-uncertain` label instead of being
silently forced — component 4 (periodic review) is where a human
resolves them, not an auto-retry on a bigger model (consistent with
`DESIGN-a2a-agents.md` §5's always-ask escalation policy, scaled down:
this isn't a costly/irreversible action, so the "escalation" here is just
a label, not a blocking gate to alvis's inbox).
**Proven** (`agap-mcp/src/classifier.test.mjs`, run against the real,
live bge-m3 at `:11436` — 7/7 pass): area/urgency/decompose all resolve
sensibly on hand-written Russian idea text spanning all 5 areas, both
urgency bands, and both decompose classes. `capture.test.mjs` (6/6 pass)
proves the label/priority/project mapping with a **stubbed** Todoist
client — no test data was written to the real Todoist account while
proving this out.
### 3.1 Project vs. label mapping — a decision made, not guessed
Todoist's real, live projects today (`todoist_list_projects`, confirmed
2026-07-23): `Inbox`, `One-Off`, `Family`, `Planning`, `Pending`. These do
**not** line up with the 5 kb#170 areas except `семья``Family`.
Creating four new Todoist projects (`Adolf`, `Welfare`, `дом`,
`здоровье`) to match would be a structural change to the user's real
Todoist account — **not done here without sign-off** (see §6, open
question 1). Instead, v1 uses **labels** (`area-*`, `urgency-*`,
`decompose`, `area-uncertain`) for every axis — purely additive and
reversible (Todoist auto-creates labels on first use; deleting a label
loses no task data) — and auto-routes to an existing project only for the
one unambiguous match (`семья``Family`), never inventing a project
selection the classifier merely guessed at.
## 4. Component 1 — capture command (built, not activated)
`openai/todoist-capture-plugin/` — same shape as `quota-command-openclaw-
plugin` (kb#62): `definePluginEntry` + `api.registerCommand({ name:
"idea", acceptsArgs: true, requireAuth: true, handler })`. Verified
against the real `PluginCommandHandler`/`PluginCommandContext`/
`OpenClawPluginCommandDefinition` types in the OpenClaw source
(`/home/alvis/adolf/src/plugins/types.ts`) — `ctx.args` is the raw string
after `/idea`, handler returns `{ text, suppressReply }`.
`requireAuth: true` (default) keeps it behind the same Matrix DM allowlist
(`channels.matrix.dm.allowFrom`) gating every other Adolf interaction — no
new privilege tier, since creating a Todoist task in the operator's own
inbox isn't a privileged/destructive action.
**Wired but not live**: `docker-compose.yml` gets the read-only bind mount
(same pattern as `quota-command`/`hindsight-memory`/`kimi-quota-footer`),
`openclaw.json` gets `plugins.entries.todoist-capture.enabled: true`, and
`agap-mcp/src/server.js` gets the `POST /capture-idea` route the plugin
calls. All three are plain config/code edits, proven end-to-end with a
stub Todoist client (see kb#170 report) — but **none of this takes effect
until the adolf container is restarted** (same activation gate every prior
plugin in this repo has hit: bind-mounts and `plugins.entries` are read at
process start). That restart is the kb#170 handoff — see report.
## 5. Component 3 — sync with services (designed, not built)
kb#170's spec: "идеи из Todoist синхронизируются с Kanboard, календарём,
проектами." This is underspecified enough that building a concrete
bidirectional sync now would be guessing scope (direction? conflict
resolution? which Todoist state maps to which Kanboard column?) rather
than following it. **v1 proposal, one-way, human-gated — not built yet:**
- Todoist is the **source of truth for the idea itself** (text, labels,
done/not-done). Kanboard is the source of truth for **anything that
became real, tracked work**.
- Sync fires only from component 4's periodic review (§6), not on a
schedule or webhook: when Adolf proposes "this idea is ready to become
work" and the human agrees, Adolf creates **one** Kanboard task whose
description contains a `context ref` back to the Todoist task id (per
`DESIGN-a2a-agents.md` §2's "context travels by reference" rule — no
content duplication) and the Todoist task gets a `kanboard-<id>` label
and stays open until the Kanboard task closes.
- **No calendar sync is proposed in v1.** A Todoist due-date does not
imply a calendar event (most captured ideas won't have a real due
time), and the reverse (creating calendar entries from arbitrary idea
due-dates) risks cluttering Radicale with noise. Calendar involvement
belongs with Welfare #106's people/events reminders design, not
invented here.
- **No two-way Kanboard→Todoist sync.** Completing the Kanboard task does
not need to close the Todoist item automatically for v1 — a human
glancing at Todoist can see the `kanboard-<id>` label and close it
manually; automating that closure is a small, safe follow-up once the
one-way direction above is live and observed, not blocking v1.
This keeps sync a **consequence of the human-gated review** (§6), never
an autonomous background writer to three services at once — consistent
with `DESIGN-a2a-agents.md` §5's always-ask posture for anything crossing
a service boundary. Building this is out of scope for this pass; flagged
in the kb#170 report as a natural follow-up task once components 1/2 are
live and real capture data exists to review.
## 6. Component 4 — periodic review (designed, not built)
"Adolf предлагает, какие идеи созрели для превращения в задачи" — this is
explicitly Adolf proposing, not auto-converting; matches
`DESIGN-a2a-agents.md` §5's always-ask escalation policy exactly (a
decision task to alvis's inbox, not a silent action). Proposed shape,
**not implemented**:
- A low-priority proactive cadence job (same shape as Welfare #124's
cadence design, once that lands) that runs `todoist_list_tasks` scoped
to labels `decompose` or `area-uncertain`, drafts a short proposal per
candidate ("эта идея выглядит готовой к декомпозиции — завести задачу
в Kanboard?"), and sends it via Matrix — same impact/cost gate as
`DESIGN-proactive-prioritization.md` (kb#123) should apply here too,
once that gate exists, rather than inventing a second one.
- On accept: component 3's one-way sync (§5) fires for that one idea.
- On dismiss: the idea's `decompose`/`area-uncertain` label is cleared so
the same candidate doesn't re-surface every cadence run.
Not built now because it depends on #124 (cadence) and, for a well-
calibrated gate, #123 (impact/cost gate) — both cited as dependencies
rather than duplicated, same posture `DESIGN-proactive-prioritization.md`
itself takes toward its own siblings.
## 7. What's built vs. handed off
| Piece | State |
|---|---|
| `agap-mcp/src/classifier.js` + test | Built, proven live against bge-m3 |
| `agap-mcp/src/capture.js` + test | Built, proven with a stubbed Todoist client (no real writes) |
| `agap-mcp/src/server.js`: `todoist_capture_idea` MCP tool + `POST /capture-idea` | Built; **not live** — needs an agap-mcp rebuild/restart (already true of the whole Todoist tool surface per kb#170 orchestrator note) |
| `adolf/openclaw.json`, `openai/shared-mcp.json`, `openai/agent-registry.yaml` | Edited, `validate_capability_grants.py` passes clean at both layers |
| `openai/todoist-capture-plugin/` (`/idea` command) | Built, HTTP contract proven with a local harness; **not live** — needs the adolf container restarted (bind mount + `plugins.entries` already wired) |
| Component 3 (sync) | Designed (§5), not built — genuine scope decisions flagged, not guessed |
| Component 4 (periodic review) | Designed (§6), not built — depends on Welfare #123/#124 |
## 8. Open questions for alvis (not guessed)
1. **Todoist project structure**: keep the label-only v1 (§3.1), or
create dedicated Todoist projects per area? The latter is a real,
visible change to the account structure and needs explicit sign-off.
2. **Activation**: rebuilding agap-mcp (its own repo/compose,
`agap_git/agap-mcp/docker-compose.yml`, `build: .` — the `Dockerfile`
`COPY`s `src/` into the image, no bind mount, so a code change needs a
rebuild, not just a restart) and restarting the adolf container (its
compose is `agap_git/openai/docker-compose.yml`, to re-read
`openclaw.json` and pick up the new plugin bind mount) are the two
outward-facing steps this task deliberately did not take. Exact
commands, once approved:
```
cd /home/alvis/agap_git/agap-mcp && docker compose build && docker compose up -d
cd /home/alvis/agap_git/openai && docker compose up -d adolf
```

View File

@@ -11,6 +11,16 @@ gateway configuration**.
the `openai` compose project's own tree.
- Model backend: `adolf-llm` container (Kimi-CLI wrapper) on `:8010`
## Memory
Adolf's long-term memory is being **migrated from Cognee to Hindsight** (a single
self-hosted container, `:8888` REST + built-in MCP, `:9999` UI). It stays wired in
the same two ways: as a **tool** (Hindsight's built-in MCP in `openclaw.json`
`mcp.servers.hindsight`) and as **forced hooks** (the `hindsight-memory` OpenClaw
plugin: `before_prompt_build`→recall inject, `agent_end`→retain). Authoritative
plan and target architecture: **[`HINDSIGHT-MIGRATION.md`](./HINDSIGHT-MIGRATION.md)**
(kanboard *Adolf* tasks H1H5). Until those land, the running stack is still Cognee.
## Config source of truth
The gateway config is **`openclaw.json` in this directory**. It is bind-mounted
@@ -109,3 +119,32 @@ To **revoke** access, remove the ID from `allowFrom` and restart.
> Matrix accounts themselves are created on the Synapse homeserver
> (`mtx.alogins.net`) — see the AgapHost wiki **Matrix** page. The allow-list
> here only controls which existing Matrix users Adolf will talk to.
## Matrix device identity (kb#67)
Adolf's Matrix login used **password auth with no pinned `device_id`**
(`MATRIX_PASSWORD` in `openai/.env`). Every time OpenClaw's own credential
cache (in the `adolf-state` volume) was missing — first boot, a lost/rebuilt
volume — a fresh password login minted a **brand-new Matrix device** with no
cross-signing, leaving dead ghost devices behind and risking new encrypted
DMs getting keys shared to a device that no longer exists.
Fix: `openai/.env` now also pins `MATRIX_ACCESS_TOKEN` + `MATRIX_DEVICE_ID` to
Adolf's current live device (`TIANDTKUZJ`, cross-signed; token in Vaultwarden
as `MATRIX_ADOLF_GATEWAY_TOKEN`). OpenClaw's matrix extension prefers a
configured access token over password login
(`extensions/matrix/src/matrix/client/config.ts` `resolveMatrixAuth`), so as
long as that token stays valid, restarts — even after a volume loss — reuse
the same device instead of minting a new one. `MATRIX_PASSWORD` stays set as
a manual-recovery fallback only (unset `MATRIX_ACCESS_TOKEN` to force a fresh
password login if the token is ever revoked).
Cross-signing for `@bot` is already bootstrapped automatically by OpenClaw's
matrix extension (`extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts`) —
no separate setup needed.
If the pinned token is ever revoked/rotated, get a fresh one bound to the
*same* device by logging in with `device_id` explicitly set to `TIANDTKUZJ`
(Synapse reuses an existing device when its id is given in `/login`, instead
of creating a new one), then update `MATRIX_ACCESS_TOKEN` in `openai/.env`
and Vaultwarden's `MATRIX_ADOLF_GATEWAY_TOKEN`.

View File

@@ -29,6 +29,71 @@
},
},
// Audio understanding (kb#175, STT source 2: "local STT on Agap").
// OpenClaw's bundled media-understanding pipeline (docs/nodes/media-
// understanding.md) auto-transcribes inbound audio attachments before the
// agent turn runs -- no plugin code needed, this is pure config. A voice
// note sent to Adolf over the existing Matrix DM (source 1, see below)
// gets transcribed by the `openai`-shaped entry below, which is redirected
// via baseUrl/apiKey to the LOCAL faster-whisper server (openai/docker-
// compose.yml's `faster-whisper` service, same compose project as this
// container, reachable by service name) instead of hosted OpenAI --
// confirmed supported via src/media-understanding/runner.entries.ts's
// per-entry baseUrl override (docs/gateway/config-tools.md "Tools and
// custom providers"). apiKey is a dummy: faster-whisper-server has no
// auth, but OpenClaw's schema requires a non-empty value.
//
// ⚠️ NOT ACTIVATED YET (2026-07-26): the `faster-whisper` container does
// not exist (never started -- `docker ps -a` shows no such container).
// Starting it opens a 4th tenant on the single 8GB GTX 1070 already at
// ~6.2GB with bge-m3 + gemma3:4b + tei-reranker (~1.8GB headroom) and
// could evict tei-reranker, silently breaking Hindsight recall (see
// DESIGN-a2a-agents.md §3b and kb#191, which is the unimplemented
// residency-guard/VRAM-alert task -- still in Backlog). This config
// block is deliberately inert until #191 lands or alvis explicitly
// accepts the co-residency risk; `enabled: false` below.
// SCHEMA FIX 2026-07-30 (kb#180 restart). This block previously carried a
// per-entry `apiKey: "not-needed"`, which the schema REJECTS:
// tools.media.audio.models.0: Invalid input
// and an invalid config makes the gateway refuse to start outright -- adolf
// crash-looped on the first restart after the block was added. Two lessons
// encoded here: `enabled: false` does NOT exempt an entry from schema
// validation, and a config edit is not "done" until a real restart boots
// healthy (this block sat invalid but latent because the running gateway
// still held an older loaded config).
//
// The old comment claimed the schema "requires a non-empty apiKey" -- it is
// the opposite: `apiKey` is not a valid per-entry key at all. Verified with
// `openclaw config validate` against this image (2026.6.11): entries with
// {provider, model} and {provider, model, baseUrl} validate; adding
// `apiKey` is the sole cause of the failure. Per docs/nodes/audio.md,
// provider auth follows the normal model auth order (auth profiles, env
// vars, models.providers.*.apiKey) -- and faster-whisper-server has no auth
// to satisfy anyway, so no key belongs here. `baseUrl` is kept: that is the
// per-entry override that redirects the `openai`-shaped provider to the
// LOCAL faster-whisper server.
tools: {
media: {
audio: {
// Still deliberately inert: the `faster-whisper` container does not
// exist, and starting it opens a 4th tenant on the 8GB GTX 1070 (see
// the kb#191 residency-guard note above). This block is now merely
// SCHEMA-VALID rather than boot-breaking; flipping this to true is a
// separate decision that still belongs to kb#175/#191.
enabled: false,
echoTranscript: true, // let the sender see what Adolf heard before it acts
echoFormat: '📝 "{transcript}"',
models: [
{
provider: "openai",
model: "deepdml/faster-whisper-large-v3-turbo-ct2", // must match WHISPER__MODEL in openai/docker-compose.yml
baseUrl: "http://faster-whisper:8000/v1",
},
],
},
},
},
// Browser tool — bundled plugin, off by default. Enables a dedicated,
// agent-only headless Chromium profile ("openclaw") driven through the
// gateway's loopback control service. Chromium is already in the image
@@ -146,9 +211,27 @@
// of which Adolf's Matrix persona drives turn-to-turn; reach the
// hindsight MCP directly (unscoped) for that admin work instead of
// paying for it on every Adolf turn. 29 tools -> 9.
//
// kb#169: this raw MCP surface previously pointed at /mcp/adolf/ --
// the single unpartitioned bank with content from every human's
// conversations. #153 scopes the hindsight-memory PLUGIN's
// recall/retain hooks by interlocutor, but this MCP tool surface is a
// second, independent path to memory that #153 does not touch: a
// model call to e.g. `recall` here bypassed interlocutor scoping
// entirely. Repointed to /mcp/adolf-shared/ (option 2 of #169) --
// the household-shared bank (0 facts as of 2026-07-26, pre-existing
// per agent-registry.yaml's memory.banks target list). This surface
// can now only ever read/write the shared bank, never a private one,
// regardless of which human is talking to Adolf -- safe by
// construction, no dependency on #153 landing first. Option 1 (drop
// entirely) was not chosen because, until #153's scoped plugin tools
// land, this is still Adolf's only path for explicit "remember
// this"/"what do you recall" turns; option 3 (dynamic per-interlocutor
// bank selection) is not supported -- this config's url is a single
// static bank_id per MCP server entry, not a per-request parameter.
hindsight: {
type: "http",
url: "http://hindsight:8888/mcp/adolf/",
url: "http://hindsight:8888/mcp/adolf-shared/",
toolFilter: {
include: ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"],
},
@@ -210,7 +293,7 @@
},
},
// agap-mcp (kb#64) -- the SAME shared agap-mcp instance Claude Code uses
// (network_mode: host, :3100, unauthenticated on localhost). Grants Adolf
// (network_mode: host, :3100, bearer-authenticated since kb#180). Grants Adolf
// the same access as Claude: vault (vw_* for credential fetching) plus
// gitea/ha/zabbix/radicale. Reached via host.docker.internal like
// kanboard/marketplace above.
@@ -228,8 +311,24 @@
agap: {
type: "http",
url: "http://host.docker.internal:3100/mcp",
// kb#180: agap-mcp's listener is authenticated now (DESIGN §4 --
// :3100 is host-networked and the LAN carries VPN peers, so an
// open JSON-RPC listener handed ha_call_service/wiki_edit/todoist
// writes to anyone). Same pattern as marketplace above:
// AGAP_MCP_TOKEN lives in Vaultwarden, is injected into this
// container via openai/.env -> docker-compose.yml, and is only
// substituted here -- never inlined. The token maps to agent id
// `adolf` in AGAP_MCP_AGENT_TOKENS, which is also what the kb#147
// vault gate reads to allow vw_* (adolf = trust_class trusted).
headers: {
Authorization: "Bearer ${AGAP_MCP_TOKEN}",
},
toolFilter: {
include: ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"],
// kb#170: added todoist_capture_idea (classify + create in one
// call — see agap-mcp/src/capture.js) so Adolf's proactive-
// secretary persona can capture+tag an idea in one tool call
// instead of list_projects+create_task+manual tagging.
include: ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "todoist_capture_idea", "wiki_search", "wiki_read", "wiki_edit"],
},
},
},
@@ -280,6 +379,15 @@
"kimi-quota-footer": {
enabled: true,
},
// Todoist idea capture (kb#170 component 1) — installed external
// plugin, bind-mounted read-only from openai/todoist-capture-plugin
// (see that project's docker-compose.yml adolf.volumes) onto
// .openclaw/extensions/todoist-capture. Registers a `/idea` native
// command; no hooks (no allowConversationAccess/allowPromptInjection
// needed) — it POSTs straight to agap-mcp's /capture-idea route.
"todoist-capture": {
enabled: true,
},
},
},
}

28
adolf/vw-mcp/.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Copy to .env (git-ignored) and fill in real values before `docker compose up`.
# This server is intentionally narrow: read-only vw_* tools, a dedicated bot
# vault identity, and its own bearer token. See CLAUDE.md / kb task #64 for
# the full architecture and the sensitive setup steps (creating the bot user
# and the "Adolf" collection) that are NOT done by this scaffolding.
# Port this server listens on. 3100=agap-mcp, 3101=marketplace-mcp,
# 3103=kanboard-mcp, 3104=kanboard-mcp-adolf — 3105 verified free at write time.
PORT=3105
# Local Vaultwarden instance (NOT bitwarden.com). Unlike agap-mcp/
# marketplace-mcp, this service owns a fresh BITWARDENCLI_APPDATA_DIR volume
# with no pre-existing `bw config server`, so vaultwarden.js sets it on every
# boot from this var.
VW_URL=http://localhost:8041
# Dedicated bot identity — NEVER the master allogn@gmail.com account.
# Create this user in Vaultwarden first (sensitive step, reserved for the
# orchestrator — see report). Password: generate one and store it in
# Vaultwarden as item "ADOLF_VW_PASSWORD" (also a sensitive step).
BW_EMAIL=adolf-vault@auth.local
BW_PASSWORD=
# Bearer token gating /mcp, /sse, /messages (same pattern as
# marketplace-mcp). Generate with e.g. `openssl rand -hex 32`, store it in
# Vaultwarden as its own item (e.g. "VW_MCP_ADOLF_TOKEN"), and put the real
# value here — the line below is a PLACEHOLDER, not a usable secret.
VW_MCP_TOKEN=replace-with-output-of-openssl-rand--hex-32

2
adolf/vw-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
node_modules/

10
adolf/vw-mcp/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:22-slim
WORKDIR /app
RUN npm install -g @bitwarden/cli
COPY package.json ./
RUN npm install --production
COPY vaultwarden.js server.js ./
COPY start.sh ./
RUN chmod +x start.sh
CMD ["./start.sh"]

View File

@@ -0,0 +1,27 @@
name: vw-mcp-adolf
services:
vw-mcp-adolf:
build: .
container_name: vw-mcp-adolf
restart: unless-stopped
network_mode: host
env_file:
- .env
environment:
- BITWARDENCLI_APPDATA_DIR=/bw-data
- NODE_TLS_REJECT_UNAUTHORIZED=0
- HTTPS_PROXY=
- HTTP_PROXY=
- ALL_PROXY=
- https_proxy=
- http_proxy=
- all_proxy=
volumes:
# Dedicated, OWN volume — deliberately NOT the host bind mount
# (`/home/alvis/.config/Bitwarden CLI`) that agap-mcp/marketplace-mcp
# share, and NOT any other bw data dir. This bot's login/session state
# must never mix with the master account's or any other bot's.
- vw-mcp-adolf_bw-data:/bw-data
volumes:
vw-mcp-adolf_bw-data:

11
adolf/vw-mcp/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "vw-mcp-adolf",
"version": "1.0.0",
"type": "module",
"description": "Standalone, read-only MCP server giving Adolf a narrow slice of Vaultwarden (vw_get_password, vw_get_item, vw_list_items only), split out of agap-mcp per kb task #64",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"express": "^4.19.0",
"zod": "^3.23.0"
}
}

137
adolf/vw-mcp/server.js Normal file
View File

@@ -0,0 +1,137 @@
// vw-mcp-adolf — standalone, read-only Vaultwarden MCP server for Adolf (kb
// task #64).
//
// Gives Adolf a narrow, scoped slice of Vaultwarden WITHOUT exposing the
// master vault:
// - Only 3 read-only tools: vw_get_password, vw_get_item, vw_list_items.
// No write tools (vw_create_login / vw_update_password) exist here at
// all — omitted, not just unregistered, so there is no code path that
// could ever write to the vault.
// - Authenticates to Vaultwarden as a DEDICATED bot user
// (adolf-vault@auth.local), never the master account.
// - Server-side scoping is the real fence: that bot user is granted
// read-only access to a narrow "Adolf" collection only.
// - Every MCP transport requires `Authorization: Bearer $VW_MCP_TOKEN`,
// same pattern as marketplace-mcp (src/server.js) — refuses to start if
// VW_MCP_TOKEN is unset, so it can never silently run open. /health stays
// unauthenticated (no sensitive data, used for liveness checks).
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems } from './vaultwarden.js';
const PORT = parseInt(process.env.PORT || '3105');
// --- Init ---
async function init() {
await initVaultwarden();
}
// --- MCP server factory (one per session — McpServer can't share transports) ---
function ok(text) {
return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
}
function err(e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
function createServer() {
const server = new McpServer({ name: 'vw-mcp-adolf', version: '1.0.0' });
server.tool('vw_get_password', 'Get password for a Vaultwarden item by name (read-only; scoped to the Adolf collection)', { name: z.string() },
async ({ name }) => {
try { return ok(vwGetPassword(name)); } catch (e) { return err(e); }
});
server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes; read-only; scoped to the Adolf collection)', { name: z.string() },
async ({ name }) => {
try {
const item = vwGetItem(name);
return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
} catch (e) { return err(e); }
});
server.tool('vw_list_items', 'List Vaultwarden items visible to the Adolf bot user (read-only). Searches its personal vault by default; set org=true to search the org (only the Adolf collection is actually visible)', {
search: z.string().optional(),
org: z.boolean().optional(),
}, async ({ search, org }) => {
try {
const items = org ? vwListOrgItems(search) : vwListItems(search);
return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
} catch (e) { return err(e); }
});
return server;
}
// --- Auth gate ---
// This server holds real credentials (a narrow slice, but real), so every
// MCP transport requires a bearer token. VW_MCP_TOKEN lives in Vaultwarden
// (create it as its own item once the server is live) and is injected via
// docker-compose env — never hardcode it here. /health stays open (no
// sensitive data, used for liveness checks). If VW_MCP_TOKEN is unset the
// server refuses to start, so this can never silently run open.
const AUTH_TOKEN = process.env.VW_MCP_TOKEN;
if (!AUTH_TOKEN) {
console.error('VW_MCP_TOKEN env var is required (see docker-compose.yml / .env)');
process.exit(1);
}
function requireAuth(req, res, next) {
const header = req.get('authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (token !== AUTH_TOKEN) {
return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null });
}
next();
}
// --- HTTP server (Streamable HTTP + legacy SSE) ---
const app = express();
app.use(express.json());
const sseTransports = new Map();
// Streamable HTTP — stateless: fresh server per request, survives container restarts
app.all('/mcp', requireAuth, async (req, res) => {
try {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => transport.close());
await createServer().connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
console.error('MCP request error:', e.message);
if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
}
}
});
// Legacy SSE — kept for backward compatibility, same pattern as agap-mcp/kanboard-mcp
app.get('/sse', requireAuth, async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
await createServer().connect(transport);
});
app.post('/messages', requireAuth, async (req, res) => {
const transport = sseTransports.get(req.query.sessionId);
if (!transport) return res.status(400).send('Unknown session');
await transport.handlePostMessage(req, res);
});
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 3 }));
init()
.then(() => {
app.listen(PORT, () => console.log(`vw-mcp-adolf listening on :${PORT}`));
})
.catch(e => {
console.error('Init failed:', e.message);
process.exit(1);
});

2
adolf/vw-mcp/start.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec node server.js

100
adolf/vw-mcp/vaultwarden.js Normal file
View File

@@ -0,0 +1,100 @@
// Trimmed, read-only copy of agap-mcp/src/vaultwarden.js (kb task #64).
//
// Deliberate differences from the master copy:
// - Only the 3 read tools are implemented: get password, get item, list
// items. vwCreateLogin / vwUpdatePassword (and AI_COLLECTION, which only
// those write paths needed) are NOT here — this server must never be able
// to write to the vault, even in principle.
// - Authenticates as a DEDICATED bot user (BW_EMAIL=adolf-vault@auth.local),
// never the master allogn@gmail.com account. No default email/password —
// both must be explicit in .env so this can never silently fall back to
// the master identity.
// - BITWARDENCLI_APPDATA_DIR (see docker-compose.yml) points at this
// service's OWN volume, separate from the agap-mcp/marketplace-mcp host
// bind mount (`/home/alvis/.config/Bitwarden CLI`) — the bot's bw
// login/session state must never share a directory with the master's.
//
// The real fence is server-side: the bot user is granted read-only access to
// a narrow "Adolf" collection only (not the whole "AI" collection). This
// client code does not filter by collection — Vaultwarden itself only
// returns items the bot user's permissions allow, whatever org-wide ORG_ID
// is passed.
import { execFileSync } from 'child_process';
const BW = 'bw';
const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51';
let _session = null;
function bwEnv() {
const env = { ...process.env };
for (const k of ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy'])
delete env[k];
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
return env;
}
function run(args, input) {
return execFileSync(BW, args, {
env: bwEnv(),
encoding: 'utf8',
input,
stdio: input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
}).trim();
}
export async function initVaultwarden() {
const email = process.env.BW_EMAIL;
const password = process.env.BW_PASSWORD;
const server = process.env.VW_URL;
if (!email || !password) {
throw new Error('BW_EMAIL and BW_PASSWORD env vars are required — the dedicated adolf-vault@auth.local bot credentials, never the master account (see .env.example)');
}
if (!server) {
throw new Error('VW_URL env var is required — this service owns a FRESH BITWARDENCLI_APPDATA_DIR volume (unlike agap-mcp/marketplace-mcp, which reuse the host dir that already has `bw config server` set), so it must configure the server itself on every boot (see .env.example)');
}
// Idempotent — safe to call on every boot, including against an
// already-configured appdata dir.
run(['config', 'server', server]);
let status = 'unauthenticated';
try {
status = JSON.parse(run(['status'])).status;
} catch {}
if (status === 'unauthenticated') {
run(['login', email, password, '--raw']);
}
_session = run(['unlock', password, '--raw']);
run(['sync', '--session', _session]);
console.log('Vaultwarden: ready (adolf-vault bot identity)');
}
function session() {
if (!_session) throw new Error('Vaultwarden not initialized');
return _session;
}
export function vwGetPassword(name) {
return run(['get', 'password', name, '--session', session()]);
}
export function vwGetItem(name) {
return JSON.parse(run(['get', 'item', name, '--session', session()]));
}
export function vwListItems(search) {
const args = ['list', 'items', '--session', session()];
if (search) args.push('--search', search);
return JSON.parse(run(args));
}
export function vwListOrgItems(search) {
// Server-side collection permissions (not this code) decide what actually
// comes back — the bot user only sees the narrow "Adolf" collection.
const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()];
if (search) args.push('--search', search);
return JSON.parse(run(args));
}