diff --git a/adolf/DESIGN-proactive-prioritization.md b/adolf/DESIGN-proactive-prioritization.md new file mode 100644 index 0000000..134bc9d --- /dev/null +++ b/adolf/DESIGN-proactive-prioritization.md @@ -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. diff --git a/adolf/DESIGN-todoist-capture.md b/adolf/DESIGN-todoist-capture.md new file mode 100644 index 0000000..58eb673 --- /dev/null +++ b/adolf/DESIGN-todoist-capture.md @@ -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-` 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-` 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 + ``` diff --git a/adolf/README.md b/adolf/README.md index 4a98bd2..244f0df 100644 --- a/adolf/README.md +++ b/adolf/README.md @@ -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 H1–H5). 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`. diff --git a/adolf/openclaw.json b/adolf/openclaw.json index 90d1945..dbdc8bc 100644 --- a/adolf/openclaw.json +++ b/adolf/openclaw.json @@ -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, + }, }, }, } diff --git a/adolf/vw-mcp/.env.example b/adolf/vw-mcp/.env.example new file mode 100644 index 0000000..f952504 --- /dev/null +++ b/adolf/vw-mcp/.env.example @@ -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 diff --git a/adolf/vw-mcp/.gitignore b/adolf/vw-mcp/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/adolf/vw-mcp/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/adolf/vw-mcp/Dockerfile b/adolf/vw-mcp/Dockerfile new file mode 100644 index 0000000..fe1ae5c --- /dev/null +++ b/adolf/vw-mcp/Dockerfile @@ -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"] diff --git a/adolf/vw-mcp/docker-compose.yml b/adolf/vw-mcp/docker-compose.yml new file mode 100644 index 0000000..f688e08 --- /dev/null +++ b/adolf/vw-mcp/docker-compose.yml @@ -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: diff --git a/adolf/vw-mcp/package.json b/adolf/vw-mcp/package.json new file mode 100644 index 0000000..f0cc2a1 --- /dev/null +++ b/adolf/vw-mcp/package.json @@ -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" + } +} diff --git a/adolf/vw-mcp/server.js b/adolf/vw-mcp/server.js new file mode 100644 index 0000000..83d74b7 --- /dev/null +++ b/adolf/vw-mcp/server.js @@ -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); + }); diff --git a/adolf/vw-mcp/start.sh b/adolf/vw-mcp/start.sh new file mode 100755 index 0000000..cf1d477 --- /dev/null +++ b/adolf/vw-mcp/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec node server.js diff --git a/adolf/vw-mcp/vaultwarden.js b/adolf/vw-mcp/vaultwarden.js new file mode 100644 index 0000000..7681254 --- /dev/null +++ b/adolf/vw-mcp/vaultwarden.js @@ -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)); +}