pi-femboy-mcp
# ♡ pi-femboy-mcp — pastel state, mood & memory server
MCP server that holds the shared state of the `pi-femboy-tui` ecosystem: the current
outfit, the emotional vector, unlocked wardrobe, short-term memory about the user, and
which terminal window the chibi is currently living in.
**Status: Phases 1-4 + 6 ♡ done.** Transports, persistence, reactive subscriptions, the full
Phase 2 resource/tool surface, the Phase 3 tamagotchi engine (background decay, biorhythms,
terminal-exit-code hook) and the Phase 4 loot loop: achievement detectors, the status-line
toast rail and a closet where the rare skins cost something. **Phase 6 has landed:** the
`pi-femboy-tui` extension now pairs over streamable HTTP — shared outfit, headpat counter and
feature flags across all windows, gated skins enforced by the server, graceful local-only
fallback. Presence multi-window focus/jump-around (Phase 5) and external status sharing are
still on the roadmap (`femboy_mcp_roadmap.md`).
```
Phase 1 ♡ done core + dual transport + sqlite store + resources/subscribe broadcast
Phase 2 ♡ done 5 resources · 10 tools · 2 prompts · mood mechanics
Phase 3 ♡ done background ticker · circadian decay · terminal-report hook · comfort mode
Phase 4 ♡ done 6 achievements · detectors · toasts · gated wardrobe · femboy://progress
Phase 6 .. ♡ done pi-femboy-tui pairing: shared vibe sync · gated skins · offline fallback
Phase 5 · 7 .. presence focus/jump-around, external status sharing, in-TUI soak tests
```
---
## What is exposed today
### Resources (all `application/json`, all reactive)
| URI | Payload | Notes |
| --- | --- | --- |
| `femboy://state` | outfit, accessory, status text, activity, badge, feature flags, locale, unlocked skins/achievements/accessories, item counts, `toast` + `toasts` (the live status-line popup), `progress` (earned/total) | `femboy.state@1` |
| `femboy://mood` | `energy` / `affection` / `patience` vector, `label` + `derivedLabel`, `labelProfile` (emoji/mouth/tone), `circadian` (hour/phase/tick recency), `comfortMode`, headpat count, fail streak, interaction recency | `femboy.mood@1` |
| `femboy://presence` | every connected terminal, transport, pid, tmux pane, heartbeat, focus, `mode` (`active`/`idle`/`sleeping`/`detached`), `activeClientId` | `femboy.presence@1` |
| `femboy://speech/live` | the current line (`speaking`, `current`) + a short `tail` of recent lines, TTL-aware | `femboy.speech@1` |
| `femboy://diary` | newest-first monologue entries with `moodTag` + structured `context`, `byClient`, open-confession count | `femboy.diary@1` |
| `femboy://progress` | every achievement with `value`/`target`/`ratio`, its rewards, the whole wardrobe with the achievement that buys each locked piece, counters, and the pending toasts | `femboy.progress@1` |
Every snapshot carries `schema`, `revision`, `updatedAtMs`, `updatedBy` and `changedKeys`, so a
client can drop a stale notification and tell what actually moved. Diffs are **sorted and
deduped** in the engine, so `changedKeys` is comparable without normalising.
### Tools
The seven the roadmap specifies:
| Tool | What it does |
| --- | --- |
| `femboy_set_outfit(outfit, accessory?)` | Wear an owned skin; accepts `"Gothic Mochi"` or `gothic_mochi`. `null` accessory takes it off. Free-form accessories are worn and registered as owned; catalogued rare ones are gated by an achievement. Refusals name the achievement *and* the condition (`20 commits in a row…`). |
| `femboy_toggle_feature(feature, enabled)` | Flip a pastel feature flag (`uwu`, `chrome`, `companion`, …). Name is case-normalised; junk identifiers are refused. Diff is per key: `features.uwu`. |
| `femboy_headpat(intensity?, note?)` | 1-10 (default 3). +affection, +patience, −a little energy, saturating near the top. She reacts on the speech buffer; `note` becomes a memory fact; every pat is counted — 100 of them earn `Gentle Touch`. Returns `earned[]` when the call tipped a detector. |
| `femboy_give_item(item_id)` | `boba_tea` / `monster_energy` / `snack` with per-item effect vectors (monster energy on an already-wired companion trades energy for jittery and says so). Increments the inventory. |
| `femboy_remember_fact(category, fact, confidence)` | Writes to `memory_log`. Repeating an identical fact raises its confidence instead of duplicating. |
| `femboy_confess_bug(problem_description, hypothesis?, status?, confession_id?)` | Rubber-duck log. Always answers with **one socratic question**, never a fix. Re-call with `confession_id` + `status:"resolved"` for the relief jump (patience up, fail streak cleared, triumphant diary entry). |
| `femboy_write_diary_entry(entry, mood_tag?)` | One line of inner monologue, stamped with the mood that was live at write time. |
The three Phase 4 loot tools:
| Tool | What it does |
| --- | --- |
| `femboy_progress(view?, only_locked?)` | Read the rail: progress per achievement, the closet with its prices, pending toasts, the counters behind them. `view` is `all` (default) / `achievements` / `wardrobe` / `toasts`. Read-only. |
| `femboy_ack_toast(toast_id?, all?)` | Take a popup off the rail after the status line displayed it — a real mutation on `femboy://progress` + `femboy://state`, so the other windows stop drawing it twice. Un-acked toasts expire on their own. |
| `femboy_report_git(action, ok?, detail?)` | The git hook: `commit` / `push` / `force_push` / `rebase`. Feeds the same Git Master chain that `femboy_report_command` builds from parsed command lines, so a `post-commit` hook and a shell hook never disagree. |
Three extras, because the Phase 2 resources would otherwise be unreachable or write-only:
| Tool | Why it exists |
| --- | --- |
| `femboy_say(text, kind?, ttl_ms?)` | the only writer for `femboy://speech/live` |
| `femboy_recall(query?, category?, limit?)` | the only reader for what `femboy_remember_fact` wrote |
| `femboy_changes_since(uri, since_revision, limit?)` | replays the diff journal for a client that was offline |
…plus the Phase 3 environment hook:
| Tool | What it does |
| --- | --- |
| `femboy_report_command(exit_code, command?)` | The terminal-status API hook, and the main Phase 4 event source. Failures raise the streak and bite patience (compounding, capped); **3+ in a row switches her into sympathy mode** — a comfort line on the bubble, one diary note, `worried` tone through the prompts. A success heals patience (the relief scales with the streak it ended) and clears it; `130`/`143` (Ctrl+C/SIGTERM) reset the streak without blame. Wire it from a pi shell hook. |
### Prompts
- **`companion_persona(focus?, verbosity?, include_diary?)`** — assembles the *live* server
state into a system prompt: outfit and feature flags, mood vector with its label profile,
her last spoken line (so she does not repeat it), remembered facts with confidence
percentages, recent diary lines, connected terminals and which one is focused, headpat and
open-confession counters — plus the behavioural rules. If the stored label disagrees with the
numbers, the prompt says so and tells the model to trust the numbers.
- **`rubber_duck(problem, attempted?, language?)`** — socratic mode: exactly one question per
turn, never a proposed fix, tone adapted to current patience, and it lists the open bug
confessions already on the server so a duplicate is not started.
### Mood mechanics live in one place
`src/mood/labels.ts` (`deriveMoodLabel`, `moodProfile`), `src/mood/effects.ts` (headpat
curve, item catalog, confession/relief deltas, command-report arithmetic, duck nudge wheel)
and `src/mood/rhythm.ts` (biorhythm decay) are **pure functions**. Every tool and the ticker
goes through them — a decay tick and a headpat can never disagree about what `blushing`
means. Labels are ordered so trouble (`sleepy` / `worried` / `lonely`) wins over pretty
(`purring` / `blushing` / `smug`).
### The tamagotchi engine (Phase 3)
A background **`MoodTicker`** (`src/state/ticker.ts`) runs on `tickIntervalMs` (default
60 s, unref'd, `tick.enabled = false` to silence). Each tick advances the vector by *real
elapsed time* since the stored `lastTickAtMs` through `simulateTick` (`src/mood/rhythm.ts`):
- **Energy** — −0.4/min while a terminal heartbeat or a tool interaction says the user is
on shift; at night (23:00-05:59) sleep restores +0.9/min — unless she is staying up with
you, which costs her; 06:00-08:59 morning lift pulls toward 60 (утренняя бодрость); idle
hours drift back to a 55 resting baseline. Items remain the only instant boosts.
- **Affection** — never self-heats. Cools after 3 h without care, double speed after 8 h
(the road to `lonely`), floored at 6 — she never goes completely cold.
- **Patience** — bleeds 0.1/min while a fail streak is unresolved (and the ticker keeps
`femboy_report_command`'s damage stinging between reports), regenerates toward 55 when green.
Properties the tests pin down:
- **Convergence, not locking.** Because each tick is `f(stored_vector, now − stored_stamp)`,
a second server process ticking a moment later sees ~0 elapsed and its write is a no-op —
multi-process decay never stacks (Phase 1 limit 1 still applies to *pushes*, not to decay).
- **A quiet tick stays quiet.** The `lastTickAtMs` stamp is low-signal: a tick that changes
nothing else bumps no revision and wakes no subscriber. `deriveMoodLabel` is called through
`engine.relabel` — the ticker cannot invent a label vocabulary the tools do not know.
- **Trouble-only monologue.** The ticker speaks exactly when it *walks her into*
sleepy/worried/lonely (source `ticker`); pretty transitions stay silent.
- **A broken clock must not crash the pastel core** — timer-path failures are contained.
### The loot loop (Phase 4)
Care is a game now. Every durable event the tools already produce is tallied in `meta`
(`headpat_total`, `items_total`, `git_commit_streak`, `night_events:YYYY-MM-DD`,
`bugs_whispered`, `patience_comebacks`, … — `src/progress/detect.ts` owns the names), and six
detectors read those tallies (`src/progress/achievements.ts`):
| Achievement | Earned when | Unlocks |
| --- | --- | --- |
| 🌙 **Night Owl** | ≥3 tracked events inside one 02:00-05:59 night (credited once per calendar day) | skin *Oversized Sleepy Hoodie* |
| 🐾 **Gentle Touch** | 100 headpats, all time | accessory *Cat Ears* |
| 🌿 **Git Master** | 20 commits in a row with no *successful* force-push between | skin *Cyber Maid* |
| 🦆 **Bug Whisperer** | a resolved confession that was hard (≥2 duck questions, or open 20+ min, or confessed while already worried/sleepy) | accessory *Round Glasses* |
| 🧋 **Sugar Rush** | 10 treats handed over | accessory *Headphones* |
| ✅ **Green At Last** | climbing out of comfort mode back to a green build | accessory *Band-aid* |
Properties the tests pin down:
- **Detectors are pure.** `measure({ counters }) → { value, target, done }` — no clock, no DB.
Awarding lives in `syncAchievements` (`src/progress/awards.ts`): the **inventory row is the
memory**, so running the same evaluation twice (or from two processes on one database)
converges and never fires a second toast.
- **One batch, one commit.** An unlock transaction writes the achievement row, the wardrobe
pieces that name it as their price, one toast each, one celebration line (`speech`,
`source: achievement:<id>`) and one triumph diary entry — then emits a single coalesced
event whose deltas cover `progress`, `state`, `speech` and `diary`.
- **Rewards are derived, never declared twice.** `achievements.ts` asks `wardrobe.ts` who
lists it as `requires`; a test asserts every gate is an achievement and back.
- **The rail is where the money is.** `femboy://progress` carries progress + the closet with
its prices + pending toasts, and `femboy://state` embeds `achievements`, `unlockedSkins`,
`accessories` and `toast` — so a client that only subscribes to state still repaints the
closet and still gets its popup.
- **Toasts are events, not fields.** Queued with a 45 s TTL, cleared by `femboy_ack_toast`
(which bumps `progress` + `state` so other windows stop drawing the same celebration), and
forgotten by housekeeping after a day. A client that never acks is never stuck.
- **Night watch.** The ticker is what earns Night Owl on an idle night: a live terminal
heartbeat inside 02:00-05:59 counts as one event per tick, then it also runs the award pass
(so counters written by *another* process get credited within a tick) and, hourly, sweeps
stale per-night tallies and expired toasts — silently, without bumping a revision.
- **The gate is one function.** `isGatedAccessory` + `identify(raw, unlocked)` decide what may
be worn; catalogued-but-locked pieces fail with the achievement and its condition named,
unknown accessory names stay free-form and become owned on first wear.
### Subscriptions (the Phase 1 headline feature)
`resources/subscribe` / `resources/unsubscribe` are implemented per session, and mutations
fan out as `notifications/resources/updated` **only to sessions that asked for that
resource**. The notification is spec-clean (`params.uri`) and carries the differential
payload in `params._meta`, which the MCP types explicitly allow:
```json
{
"method": "notifications/resources/updated",
"params": {
"uri": "femboy://state",
"_meta": {
"femboy.dev/schema": "femboy.resourceDelta@1",
"femboy.dev/revision": 7,
"femboy.dev/changedKeys": ["outfit", "features.uwu"],
"femboy.dev/actor": "stdio:98565@boxlaptop",
"femboy.dev/timestampMs": 1761900000000
}
}
}
```
A client that was disconnected can replay what it missed: the `resource_changes` journal
stores every committed diff by revision (`StateEngine.changesSince(uri, lastSeenRevision)`).
Two properties the tests pin down:
- **Commit before broadcast.** A mutation is persisted, journalled and *committed* before
any notification leaves the process — nobody is woken for data that isn't there yet.
- **Quiet stays quiet.** Heartbeat/activity stamps are written but filtered out of the diff,
so a room full of terminals poking the store does not spam SSE streams. Only meaningful
changes bump a revision.
---
## Running it
```bash
npm install
npm run build
# local per-terminal mode (pi spawns this)
node dist/index.js serve --transport stdio
# shared mode for tmux windows / machines
FEMBOY_SECRET=$(openssl rand -hex 16) node dist/index.js serve --transport http --port 7413
# both at once (stdio for the parent process, HTTP for everyone else)
node dist/index.js serve --transport both
# introspection
node dist/index.js --print-config # resolved config + where each value came from
node dist/index.js status # probe /healthz
node dist/index.js --help
```
### Wiring it into pi
```bash
# stdio (simplest; one server per pi process)
pi mcp add femboy -- npm --prefix /path/to/femboy-mcp run start -- --transport stdio
# shared HTTP across all your windows (what pi-femboy-tui pairs to — see femboy.mcp.url)
pi mcp add femboy --url http://127.0.0.1:7413/mcp --bearer-token-env-var FEMBOY_SECRET
```
Or by hand in `~/.pi/agent/mcp.json` / `.pi/mcp.json`:
```json
{
"mcpServers": {
"femboy": {
"url": "http://127.0.0.1:7413/mcp",
"headers": { "Authorization": "Bearer ${FEMBOY_SECRET}" },
"description": "Shared pastel vibe state for pi-femboy-tui"
}
}
}
```
---
## Configuration
Precedence: **defaults < `femboy-mcp.config.json` < environment < CLI flags.**
See `femboy-mcp.config.example.json` for a starting point.
| Key | Env | CLI | Default |
| --- | --- | --- | --- |
| transport | `FEMBOY_TRANSPORT` | `--transport` | `stdio` |
| http host | `FEMBOY_HOST` | `--host` | `127.0.0.1` |
| http port | `FEMBOY_PORT` | `--port` | `7413` (0 = ephemeral) |
| mcp path | `FEMBOY_HTTP_PATH` | — | `/mcp` |
| db path | `FEMBOY_DB_PATH` | `--db-path` | `$XDG_DATA_HOME/femboy-mcp/state.sqlite` |
| bearer secret | `FEMBOY_SECRET` | `--secret` | unset (auth off) |
| loopback exempt | `FEMBOY_ALLOW_LOCAL_UNAUTH` | `--allow-local-unauth` | `true` |
| mood tick | `FEMBOY_TICK_MS` | `--tick-ms` | `60000` (Phase 3 ticker interval) |
| ticker on/off | `FEMBOY_TICK_ENABLED` | `--tick-enabled` | `true` (false = mood only moves via tools) |
| session reaper | `FEMBOY_SESSION_IDLE_MS` | `--session-idle-ms` | `300000` (0 = never) |
| reaper sweep | `FEMBOY_SWEEP_MS` | `--sweep-ms` | `15000` |
| log level | `FEMBOY_LOG_LEVEL` | `--log-level` | `info` |
| config file | `FEMBOY_CONFIG` | `--config` | `./femboy-mcp.config.json`, then `~/.config/femboy-mcp/config.json` |
File lookup order is: `--config` → `FEMBOY_CONFIG` → `./femboy-mcp.config.json` →
`~/.config/femboy-mcp/config.json`. `~` is expanded in paths. Bad values fail loudly at
startup instead of being coerced.
### Auth model
If `FEMBOY_SECRET` is set, HTTP clients need `Authorization: Bearer <secret>` — except
loopback requests, which are exempt while `allowLocalUnauthenticated` is `true` (that's the
convenient default for your own tmux windows). Set `FEMBOY_ALLOW_LOCAL_UNAUTH=0` to require
the token everywhere. Starting an HTTP transport on a non-loopback host with no secret
prints a warning. `http.allowedHosts` turns on the SDK's DNS-rebinding protection.
### Extra HTTP endpoints
- `GET /healthz` — liveness, session count, current revisions, broadcast counters, ticker status.
- `GET /healthz?deep=1` — adds `PRAGMA quick_check`, memory stats, inventory and presence counts.
- `GET /` — name/version/endpoint discovery.
`FEMBOY_READY_FILE` makes the server dump `{pid, http: {host, port, path}}` once it is
bound — handy when you start it with `--port 0`.
### Presence hints
HTTP clients may send `x-femboy-client`, `x-femboy-tmux-pane`, `x-femboy-window-title` and
`x-femboy-pid`. They land in `presence_sessions`, so `femboy://presence` can name the
window the chibi should actually be drawn in. Without hints the id is `http:<clientInfo.name>:<session8>`.
---
## Architecture
```
src/
index.ts CLI entry, lifecycle, graceful shutdown, ready file
cli.ts argument parsing + help
config.ts layered config (file < env < CLI), validation
log.ts stderr-only logger (stdout is the stdio JSON-RPC channel)
constants.ts version + resource URIs
mood/
labels.ts pure deriveMoodLabel() + per-label render/tone profiles
effects.ts headpat curve, item catalog, confession/relief deltas, command reports, duck nudges
rhythm.ts Phase 3 biorhythm physics: elapsed-time decay, circadian phases, transition lines
progress/
detect.ts Phase 4 detectors, pure: git-command classification, 02:00-05:59 nights,
hard-bug tests, counter names
achievements.ts the catalog: six scored detectors, celebration lines, toast copy
wardrobe.ts the closet: skins/accessories, tier, which achievement buys each
awards.ts orchestration: observe* → syncAchievements → progressView → housekeeping
state/
types.ts domain types: VibeState, MoodSnapshot, PresenceSnapshot, SpeechLine,
DiaryEntry, BugConfession, Toast, ResourceDelta
engine.ts StateEngine: transactions, revisions, diff journal, listeners, snapshots
service.ts VibeService: the tool use-cases (one transaction per call)
ticker.ts MoodTicker: the background heartbeat tick (Phase 3 decay + relabel + mutters)
outfits.ts display name ⇄ ref_id canonical mapping
store/
schema.ts ordered migrations (PRAGMA user_version)
db.ts FemboyStore: WAL, busy_timeout, IMMEDIATE transactions, checkpoint
repositories.ts state / mood / presence / inventory / memory / revisions
streams.ts speech buffer / diary / bug confessions / event counters
toasts.ts Phase 4 popup rail: push / pending / ack / prune
mcp/
resources.ts resource registry (URI → snapshot builder)
tools.ts the tool surface, Phases 2-4 (zod v4 input schemas, structuredContent)
prompts.ts companion_persona + rubber_duck assembled from live state
subscriptions.ts per-session subscription sets + notification envelopes
session.ts one McpServer per connection, subscribe/unsubscribe handlers
hub.ts fan-out fabric: engine event → matching sessions → SSE/stdio
transport/
stdio.ts StdioServerTransport + stdin-EOF lifecycle
http.ts StreamableHTTPServerTransport, one session per Mcp-Session-Id,
idle/background tracking + reaper
```
**Data flow of a tool call**
```
caller (MCP tool / ticker / transport lifecycle)
└─ VibeService.headpat(...) one use-case = one transaction
└─ StateEngine.transact([moodSpec, speechSpec], { side: counters })
├─ store.transaction ── IMMEDIATE txn
│ ├─ repository.write() → changedKeys[]
│ ├─ drop low-signal keys, sort + dedupe
│ └─ RevisionRepository.bump(uri, keys, actor) → resource_meta + resource_changes
├─ commit
└─ emit one coalesced {deltas:[mood, speech]}
└─ FemboyHub.route → SessionSubscriptions.match → session.deliver
└─ notifications/resources/updated (+_meta diff)
```
**Storage layout** (SQLite, WAL, `busy_timeout = 5000`):
- `state` / `mood_metrics` — singleton rows (`CHECK (id = 1)`), JSON columns for flags/meta.
- `inventory_and_unlocks` — `(kind, ref_id)` unique; kinds `item|skin|achievement|accessory`.
- `memory_log` — deduped facts with confidence, use counts, `superseded_by` chain.
- `presence_sessions` — one row per client id, heartbeat + focus + `mode`.
- `speech_buffer` — rolling live line (TTL) with history kept for replay.
- `diary_entries` — monologue with `mood_tag` + JSON `context` + client attribution.
- `bug_confessions` — rubber-duck log: `open → thinking → resolved|abandoned`, nudge tally, mood before.
- `resource_meta` — the single authority for "which revision of this URI is live".
- `resource_changes` — append-only diff journal, replayable by revision, prunable.
- `toasts` — Phase 4 popup rail: `kind`, `ref_id`, title/body/emoji, TTL, `acked_at_ms`.
- `meta` — key/value counters (`headpat_total`, `item_total:<id>`, `git_commit_streak`,
`bugs_whispered`, `night_events:YYYY-MM-DD`, …) — the durable truth the detectors read.
---
## Known limits (by design)
1. **Notification fan-out is per server process.** Multiple *processes* sharing one SQLite
file (e.g. several `--transport stdio` servers) read each other's committed state on the
next read — and can replay `resource_changes` by revision — but are not pushed each
other's notifications. Sharing one HTTP server (or, later, a change-tail poller) is the
Phase 5 answer, which is exactly what multi-window tmux should use.
2. **Achievements are credited by the process that observed them (or by its ticker).** The
award pass is idempotent and reads shared counters, so a second server on the same file
still earns the badge — it just may do it one tick later than the first would have. Until
Phase 5 there is no cross-process nudge, so a celebration can arrive slightly out of order
across windows that each run their own stdio server.
3. **~~`tickIntervalMs` is stored but unused~~ — shipped in Phase 3.** The background ticker
decays the vector on real elapsed time and relabels through the same `deriveMoodLabel`
the tools use. Its clock is *server-local*: run the shared HTTP server in the user's
timezone (per-client circadian offsets would need Phase 5 presence hints).
4. **Presence honesty has two stages:** last stream closed → `idle` (≈5 s grace), full
silence past `sessionIdleMs` → `detached`. A crashed process is therefore never shown as
an active terminal for longer than the idle budget.
---
## Development
```bash
npm run typecheck # tsc --noEmit (src)
npm run build # → dist/
npm test # tools/check.sh: typecheck src+test, build, unit, wire, spawned E2E
node tools/poke.mjs # hands-on tour: subscribes to everything, calls the tools, prints the diffs
node tools/poke.mjs --url http://127.0.0.1:7413/mcp --token "$FEMBOY_SECRET"
```
`tools/poke.mjs` runs against a **throwaway temp database** unless you pass `--db-path` or
`--url`, so poking the surface can never overwrite the vibe state you care about.
`tools/check.sh` is the gate. It runs, in order:
1. `tsc --noEmit` for `src` and for `src + test` (`tsconfig.test.json`),
2. a clean build,
3. unit + in-process integration tests (`node --import tsx --test`):
config precedence & validation, migrations & seeds, engine revisions/diffs/journal,
presence lifecycle, memory dedupe, inventory unlocks,
**mood mechanics** (label matrix, headpat saturation, item effects and overrides, duck
nudges being questions), **rhythm & command reports** (circadian phases, elapsed-time
decay, ignore cooling, patience bleed/heal, comfort-mode threshold, Ctrl+C forgiveness),
**the ticker** (silent stamping, low-signal `lastTickAtMs`, trouble-only mutters,
multi-process convergence, contained broken clocks, the `femboy_report_command`
use-case end to end), **streams** (speech TTL, diary ordering/context, confession
lifecycle, counters), **the loot rail** (git-command and night-window classifiers, hard-bug
rules, catalog/wardrobe consistency, one-shot idempotent awarding, toast TTL + ack, gated
wardrobe, the ticker's night watch and housekeeping), **the service use-cases** (one
transaction per call, rejected operations leave no trace), and the subscription matrix over
linked in-memory transports
(per-resource routing, idempotent subscribe, unsubscribe, URI normalisation, unknown-URI
rejection, dead-stream containment, coalesced multi-resource mutations,
idle/wake broadcasts),
4. **the whole tool + prompt surface over the wire** (`test/tools.test.ts`): advertised
schemas, structuredContent, tool errors with hints, malformed calls refusing to write,
remember→recall round-trips, diary and speech visible through their resources, the
confession lifecycle, diff replay, the Phase 4 rail (gated accessory refused, the git chain
built over the wire, unlock notifications reaching `progress` + `state` subscribers, toasts
acked away, achievements inside `companion_persona`), and both prompts rendered from live
state,
5. tests against the **spawned** server binary: stdio (stdout purity, shared durability,
detach-on-EOF, child exits) and streamable HTTP (two real clients, cross-session
broadcast of a headpat performed by the *other* window, DELETE termination, prompt access
over HTTP, 401/bearer paths, unknown-session 404, backgrounding on stream close, idle
reaper).
Tests use real SQLite files in `$TMPDIR` and real `node` child processes — nothing is mocked
at the transport boundary, because the transport boundary is the whole point of Phase 1.
### Adding a reactive resource
1. add the URI to `RESOURCE_URIS` (`src/constants.ts`),
2. add a table/columns in a new migration (`src/store/schema.ts`),
3. add a repository write that returns `changedKeys`,
4. register it in `RESOURCE_DEFINITIONS` (`src/mcp/resources.ts`) and give the engine a
snapshot builder that includes `schema` + `revision`,
5. map URI → table in `URI_TABLE` (`src/state/engine.ts`) if it has low-signal fields,
6. add a use-case on `VibeService` (one `transact` call) and expose it as a tool.
Subscriptions, diffing and fan-out need no changes — that is the point of Phase 1.
---
## The one rule
`pastel never breaks pi`: no stderr banner may ever reach stdout, no notification may be
sent for uncommitted data, a rejected operation must leave no trace, and a dead client stream
must never take the shared server down. All four are asserted by the test suite.
MIT © ArtemidaGre
TDQS
Scored across 14 tools
Most tools map to a distinct action (feeding, headpats, outfit, diary, facts, confessions, toasts, progress). The main overlap is femboy_report_command vs femboy_report_git, which the descriptions admit are classified identically, and femboy_say vs femboy_write_diary_entry sit close together (live speech vs inner monologue) but are adequately differentiated.
Every tool uses the same femboy_ prefix with snake_case, and nearly all follow verb_noun (give_item, write_diary_entry, set_outfit, toggle_feature, remember_fact, report_git). Two bare-verb outliers, femboy_say and femboy_recall, are minor deviations that remain readable.
Fourteen tools is on the upper edge for a companion/novelty server, but each covers a genuinely different mechanic (care, memory, diary, speech, achievements, hooks, change journal). The report_git/report_command split is the one arguably redundant addition.
The surface covers the domain well: care actions, memory write+read, diary write, achievements read+ack, feature flags, bug confession open/resolve, git and shell hooks, and differential replay. Minor gaps exist: diary entries and the wardrobe/inventory can be read via resources or femboy_progress but not directly as tools, and there is no explicit state/inventory getter.