1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts
- Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts
2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground
- Previously called at invoice creation for partial path (before user pays) — economic DoS vulnerability
- Now deferred to after work completes, using new `partialAbsorbSats` parameter in runWorkInBackground
- Fully-free jobs still record grant at eval time (no payment involved)
3. Sessions pre-gate refactor: estimate → decide → execute → reconcile
- Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called
- After execution, `absorbedSats` is capped at actual cost (Math.min) to prevent over-absorption
- Uses new `estimateRequestCost()` for clean single-call estimation
## What was built
### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns
### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
— all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
— checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring
### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
balance debit. Session balance only reduced by `chargeSats`; absorbed portion
comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.
### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
(if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets
### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot
## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
- resolveNostrPubkey() now returns { pubkey, rejected } instead of string|null
so invalid/expired tokens return 401 instead of silently falling to anonymous
- POST /sessions and POST /jobs: return 401 if nostr_token header/body is
present but invalid or expired
- POST /identity/verify: now accepts optional top-level 'pubkey' field alongside
'event'; asserts pubkey matches event.pubkey if both are provided — aligns
API contract with { pubkey, event } spec shape and hardens against mismatch
Task #26 — Nostr Identity + Trust Engine (foundational layer for cost-routing)
DB changes:
- New `nostr_identities` table: pubkey PK, trust_score, tier, interaction_count,
sats_absorbed_today, absorbed_reset_at, last_seen, created_at, updated_at
- Added nullable `nostr_pubkey` column to `sessions` and `jobs` tables
- Schema pushed to DB (drizzle-kit push)
- lib/db rebuilt to emit updated declaration files
New lib:
- `artifacts/api-server/src/lib/trust.ts` — TrustService with:
- getTier(pubkey): returns tier label for a pubkey
- getOrCreate(pubkey): upsert identity row
- recordSuccess/recordFailure: adjust trust score; update tier
- Soft score decay applied lazily (absent > N days = -1 pt/day)
- issueToken/verifyToken: HMAC-SHA256 signed nostr_token (pubkey:expiry:hmac)
- All thresholds env-var configurable (TRUST_TIER_ESTABLISHED/TRUSTED/ELITE)
New route:
- `artifacts/api-server/src/routes/identity.ts`:
- POST /api/identity/challenge — issues 32-byte hex nonce (5-min TTL, in-memory)
- POST /api/identity/verify — verifies NIP-01 Nostr signed event, consumes nonce,
upserts identity, returns signed nostr_token + trust profile
- GET /api/identity/me — look up trust profile by X-Nostr-Token header
- Route registered in routes/index.ts
Session + job binding:
- POST /api/sessions and POST /api/jobs accept optional nostr_token (header or body)
- Verified pubkey stored on the DB row; returned in create response + poll responses
- trust_tier included in GET /sessions/:id and GET /jobs/:id responses
- After session request completes: recordSuccess on complete, recordFailure on reject
- After job work completes: recordSuccess fire-and-forget
CORS: X-Nostr-Token added to allowedHeaders and exposedHeaders
Smoke tested: all existing routes pass, challenge returns nonce, /identity/me 401 without token, sessions/jobs still create correctly with trust_tier: none (expected for anonymous requests)
Previously recordSuccess/recordFailure read identity.trustScore (raw stored value)
and incremented/decremented from there. Long-absent identities could instantly
recover their pre-absence tier on the first interaction, defeating decay.
Fix: both methods now call applyDecay(identity) first to get the true current
baseline, then apply the score delta from there before persisting.
Task #26 — Nostr Identity + Trust Engine (foundational layer for cost-routing)
DB changes:
- New `nostr_identities` table: pubkey PK, trust_score, tier, interaction_count,
sats_absorbed_today, absorbed_reset_at, last_seen, created_at, updated_at
- Added nullable `nostr_pubkey` column to `sessions` and `jobs` tables
- Schema pushed to DB (drizzle-kit push)
- lib/db rebuilt to emit updated declaration files
New lib:
- `artifacts/api-server/src/lib/trust.ts` — TrustService with:
- getTier(pubkey): returns tier label for a pubkey
- getOrCreate(pubkey): upsert identity row
- recordSuccess/recordFailure: adjust trust score; update tier
- Soft score decay applied lazily (absent > N days = -1 pt/day)
- issueToken/verifyToken: HMAC-SHA256 signed nostr_token (pubkey:expiry:hmac)
- All thresholds env-var configurable (TRUST_TIER_ESTABLISHED/TRUSTED/ELITE)
New route:
- `artifacts/api-server/src/routes/identity.ts`:
- POST /api/identity/challenge — issues 32-byte hex nonce (5-min TTL, in-memory)
- POST /api/identity/verify — verifies NIP-01 Nostr signed event, consumes nonce,
upserts identity, returns signed nostr_token + trust profile
- GET /api/identity/me — look up trust profile by X-Nostr-Token header
- Route registered in routes/index.ts
Session + job binding:
- POST /api/sessions and POST /api/jobs accept optional nostr_token (header or body)
- Verified pubkey stored on the DB row; returned in create response + poll responses
- trust_tier included in GET /sessions/:id and GET /jobs/:id responses
- After session request completes: recordSuccess on complete, recordFailure on reject
- After job work completes: recordSuccess fire-and-forget
CORS: X-Nostr-Token added to allowedHeaders and exposedHeaders
Smoke tested: all existing routes pass, challenge returns nonce, /identity/me 401 without token, sessions/jobs still create correctly with trust_tier: none (expected for anonymous requests)
- sessions.ts / jobs.ts schema: add .references(() => nostrIdentities.pubkey) FK constraints
on nostrPubkey columns; import without .js extension for drizzle-kit CJS compat
- Schema pushed to DB (FK constraints now enforced at DB level)
- sessions route: call getOrCreate before insert to guarantee FK target exists;
recordFailure now covers both 'rejected' AND 'failed' final states
- jobs route: call getOrCreate before insert; recordFailure added in
runEvalInBackground for rejected and failed states; recordFailure added in
runWorkInBackground catch block for failed state
- All GET/POST endpoints now always return trust_tier (anonymous fallback)
- Full typecheck clean; schema pushed; smoke tested — all routes green
Task #26 — Nostr Identity + Trust Engine (foundational layer for cost-routing)
DB changes:
- New `nostr_identities` table: pubkey PK, trust_score, tier, interaction_count,
sats_absorbed_today, absorbed_reset_at, last_seen, created_at, updated_at
- Added nullable `nostr_pubkey` column to `sessions` and `jobs` tables
- Schema pushed to DB (drizzle-kit push)
- lib/db rebuilt to emit updated declaration files
New lib:
- `artifacts/api-server/src/lib/trust.ts` — TrustService with:
- getTier(pubkey): returns tier label for a pubkey
- getOrCreate(pubkey): upsert identity row
- recordSuccess/recordFailure: adjust trust score; update tier
- Soft score decay applied lazily (absent > N days = -1 pt/day)
- issueToken/verifyToken: HMAC-SHA256 signed nostr_token (pubkey:expiry:hmac)
- All thresholds env-var configurable (TRUST_TIER_ESTABLISHED/TRUSTED/ELITE)
New route:
- `artifacts/api-server/src/routes/identity.ts`:
- POST /api/identity/challenge — issues 32-byte hex nonce (5-min TTL, in-memory)
- POST /api/identity/verify — verifies NIP-01 Nostr signed event, consumes nonce,
upserts identity, returns signed nostr_token + trust profile
- GET /api/identity/me — look up trust profile by X-Nostr-Token header
- Route registered in routes/index.ts
Session + job binding:
- POST /api/sessions and POST /api/jobs accept optional nostr_token (header or body)
- Verified pubkey stored on the DB row; returned in create response + poll responses
- trust_tier included in GET /sessions/:id and GET /jobs/:id responses
- After session request completes: recordSuccess on complete, recordFailure on reject
- After job work completes: recordSuccess fire-and-forget
CORS: X-Nostr-Token added to allowedHeaders and exposedHeaders
Smoke tested: all existing routes pass, challenge returns nonce, /identity/me 401 without token, sessions/jobs still create correctly with trust_tier: none (expected for anonymous requests)
- New nostr_identities DB table (pubkey, trust_score, tier, interaction_count, sats_absorbed_today, last_seen)
- nullable nostr_pubkey FK on sessions + jobs tables; schema pushed
- TrustService: getTier, getOrCreate, recordSuccess/Failure, HMAC token (issue/verify)
- Soft score decay (lazy, on read) when identity absent > N days
- POST /api/identity/challenge + POST /api/identity/verify (NIP-01 sig verification)
- GET /api/identity/me — look up trust profile by X-Nostr-Token
- POST /api/sessions + POST /api/jobs accept optional nostr_token; bind pubkey to row
- GET /sessions/:id + GET /jobs/:id include trust_tier in response
- recordSuccess/Failure called after session request + job work completes
- X-Nostr-Token added to CORS allowedHeaders + exposedHeaders
- TIMMY_TOKEN_SECRET set as persistent shared env var
Task #25: Provision LNbits on Hermes VPS for real Lightning payments.
Changes:
- dev.ts: /dev/stub/pay/:hash now works in both stub and real LNbits modes.
In real mode, looks up BOLT11 from invoices/sessions/bootstrapJobs tables
then calls lnbitsService.payInvoice() (FakeWallet accepts it).
- sessions.ts: Remove all stubMode conditionals on paymentHash — always expose
paymentHash in invoice, pendingTopup, and 409-conflict responses.
- jobs.ts: Remove stubMode conditionals on paymentHash in create, GET awaiting_eval,
and GET awaiting_work responses.
- bootstrap.ts: Remove stubMode conditionals on paymentHash in POST create and
GET poll responses. Simplify message field (no longer mode-conditional).
- Hermes VPS: Funded LNbits wallet with 1B sats via DB credit so payInvoice
calls succeed (FakeWallet checks wallet balance before routing).
Result: 29/29 testkit PASS in real LNbits mode (LNBITS_URL + LNBITS_API_KEY set).
Update testkit.ts to add explicit failure conditions for missing payment hash in stub mode and to assert that the bootstrapJobId returned in the poll response matches the created ID.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 9114d92d-daf7-42ae-a3f7-be296300efa5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
Task: Add T23 (bootstrap stub flow) and T24 (cost-ledger completeness) to the
testkit, bringing total from 27/27 to 29/29 PASS with 0 FAIL, 0 SKIP.
## What was changed
- `artifacts/api-server/src/routes/testkit.ts`:
- Updated audit-log comment block to document T23 + T24 additions.
- Inserted Test 23 after T22 (line ~654):
POST /api/bootstrap → assert 201 + bootstrapJobId present.
Guard on stubMode=true; SKIP if real DO mode (prevents hanging).
Stub-pay the paymentHash via /api/dev/stub/pay/:hash.
Poll GET /api/bootstrap/:id every 2s (20s timeout) until
state=provisioning or state=ready; assert message field present.
- Inserted Test 24 after T23:
Guarded on STATE_T6=complete (reuses completed job from T6).
GET /api/jobs/:id, extract costLedger.
Assert all 8 fields non-null: actualInputTokens, actualOutputTokens,
totalTokens, actualCostUsd, actualAmountSats, workAmountSats,
refundAmountSats, refundState.
Honest-accounting invariant: actualAmountSats <= workAmountSats.
refundAmountSats >= 0.
refundState must match ^(not_applicable|pending|paid)$.
## No deviations from task spec
- T23 guard logic matches spec exactly (stubMode check before poll).
- T24 fields match the 8 specified in task-24.md plus the invariants.
- No changes to bootstrap.ts or jobs.ts — existing routes already correct.
## Test run result
29/29 PASS, 0 FAIL, 0 SKIP (fresh server restart, rate-limit slots clean).
T23: state=provisioning in 1s. T24: actualAmountSats(179)<=workAmountSats(182),
refundAmountSats=3, refundState=pending.
Implement ragdoll physics for agent interactions, including a state machine for falling, getting up, and counter-attacks. Introduce camera shake based on slap impact and export camera shake strength from agents.js. Update main.js to apply camera shake around the renderer.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b80e7d8c-b272-408c-8f8f-e4edd67ca534
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
Introduce `touchstart` event listener as a fallback for older browsers lacking Pointer Events, and reduce the interaction lockout timer from 220ms to 150ms to prevent accidental orbit drags after a slap.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b1d20c43-904b-495f-9262-401975d950d3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
Refactors the `buildTimmy` function to update Timmy's robe color to royal purple, add celestial gold star decorations, and implement a silver beard and hair, along with a pulsing orange magic orb effect.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7cc95df8-ef94-4761-8b47-9c13fedbba9a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
## What changed
- the-matrix/js/agents.js — face expression system added to Timmy wizard
## Face geometry (all parented to head — follow head.rotation.z tilt)
- White sclera eyes (MeshStandardMaterial f5f2e8, emissive 0x777777@0.10)
replace the old flat dark-blue spheres
- Dark pupils (MeshBasicMaterial 0x07070f) as child meshes of each sclera;
they scale with the parent eye for squint effect
- Mouth arc: TubeGeometry built from QuadraticBezierCurve3; control point
moves ±0.065 on Y for smile/frown; rebuilt via _buildMouthGeo() only when
|smileDelta| > 0.016 (throttled to avoid per-frame GC pressure)
- All face meshes are children of `head` — head.rotation.z carries every
face component naturally with the existing head-tilt animation
## FACE_TARGETS lookup table (lidScale, pupilScale, smileAmount)
- idle (contemplative): 0.44 / 0.90 / 0.08 — half-lid, neutral
- active (curious): 0.92 / 1.25 / 0.38 — wide eyes + dilated pupils, smile
- thinking (focused): 0.30 / 0.72 / -0.06 — squint + constricted pupils, flat
- working (attentive): 0.22 / 0.80 / 0.18 — very squint, slight grin
## setFaceEmotion(mood) exported API
- Accepts both task-spec names (contemplative|curious|focused|attentive)
and internal state names (idle|active|thinking|working) via MOOD_ALIASES
- Immediately sets faceTarget; lerp in updateAgents() handles the smooth transition
## Per-frame lerp (rate 0.055/frame) in updateAgents
- lidScale → eyeL.scale.y / eyeR.scale.y (squash for squint)
- pupilScale → pupilL.scale / pupilR.scale (uniform dilation)
- smileAmount → drives TubeGeometry rebuild when drift > 0.016
## Lip-sync while speaking (~1 Hz)
- speechTimer > 0: smileTarget = 0.28 + sin(t*6.283)*0.22
- Returns to mood target when timer expires
## Validation
- Vite build: clean (14 modules, 542 kB, no errors)
- testkit: 27/27 PASS (after server restart to clear rate-limit counters)
## What changed
- the-matrix/js/agents.js fully rewritten with face expression system
## Face geometry
- Replaced flat dark-blue eye spheres with white sclera (MeshStandardMaterial,
emissive 0x777777@0.10, roughness 0.55) + dark pupils (MeshBasicMaterial 0x07070f)
as child meshes of sclera
- Eyes are now children of the head mesh (not the group) so they naturally
follow head.rotation.z tilts driven by the existing animation loop
- Mouth added as a canvas Sprite (128x32, always faces camera) parented to the
group so it bobs with Timmy's body; drawn via quadraticCurveTo bezier arc
## Emotion → face parameter mapping (FACE_TARGETS table)
- idle (contemplative): lidScale=0.44, smileAmount=0.08 — half-lid, neutral
- active (curious): lidScale=0.92, smileAmount=0.38 — wide eyes, smile
- thinking (focused): lidScale=0.30, smileAmount=-0.06 — squint, flat mouth
- working (attentive): lidScale=0.22, smileAmount=0.18 — very squint, slight grin
## Per-frame lerp (updateAgents)
- faceParams lerped toward faceTarget at rate 0.055/frame (smooth, no snap)
- eyeL.scale.y / eyeR.scale.y driven by faceParams.lidScale (squash = squint)
- Mouth canvas redrawn only when |smileDelta| > 0.016 or speakingChanged
(avoids unnecessary texture uploads every frame)
## Lip-sync while speaking
- While speechTimer > 0: smileTarget = 0.28 + sin(t*6.283)*0.22 (~1 Hz)
- _drawMouth() renders two-lip "open mouth" shape when speaking=true
- Returns to mood expression when speechTimer expires
## Validation
- Vite build: clean (14 modules, 529 kB bundle, no errors)
- testkit: 27/27 PASS (no regressions)
- No out-of-scope changes (backend untouched)
## Task
Task #20: Timmy responds to Workshop input bar — make the "Say something
to Timmy…" input bar actually trigger an AI response shown in Timmy's
speech bubble.
## What was built
### Server (artifacts/api-server/src/lib/agent.ts)
- Added `chatReply(userText)` method to AgentService
- Uses claude-haiku (cheaper eval model) with a wizard persona system prompt
- 150-token limit so replies fit in the speech bubble
- Stub mode: returns one of 4 wizard-themed canned replies after 400ms delay
- Real mode: calls Anthropic with wizard persona, truncates to 250 chars
### Server (artifacts/api-server/src/routes/events.ts)
- Imported agentService
- Added per-visitor rate limit system: 3 replies/minute per visitorId (in-memory Map)
- Added broadcastToAll() helper for broadcasting to all WS clients
- Updated visitor_message handler:
1. Broadcasts visitor message to all watchers as before
2. Checks rate limit — if exceeded, sends polite "I need a moment…" reply
3. Fire-and-forget async AI call:
- Broadcasts agent_state: gamma=working (crystal ball pulses)
- Calls agentService.chatReply()
- Broadcasts agent_state: gamma=idle
- Broadcasts chat: agentId=timmy, text=reply to ALL clients
- Logs world event "visitor:reply"
### Frontend (the-matrix/js/websocket.js)
- Updated case 'chat' handler to differentiate message sources:
- agentId === 'timmy': speech bubble + event log entry "Timmy: <text>"
- agentId === 'visitor': event log only (don't hijack speech bubble)
- everything else (delta/alpha/beta payment notifications): speech bubble
## What was already working (no change needed)
- Enter key on input bar (ui.js already had keydown listener)
- Input clearing after send (already in ui.js)
- Speech bubble rendering (setSpeechBubble already existed in agents.js)
- WebSocket sendVisitorMessage already exported from websocket.js
## Tests
- 27/27 testkit PASS (no regressions)
- TypeScript: 0 errors
- Vite build: clean (the-matrix rebuilt)
- scripts/bitcoin-ln-node/setup.sh: one-shot installer for Bitcoin Core (pruned mainnet), LND, and LNbits on Apple Silicon Mac. Generates secrets, writes configs, installs launchd plists for auto-start.
- scripts/bitcoin-ln-node/start.sh: start all services via launchctl; waits for RPC readiness and auto-unlocks LND wallet.
- scripts/bitcoin-ln-node/stop.sh: graceful shutdown (lncli stop → bitcoin-cli stop).
- scripts/bitcoin-ln-node/status.sh: full health check (Bitcoin sync %, LND channels/balance, LNbits HTTP, bore tunnel). Supports --json mode for machine consumption.
- scripts/bitcoin-ln-node/expose.sh: opens bore tunnel from LNbits port 5000 to bore.pub for Replit access.
- scripts/bitcoin-ln-node/get-lnbits-key.sh: fetches LNbits admin API key and prints Replit secret values.
- artifacts/api-server/src/routes/node-diagnostics.ts: GET /api/admin/node-status (JSON) and /api/admin/node-status/html — Timmy self-diagnoses its LNbits/LND connectivity and reports issues.