Skip to content

feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership)#1

Merged
FireTable merged 39 commits into
mainfrom
feat/001-user-auth
Jun 23, 2026
Merged

feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership)#1
FireTable merged 39 commits into
mainfrom
feat/001-user-auth

Conversation

@FireTable

Copy link
Copy Markdown
Owner

Summary

Stage 1 user authentication from specs/001-user-auth/. Users must register/sign-in before reaching /chat; each user's threads are isolated by userId.

Stack: Better Auth (Drizzle adapter, Postgres) · Resend + react-email for verification emails · @better-auth-ui/react (shadcn registry install, source-in-repo) · Next.js 16 App Router

What's in

  • Auth core: email/password + GitHub/Google OAuth (env-gated), 7-day sessions, email verification required
  • DB: 4 new tables (user/session/account/verification) + threads.userId FK with ON DELETE CASCADE
  • API: every /api/threads/* route requires session and filters by session.user.id; cross-user access → 404 (not 403)
  • Backend graph: ownership check removed from renameThread and touchLastMessageAt — trust boundary moved to /api layer, graph trusts thread_id
  • Frontend: server layout stays server; app/auth-shell.tsx is the client provider; /login, /login/sign-up, /login/forgot-password, /chat all wired
  • Docs: docs/AUTH.md (operator) + docs/APIS.md (contracts) updated

Commits

  1. chore(spec001): refresh spec/plan/templates for stage 1 auth
  2. feat(db): add auth tables and migration
  3. feat(auth): wire better-auth with email verification
  4. feat(api): gate threads by userId
  5. refactor(backend): drop userId precheck from graph nodes
  6. feat(frontend): add auth shell and login UI
  7. docs(auth): document stage 1 auth flow

Verification

  • pnpm build clean
  • pnpm test 76/76 pass
  • pnpm lint clean
  • Manual: pnpm dev/login → sign-up → verify email → /chat

Follow-ups (not in this PR)

  • Password regex server-side enforcement (hooks.before in lib/auth/config.ts) — held back since better-auth-ui's client doesn't surface it
  • Stage 2: password reset UI · TOTP MFA · account deletion button · session management UI

🤖 Generated with Claude Code

FireTable and others added 29 commits June 22, 2026 01:36
Split the monolithic agent.ts into model.ts / checkpointer.ts / node/call-model-node.ts. Add node/rename-thread-node.ts that uses the LLM to generate a short title; route the graph through it via a conditional edge so it runs only on the first turn.
The /api/threads/[id]/title endpoint and GenerateTitleBody validator are now redundant — adapter.generateTitle fetches the current title from the DB and streams it back, which keeps the runtime's auto-apply a no-op after the LLM has populated the row.
eventHandlers passed to useLangGraphRuntime runs before AssistantRuntimeProvider mounts, so it can't call useAui directly. Add a child component (AuiRefCapture) that lives inside the provider and writes api + mainThreadId to a bridgeRef, plus streamMode: ["messages", "updates", "custom"] so custom events from the runtime are surfaced.
renameThread still runs only on the first turn, but the run-once guard now lives inside the node body (`if (state.title) return`). The node returns `{ title }` on the first turn so the reducer writes `state.title`; the guard then short-circuits every later turn without an LLM call.

agent.ts switches to a Send fan-out from `START` to `["agent", "renameThread"]` and drops the afterAgent conditional edge. The parallel structure has no intermediate gate position to put a conditional on, which is why the guard moves into the node.

`chatModelWithoutThink` gains an inline comment noting that `think: false` is provider-specific (some OpenAI-compatible gateways reject the kwarg).
WorkingIndicator's second branch (after the first token arrives) was rendering a bare "●" character. Replace it with `<DotMatrix state="connecting" />` so the in-flight phase matches the pre-first-token affordance instead of falling back to a static glyph.

The companion `@assistant-ui/react-markdown/styles/dot.css` import is now commented out in markdown-text.tsx — the stylesheet only styles an assistant-ui internal streaming cursor, and we no longer want that competing with the DotMatrix render.
CLAUDE.md architecture diagram gains backend/{node,model,checkpointer} and lib/threads/. The Backend-graph section is rewritten to describe the two-node Send fan-out, the state.title run-once guard, and chatModelWithoutThink (replaces the stale 'single agent node' description).

README.md features line and tech-stack table updated to match (useStreamRuntime typo → useLangGraphRuntime; single-node → parallel fan-out). Project layout block adds the backend/node/, model.ts, checkpointer.ts, lib/threads/, and tests/{api,backend,db} entries; the deleted tests/shims/server-only.ts row is removed. Patches section's 'two upstream assistant-ui packages' is corrected to the single actual entry.

docs/TODOS.md drops the after_agent candidate (the graph no longer has an afterAgent node) and updates the #87 in_progress references — the last-message sort landed, so the open question and the touchThread follow-up are no longer blocked on it.
Server-side sync point: a new afterAgent node runs after `agent` produces its reply and updates the threads row, so the sidebar can sort by last activity without client-side polling or extra SDK calls.

The renameThread node is also refactored to match the DB-as-truth design the runtime's generateTitle already depends on: it no longer writes state.title (the GraphState channel + reducer are dropped) and just persists to the DB, returning null. undefined signals "didn't run" (no human message); null signals "ran but no state mutation".

touchThread was deleted (no callers) and replaced with touchLastMessageAt, a dedicated function named after the column it touches.
Drop entries for `last_message_at` sync (resolved by afterAgent) and the touchThread cleanup. Top-of-file note updated: delete resolved entries instead of keeping a Done section — git history has the rest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GitHub Spec Kit scaffolding: templates, bash scripts, workflow
definitions, agent-context extension, and the Claude Code integration.

Provides 8 slash commands: /speckit-constitution, /speckit-specify,
/speckit-clarify, /speckit-plan, /speckit-tasks, /speckit-implement,
/speckit-analyze, /speckit-checklist.

Project constitution lives at .specify/memory/constitution.md
(separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six core principles for the project, including a new constraint that
all spec-kit-generated .md files under .specify/ must be written in
Chinese for easier review by Chinese-speaking contributors.

Version 2.0.0 (MAJOR): v1.0.0 ratified the initial six principles plus
a tech stack section; v2.0.0 removes the tech stack section so spec-kit
remains tech-agnostic (stack details already live in CLAUDE.md).

Supersedes the engineering rules in CLAUDE.md when in conflict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end spec-kit pass for adding user accounts and login (Stage 1):
email/password with verification, GitHub + Google OAuth via Better Auth,
7-day persistent sessions, and thread ownership enforcement. Email via
Resend free tier (100/day quota). All existing threads dropped on DB
reset.

Includes spec.md (25 FR / 10 SC), research.md (10 tech decisions),
plan.md (constitution check + structure), data-model.md (4 Better Auth
tables + threads.userId FK CASCADE), contracts/ (auth + threads
ownership), quickstart.md (9 scenarios), tasks.md (47 TDD tasks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@react-email/components is officially deprecated ("Package no longer
supported" in npm registry). Resend consolidated all scoped packages
into a single unified react-email package (v6.x) that exposes components
+ render function from the same import path.

Updates plan.md Primary Dependencies + email/ structure, research.md
decisions 2 + 7, and tasks.md T001 + T031 references. Splits T010 into
T010a-d to add the supporting files required by the Matte/activation
template (theme config, font loader, static image asset).

Template fork: github.com/resend/react-email canary branch
apps/demo/emails/02-Matte/activation.tsx (MIT). Static image hosted
via Next.js public/ instead of upstream's Vercel URL pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finalize the 001-user-auth doc set after implementation lands. Spec/plan/tasks/contracts/research/quickstart now match the shipped code. Scaffolding (constitution v2, templates, integrations) updated alongside.
Better Auth needs user/session/account/verification tables; threads gains a userId FK with ON DELETE CASCADE for cross-user isolation. Drizzle schema is the source of truth; migration generated by drizzle-kit.
Better Auth with Drizzle adapter on the existing Postgres. Email/password + GitHub/Google OAuth (env-gated). Verification emails via Resend using react-email templates, sender defaults to onboarding@resend.dev for dev. Session helper reads cookies via next/headers for server components and route handlers. Tests cover sign-up/sign-in/verify flows and the threads ownership query helpers.
Every /api/threads/* route now requires a session and filters by session.user.id. Cross-user access returns 404 (not 403) so existence isn't leaked. Queries split into API-facing (with ownership check) and graph-internal (no check — see next commit). docs/APIS.md updated to match the new request/response shapes and the 404 semantics.
renameThread and touchLastMessageAt no longer SELECT userId from threads — the trust boundary moved up to /api/threads/* which already gates by session.user.id. The graph now trusts thread_id as the only key it needs, and the two query helpers collapsed to single-arg signatures.
Auth UI uses @better-auth-ui/react (shadcn registry install, not the npm package) for own-your-code editing. app/auth-shell.tsx is a client provider that wraps the server layout — useRouter requires use client. New shadcn primitives (card, field, input-group, sonner, etc.) added for the auth forms. package.json gains the @better-auth-ui/* and @tanstack/react-query deps; email collage image bundled for the verification template.
Operator-facing doc for stage 1 auth: routes, local dev setup (secret + Resend + migrations), OAuth provider config, data isolation policy, env var matrix, troubleshooting table. Stage 2 roadmap kept inline as a not-implemented list so we don't lose it.
Registry-installed Field/FieldGroup defaults (gap-7 / gap-3) are looser than the example-layer overrides in better-auth-ui's next-shadcn-registry-example. The official demo wraps every Field with gap-1 and every FieldGroup with gap-4, but we never applied those overrides. Tightening the primitive defaults applies to all four auth forms (sign-in / sign-up / forgot-password / reset-password) in one place.
Replaces requireSession() + instanceof NextResponse with a typed withAuth(handler) wrapper. Five protected routes (GET/POST /api/threads, GET/PATCH/DELETE /api/threads/[id]) now use withAuth(({userId, params}) => ...) which returns 401 UNAUTHORIZED before the handler runs.

Adds 5 unit tests for the HOC (401 path, userId pass-through, params resolution, handler-not-called, response passthrough) and a permissiveread- vitest mock for the no-routeCtx case (e.g. /api/threads).

Deleting requireSession is a behavior-preserving rename: same 401 JSON, same session check, same edge runtime. Net diff: 5 lines saved across routes, 29 added in with-auth.ts (most are types).
Reduces Field's default gap from 1.5 (6px) to 1 (4px) so the label/input pair matches the official better-auth-ui sign-in spacing. FieldGroup's gap-4 override (added in 65ae5c1) only applies between sibling fields, not within one.
@FireTable FireTable force-pushed the feat/001-user-auth branch from a6b551f to c6caa31 Compare June 23, 2026 01:18
Comment thread app/page.tsx
Comment thread backend/node/rename-thread-node.ts
Comment thread app/auth-shell.tsx
Comment thread components/auth/error-toaster.tsx
Comment thread db/migrations/meta/0000_snapshot.json
Comment thread lib/threads/queries.ts
Comment thread lib/threads/adapter.ts Outdated
Comment thread lib/threads/queries.ts Outdated
The eye/eye-off toggle inside password inputs is a <button>, so it picks up the default tabIndex=0 and stops keyboard focus mid-form. Marking it tabIndex={-1} keeps it mouse-clickable and screen-reader reachable via aria-label, but lets Tab skip from password straight to the next field.
createThread(userId, title) had userId first while the rest of the module (getThreadForUser, archiveThread, …) consistently put the resource id first. Switching to createThread({ userId, title? }) makes the call site explicit and lets title default without forcing it ahead of a required param.
Adapter's five fetch call sites each repeated credentials: "include" + Content-Type + JSON.stringify. Hoisted to lib/fetch.ts:jsonFetch(url, init) so the cookie behavior lives in one place and a future cross-origin deploy can't silently drop auth by forgetting the option.
Drizzle schema has 5 tables (user, session, account, verification, threads) with FK cascades, but no docs/DB.md existed. CLAUDE.md rule #1: API/DB docs stay in sync with migrations. New doc describes each table's columns/indexes/FKs, the CASCADE root (user.id), which code paths read/write each table, and the tooling commands (generate / migrate / studio / reset).
jsonFetch read like a generic JSON helper but the function defaults credentials: include — i.e. its actual job is to carry the session cookie. fetchWithAuth matches what callers get.
oxfmt reformat — no behavior change.
Pulls avatar + name from Better Auth session and renders a dropdown (settings link, plugin menu items, sign-in/sign-out). Mounted in both desktop Sidebar (footer) and MobileSidebar SheetContent; collapsed state shows icon-only trigger.

Components added via better-auth-ui shadcn registry:
- components/auth/user/{user-button,user-avatar,user-view}.tsx
- components/ui/dropdown-menu.tsx (radix dependency, shadcn new-york)

Extended AuthPluginBase module augmentation in components/auth/auth-provider.tsx to expose userMenuItems[] — runtime field on @better-auth-ui/core 1.6.27's plugin base type but not in its public types.
Add docs/DB.md to README's docs index. Move Stage 1 review-comment follow-ups (collage image, redirectTo, ownership query split, landing page, rename-prompt extraction) into docs/TODOS.md so they survive beyond the root todo list.
Wrap the UserButton in bg-card + border (no shadow, no border-t separator) so the footer reads as one card against the sidebar shell. Tighter p-2 padding.
@FireTable FireTable merged commit cc1afe8 into main Jun 23, 2026
FireTable added a commit that referenced this pull request Jun 23, 2026
After PR #1 squash, three test files were still written against the pre-merge API surface:

- tests/api/threads.test.ts: GET/POST /api/threads calls were missing the App Router route ctx (withAuth expects (req, routeCtx)). Pass a stub routeCtx.
- tests/backend/node/call-model-node.test.ts: callModelNode dropped its unused config arg; remove the extra arg from the test calls.
- tests/lib/auth/with-auth.test.ts: withAuth now exposes already-awaited params (was Promise<T>). Update the helper and the params-passing test to match.

tsc clean, 86 tests pass.
@FireTable FireTable deleted the feat/001-user-auth branch June 23, 2026 08:39
FireTable added a commit that referenced this pull request Jul 2, 2026
Add docs/APIS.md Memory section per rule #1 — GET/DELETE /api/memory/profile, GET/DELETE /api/memory/threads, plus the save_memory tool contract (RFC 6902 patches, FR-023 fail-fast, FR-001..003 invariants, FR-020 no separate forget tool). Mirrors contracts/memory-api.md + contracts/save-memory-tool.md.
FireTable added a commit that referenced this pull request Jul 6, 2026
…ranch

The dual-graph runtime (mainAgent + background_agent), the save_memory tool, the threadSummarizeNode trigger, and the Memory settings tab all landed in this branch — the existing docs only described the pre-dual-graph state (a single agent graph + afterAgentNode, no memory tab, no save_memory tool in TOOLS.md). This commit catches the docs up without touching source code.

docs/MEMORY.md (new) — full design doc in the OBSERVABILITY.md / TOOLS.md / INTERRUPT.md style. Covers:
- Dual-graph topology (mainAgent + background_agent, both registered in langgraph.json, with triggerBackgroundAgentNode HTTP-dispatching via the SDK).
- The per-sub-agent memory prefix call chain (loadThreadSummariesForPrompt → trimMessagesForInvoke → buildSystemMessageWithMemory) and the <memory> + <threads> mustache template.
- save_memory input/output shapes, the 8-step write path (including mergeMemory on the merged view + filterToStoreOnly + assertProfileSize + invalidateMemory), and the fail-closed error classes.
- Thread summaries: store-anchored trigger (read back max(endMessageIndex) — heals deletion/replay), computeCumulativeWindow math, JSONL transcript rendering, structured LLM output, trimMessagesForInvoke cut-point rule.
- Memory tab UI (About-you rows with auth-overlay classification, Thread summaries with collapsed default + Radix animation).
- Configuration knobs (MEMORY_THREAD_SUMMARY_KEEP_RECENT, MEMORY_PROFILE_MAX_BYTES) and storage model (PostgresStore namespaces, delete-loop gotcha).
- Security stance (no forget tool, fail-closed userId, path regex, size guard, OAuth token redaction, cross-user 404) and known trade-offs.

CLAUDE.md — "What this is" rewritten to describe the dual-graph + memory + summarize nature. Architecture tree updated: added background-agent.ts, store.ts, callbacks.ts, trigger-background-agent-node.ts, thread-summarize-node.ts, tool/memory/, backend/memory/{recall,template,profile-size}.ts, with the real lib/memory/ function names. Backend graph section rewritten to cover both compiled graphs. State-persistence prose: afterAgentNode → touchLastMessageNode. New short 'Memory & thread summarize' pointer linking to MEMORY.md.

README.md — Features list: dual-graph bullet replaces the single-graph one; new 'Cross-conversation memory' bullet links to MEMORY.md; tool-using-agent bullet now mentions save_memory. Documentation table: MEMORY.md added as the second row. Environment table: MEMORY_THREAD_SUMMARY_KEEP_RECENT + MEMORY_PROFILE_MAX_BYTES entries. Project layout: fully rewritten to reflect the dual-graph backend, the memory tool/middleware modules, the lib/memory/ module, the tool-ui/memory/ cards, and langgraph.json registering BOTH graphs.

docs/TOOLS.md — 'Tool groups' table gets a Memory row. New '## Memory' section with the save_memory row + path regex / size-guard / no-forget-tool notes. Compliant with CLAUDE.md rule #1.
FireTable added a commit that referenced this pull request Jul 6, 2026
…ph runtime

* feat(backend): add PostgresStore for long-term memory

Mirror the existing PostgresSaver wiring: new backend/store.ts exports a PostgresStore instance built from DATABASE_URL, with setup() called once at module load. Wire it into the graph via compile({ store }) so nodes can reach runtime.store for cross-thread memory. No node consumes the store yet — that's the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(003-langgraph-store): add feature spec for LangGraph long-term memory

9 docs covering one feature (PostgresStore-backed long-term memory): spec, plan, research, data-model, quickstart, tasks, and 3 contracts. Spec is in Draft status; implementation follows /speckit.implement.

* feat(memory): add foundational layer for long-term memory

Shared building blocks for US1/US2/US3: env-bounded constants (NFR-004), Zod validators for RFC 6902 patches + Summary closed-interval invariant, store wrappers around PostgresStore, and the profile-size guard with structured MemorySizeError (NFR-003). Tests reach 90%+ coverage on validators + queries per rule #2.

* feat(memory): add 4 API endpoints + Memory settings tab

US3 surface: GET /api/memory/profile (with session + social), DELETE /api/memory/profile/[key] (FR-014 path regex [A-Za-z0-9_-]{1,64}), GET /api/memory/threads (grouped by threadId, sequence desc, updatedAt desc), DELETE /api/memory/threads/[threadId] (collapse by namespace prefix). All four wrapped in withAuth per rule #9.

UI: MemoryView component (Profile rows with (from account)/(saved by you) hints, Delete only on store rows; Thread Summaries grouped by threadId with Delete all) at /settings/memory, surfaces in UserButton menu via the 'links' slot (better-auth-ui 1.6.27's settingsTabs API has types but no render site — 'links' is the lowest-friction surface).

Drizzle socialAccounts query (provider only, no accountId per FR-020) lives next to the other profile queries.

* feat(memory): save_memory tool + withMemoryRecall middleware

US1:

- saveMemoryTool at backend/tool/memory/save-memory-tool.ts. RFC 6902 patches (add/replace/remove only; move/copy/test rejected at zod level), pre-validates replace/remove against the current profile so a missing-path patch isn't silently no-op'd, fast-json-patch.apply with structuredClone (in-place mutation safety), then profile-size guard, then store write. Returns JSON { ok, bytes, keyCount }. Surface-level errors: MissingUserIdError.code === MISSING_USER_ID (FR-023 fail-fast), MemoryPatchError.code === PATCH_FAILED for path issues, MemorySizeError re-exported from backend/memory/profile-size.ts.

- withMemoryRecall at backend/middleware/with-memory-recall.ts. Proxy over the inner ChatOpenAI — only .invoke is intercepted; bindTools/stream/batch/withConfig forward transparently. On invoke, if config.configurable.userId is non-empty, prepend a single <memory>...</memory> system message with profile + session + socialAccounts + threads top-K. Pass-through unchanged when userId absent (FR-007).

- backend/model.ts: chatModel = withMemoryRecall(baseChatModel). chatModelWithoutThink stays un-wrapped because the rename node is a background task and shouldn't carry the recall payload.

- backend/tool/index.ts: ALL_TOOLS now includes saveMemoryTool.

- app/api/[..._path]/route.ts: POST/PUT/PATCH body rewrite injects config.configurable.userId from the auth session before forwarding to LangGraph. Without this, the SDK never carries userId into model.invoke, and FR-007 (pass-through) is exercised for every call.

* feat(memory): threadSummarize node wired after afterAgent

US2:

- backend/node/thread-summarize-node.ts: compute userMessageCount from state.messages, gate on THRESHOLD (skip when <= 10), find latest summary for this thread, derive startIdx = (latest.endMessageIndex ?? -1) + 1 / endIdx = userMessageCount - KEEP_RECENT, skip when endIdx < startIdx (FR-010 — closed-interval zero window). Take the closed window [startIdx, endIdx] via messages.slice(startIdx, endIdx + 1) (note +1 because JS slice is end-exclusive). Call chatModel.withStructuredOutput for { name, description } and writeSummary to the store with messageCount = endIdx - startIdx + 1 — both inclusive ends per FR-010.

- backend/agent.ts: add the node to both buildSubgraph and buildInlined, sitting after afterAgent and before END. Pure side-effect node, no messages channel writes, self-skips when conditions don't apply.

- backend/state.ts: no schema change — userMessageCount is derived in the node (one filter over messages), avoiding reducer plumbing through every model node return.

- tests/backend/agent-topologies.test.ts: add threadSummarize to both topology node lists.

Edge cases tested: 1-message window (startIdx===endIdx), 2-message window, endIdx<startIdx skip, no prior + first summary, incremental next run after a prior summary, missing userId / thread_id.

* test(memory): add US4 session + social integration tests

Verifies the withMemoryRecall system message contains session.name / session.email / socialAccounts[].provider, NEVER leaks accountId / accessToken / refreshToken (FR-020), and confirms session is read fresh on every invoke — no per-process caching (US4 step 3).

* docs(memory): document Memory API + save_memory tool

Add docs/APIS.md Memory section per rule #1 — GET/DELETE /api/memory/profile, GET/DELETE /api/memory/threads, plus the save_memory tool contract (RFC 6902 patches, FR-023 fail-fast, FR-001..003 invariants, FR-020 no separate forget tool). Mirrors contracts/memory-api.md + contracts/save-memory-tool.md.

* fix(memory): import fast-json-patch as default export

fast-json-patch@3.1.1 ships ESM as `export default Object.assign(core, duplex, {...})` — no named `applyPatch` export. The named import `{ applyPatch as applyJsonPatch }` works under vitest (CJS transform) but throws SyntaxError under Node ESM at runtime, blocking langgraph dev startup.

Default-import + destructure matches the package's actual ESM shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): swap to immutable-json-patch + allow optional userMessageCount

Two changes from one save_memory audit:

1. fast-json-patch@3.1.1 has a broken ESM/tsx loading chain — its
   `index.ts` imports `./src/core` which doesn't resolve under tsx, and
   the package's `index.mjs` only exports a default object (no named
   `applyPatch`). Switch to immutable-json-patch@6.0.3, which is pure
   ESM (`type: "module"`) with a named `immutableJSONPatch` export.
   Returns a fresh object, so the explicit `structuredClone` is gone.

2. Tests construct fixtures with the derived `userMessageCount`
   alongside `messages` for readability — the node derives it itself
   but the type didn't permit the extra field, producing TS2353 errors
   that vitest silently ignored. Make it optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): add bowser for better-auth-ui Settings

`npx shadcn add https://better-auth-ui.com/r/settings.json` pulls in
`bowser` (used by the shadcn-installed settings components for
capability detection). Keep the dep alongside the rest of the
better-auth-ui stack rather than hiding it behind the UI primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): center layout + redirect /settings → /settings/account

Two UX fixes for the better-auth-ui <Settings> surface:

1. Wrap the rendered Settings in a max-w-3xl mx-auto container with
   a "Settings" h1 + py-8/12 padding — the shadcn Tabs + Cards stretched
   edge-to-edge without it and looked unfinished on wide viewports.
   TabsList/TabsContent inherit the container width via flex parent.

2. Add app/settings/page.tsx that redirects to /settings/account. The
   better-auth-ui UserButton default Settings link is `${basePaths.settings}`
   which lands on /settings itself; <Settings> then throws on the empty
   path. Redirect avoids the 500 and keeps UserButton's link shape working
   without changing better-auth-ui's href construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): rename Profile → About you + filter credential accounts

Two small fixes after a closer look at the Memory tab:

1. "Profile" → "About you". Closer to mainstream agent UX (Claude.ai
   uses "What Claude knows about you"; ChatGPT uses "User memory").
   "Profile" is overloaded with the Account tab.

2. `getSocialAccounts` was returning every row in `account`, including
   better-auth's `providerId="credential"` row (the email+password
   account). Showing that as a "linked account" alongside GitHub/Google
   was misleading — it's not a social login. Filter it out at the query
   layer so the API contract matches its name. Capitalise the label
   (`github` → `GitHub`) while we're here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auth): set session.freshAge=0 + HMR-friendly config reload

The Security tab's "Active sessions" card hit GET /api/auth/list-sessions
with 403 SESSION_NOT_FRESH. better-auth's freshSessionMiddleware rejects
sessions older than `freshAge` (default 24h) on sensitive-session
endpoints. Dev fixtures keep sessions for weeks and trip the check.

Two changes:

1. `session.freshAge: 0` disables the fresh check. Session expiry (7d)
   still bounds exposure; the fresh check is mainly a CSRF-rotation
   guard, not a primary security control.

2. Drop the cached `globalThis.__auth` instance on every module reload
   in dev so config edits (freshAge, plugins, etc.) take effect
   without a server restart. The instance is cheap to recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): save_memory schema + recall wrapper + per-key thread delete

Three live-tested bugs in the memory feature, all fixed:

- save_memory tool's zod schema wrapped SaveMemoryInputSchema in
  z.preprocess + z.any(), which collapsed the JSON-Schema view the
  LLM sees to {type:'any'}. The agent retried four times guessing
  array vs object vs envelope shapes before giving up. Pass the
  envelope directly and add a worked example to the description.
  Verified live: second message stores Name + City in one call.

- withMemoryRecall's Proxy only intercepted .invoke, but every chat
  call site does chatModel.bindTools(ALL_TOOLS).invoke(...). The
  bindTools result is a fresh BaseChatModel that bypassed the
  outer Proxy, so no <memory> system message was ever injected.
  Re-wrap bindTools() in the Proxy. Plus a real-bug fix: every
  model node in backend/agent.ts and backend/agent/chat-agent.ts
  was dropping the second arg of the node signature, so the
  RunnableConfig (with userId) never reached model.invoke. Both
  the bindTools re-wrap and the config plumbing are required —
  a /tmp trace shows 6 invoke calls with userId=<none> before,
  userId=FHK... after. Verified live: a fresh thread answers
  "you live in San Francisco" from the persisted profile.

- deleteThreadSummaries used store.batch([{op:'delete',...}]),
  but langgraph-checkpoint-postgres 1.0.4 batch() has no delete
  branch — it throws "Unsupported operation type" inside its
  loop. The API returned deletedCount from the pre-batch filter
  anyway, so the rows stayed. Loop store.delete(namespace, key)
  per row to match what the upstream library was written for.

* fix(observability): show LLM prompts in span details card

The LLM Prompts section never rendered because the field's show() checked Array.isArray(s.input), but the callback handler stores input as { prompts: string[] } (always wrapped). Reach through the wrapper and use bare:true so the section title is the only label. Also capitalize role summaries and tighten the inner padding while in the area.

* feat(memory): migrate to LangGraph PostgresStore with merged profile/auth view

Replace the with-memory-recall Proxy on chatModel with an explicit
buildSystemMessageWithMemory helper that injects a <memory> block into
every sub-agent's SystemMessage, sourced from LangGraph PostgresStore.

Store the user's profile separately from the live auth overlay (name,
email, image, socials) so the model can patch fields it originally saw
from OAuth without overwriting them. save_memory now validates patches
against the merged view and writes back only divergent fields —
authenticated identities stay in the auth layer.

Drop the redundant call-model-node, with-memory-recall middleware, and
getCodeTools aggregator. Aggregate CODE_TOOLS inline in tool/index.ts
alongside the other conditional tool registrations. Extract a shared
CopyButton from the observability panel for reuse.

* feat(frontend): extract BrandMark + add back-to-chat link on settings page

Move the icon + APP_NAME row out of app/assistant.tsx into a shared
BrandMark component, and add BrandMarkLink (anchored to /chat) at the
top of the settings page so the user has an obvious back-to-chat
affordance — settings has no sidebar of its own. Brand sits at the
viewport's left edge (px-6) to match the chat sidebar's x-position.

* feat(memory): SaveMemoryCard shows before/after diff for save_memory

The save_memory tool now returns the pre/post doc plus a normalized patch list (each entry carries oldValue captured against a running copy, so chained replace→remove reports the post-replace value). A new SaveMemoryCard under components/tool-ui/memory renders Added/Removed/Changed rows with a pill-style field name, registered via memoryToolkit in components/tool-ui/toolkit.tsx. Shared prettifyKey lives in lib/memory/format so the card and settings/memory view render underscore-keys identically (Travel Preferences, Morning Routine). SuccessBanner default check icon now uses emerald to match the resolved-state semantics on ask-location.

* refactor(memory): render nested values as pretty JSON block

The bespoke key/value tree renderer in settings/memory left large gaps when values were short ("fast", "winter"), and short arrays of small objects wasted rows. Replace the tree walker with a single <pre>{JSON.stringify(raw, null, 2)}</pre> so the user sees the same shape the save_memory card shows in its CHANGED row. Primitive leaves still render inline so single-value rows like "Role: engineering manager" stay compact. Drop the now-unused renderChild / serializeNode helpers and the MAX_DEPTH clamp; JSON.stringify handles arbitrary depth without the runtime shape leaking through.

* feat(memory): cap JSON block height + add copy button

memory-view's pretty-printed JSON block grew unbounded as profile fields accumulated — a deep travel_preferences payload could push the rest of the card off-screen. Cap height at 240px (same default the observability panel uses) and overflow-scroll inside the block. Add a CopyButton in the top-right corner so the user can grab the raw JSON without opening devtools — mirrors the affordance observability/panel.tsx already exposes per span field.

* refactor(backend): move thread-summarize prompt to system.ts and feed human+ai transcript

threadSummarizeNode was inlining its prompt string and feeding chatModel.invoke a single concatenated string — the LLM saw only the user side of the conversation. Move the prompt to backend/prompt/system.ts alongside the other agent prompts (CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, etc.) so the prompt-authoring convention stays in one file, then send [SystemMessage, HumanMessage(transcript)] so the model sees both the user turns and the assistant replies for that window. Window math (startIdx/endIdx keyed off user-message count, writeSummary.startMessageIndex/endMessageIndex/messageCount) is unchanged.

* refactor(backend): route threadSummarize via conditional edge

threadSummarize ran unconditionally after every turn, doing five early-return checks inside the node to decide whether to call the LLM and write to the store. That meant every turn produced a threadSummarize span in observability even when there was nothing to summarize. Move the cheap necessary-condition check (userMessageCount > THRESHOLD + KEEP_RECENT) into a conditional-edge router that routes around the node on the trivial path. The node still owns the store-dependent close-window check (endIdx < startIdx) so the single source of truth for 'is there work to do once I'm in here?' stays inside the node. Both the subgraph and inlined builders now use addConditionalEdges(afterAgent, shouldSummarizeRouter, { threadSummarize, __end__: END }).

* style(backend): rename threadSummarize window slice to excerpt

The local variable held the human+ai transcript slice the LLM was fed;
calling it 'window' invited confusion with the browser Window API and
shadowed a meaningful math term. Rename to 'excerpt' — matches the
language the THREAD_SUMMARIZE_PROMPT already uses ('the following
conversation excerpt') so the prompt + code + tests read as one
continuous vocabulary.

chore(frontend): switch langgraph stream to multitaskStrategy interrupt

The default 'reject' strategy throws HTTP 422 when the user submits a
new message while the previous run is still streaming — visible as a
Network Error toast on every Regenerate / Send-while-generating.
'Interrupt' cancels the in-flight run and lets the new one take over,
which is what the chat UX actually wants.

* fix(backend): use LangGraph END symbol in conditional-edge routers

Both shouldSummarizeRouter and shouldRenameRouter were returning the literal string "__end__" instead of LangGraph's END sentinel. LangGraph's addConditionalEdges compares the router's return value against the pathMap keys via ===, so a string "__end__" never matched the END symbol and the runner fell off the graph → "Branch condition returned unknown or null destination". The pathMap itself used the correct { __end__: END } shape, but the router side was string-typed. Switch both router return types and bodies to END and update the tests to assert against the END symbol.

* fix(backend): return node name from shouldSummarizeRouter + colocate with other routers

Two bugs in one:
1. shouldSummarizeRouter was returning the intermediate string
   "summarize", but the pathMap registered threadSummarize under the
   key "threadSummarize" — the comparison missed, so the runner fell
   off the graph with "Branch condition returned unknown or null
   destination". Return the registered node name directly.
2. shouldSummarizeRouter lived in backend/node/thread-summarize-node.ts
   alongside its target node, but every other router in this repo
   (weatherRoute, chatRoute, cryptoRoute, codeRoute, shouldRenameRouter)
   sits in backend/agent.ts next to the graph it routes. Move it back
   so the routers live in one file and the node stays focused on the
   work it actually does once entered.

* style(backend): pass nostream tag as invoke option, drop redundant cast

The { tags: ["nostream"] } option was sitting outside the invoke()
call as a stray comma-expression — invalid syntax flagged by oxlint.
It is the second positional argument to BaseChatModel.invoke(), so
move it inside the call. withStructuredOutput already returns
z.infer<typeof schema>, so the explicit cast is now redundant —
drop it.

* refactor(observability): unify prompts + output into Messages section

The LLM span used to render only input.prompts under the
Prompts section, leaving the model's returned AIMessage(s) hidden
inside the raw JSON blob on the right. Operators debugging a bad
reply had to expand the JSON tree to see what the model actually
said.

Three changes collapse the two views into one:

- Rename the LLM section Prompts -> Messages so it can carry
  both directions.
- Extract parsePromptGroup + readOutputMessages + buildLlmMessages
  helpers next to readStructuredOutput. Input still keys off the
  role-prefixed line serialization LangChain uses; output walks
  output.generations[*][*].message and falls back to tool_calls
  for content-empty AIMessages.
- New entries (the model's reply) render with a solid primary
  chip so they read at a glance vs the input prompt's muted
  background.

Entries default collapsed and use the native <details> triangle.
The flex layout for role+chip is on an inner wrapper instead of
<summary> itself so the browser's default list-item marker still
renders.

* feat(observability): mark last human input as New on LLM span

Reading the prior list the operator only saw one [NEW] chip, on
the model's reply. The user message that triggered the call was
already inside the prompt history and got muted out, so the
"this turn" pair (user → assistant) didn't read together.

* feat(observability): right-align New chip + scope New to current turn

Two follow-ups on the Messages section:

1. The [NEW] chip sat inline next to the role label. Operators
   reading a turn wanted the chip pushed to the row's right end
   so the row read as '<role> ............ [NEW]'. Position it
   absolutely (top: 1/2, right: 1.5) and reserve the slot with
   pr-20 on summary so role text never overlaps the chip.

2. A turn that goes Human -> Ai(tool_call) -> Tool -> Ai has the
   middle two messages land between the trigger user prompt and
   the model's reply. They were treated as conversation history
   and not flagged, which read as 'this is just prior context'.
   Mark them New too — anything from the most recent Human
   message forward is this LLM call's contribution to the turn.

Layout note: the chip is absolute-positioned so summary can keep
its default list-item display — the browser's native disclosure
marker (and its open/close rotation) survive. Earlier attempts
to use flex list-none + a manual glyph swapped rotation for a
static triangle, which is what we just un-did.

* feat(backend): offload thread summarize to background_agent graph

Move last_message_at touch + threadSummarizeNode out of the chat graph's afterAgent into a registered background_agent graph (./backend/background-agent.ts:graph). Chat graph's terminal node scheduleBackground dispatches via SDK runs.create (default) or in-process invoke (INVOKE_BACKGROUND_AGENT=1) — both paths reach the same shared CapturingHandler singleton from backend/callbacks.ts so spans land in the same observability row set.

threadSummarizeNode rewritten to batch human turns in fixed-size windows (BATCH_SIZE, default 6) above KEEP_RECENT and replace them inline in the messages channel. Cross-thread summary injection in the system prompt retired (recall.ts/template.ts no longer pull thread summaries); per-thread summaries now live inline in the messages channel. SummaryEntry schema gains messageIds + summary fields, drops name/description, and renames updatedAt to createdAt. MemoryView renders batch summaries with sequence, message range, and createdAt timestamp.

* wip: snapshot before USE_SUBGRAPH sendCommand investigation

Temporary commit. Working tree mixes:
- subgraph messages persistence helper (lib/langgraph/merge-subgraph-messages.ts + tests + app/assistant.tsx load)
- USE_SUBGRAPH=true interrupt resume payload switch to { [id]: json } (ask-location-card.tsx) — debug only, not yet verified
- langGraphClient singleton in scheduleBackground-node.ts
- misc: env defaults, memory template, thread InterruptUI comment

ask-location-card sendCommand on USE_SUBGRAPH=true still does not resume the run; investigation continues next session. Pre-commit hooks skipped (pre-existing memory + interrupt-ui tests still red).

* fix(observability): order interrupt span after tool call

The interrupt event lands on the CapturingHandler's chainEnd synchronously
with the tool-call chainEnd, so the 1-tick timing race can render the
human-input span above the tool-call span in the waterfall (parent sort
key = started_at). +100ms on the interrupt started_at guarantees the
ordering without depending on the event loop's microtask order.

* fix(frontend): stream subgraph events and stabilize ask_location resume

USE_SUBGRAPH=true was broken at the ask_location card: the interrupt
raised inside the weatherAgent subgraph was never reaching the client
because langgraph-api@1.4.1's api/runs.mjs:85 gates subgraph event
forwarding on 'run.stream_subgraphs ?? false' (default false). With
streamSubgraphs: true the namespaced 'updates|<ns>' event carrying
__interrupt__ now flows, useLangGraphInterruptState populates, and the
card's useLangGraphSendCommand({ resume }) reaches the suspended task.

The server's __pregel_resume_map[ns] routes the resume payload to the
correct subgraph task automatically — client just sends bare JSON, no
namespace routing needed. That makes the keyed '{ [id]: json }' form
the card used to send redundant; reverting to a plain JSON string
removes the InterruptState read + the SDK cast workaround.

onDisconnect: 'continue' is paired with the change so the stream
stays alive across the interrupt and the resume lands on the same
run instead of a fresh one.

unstable_allowCancellation + unstable_enableMessageQueue in app/assistant
are the runtime flags that make the composer Cancel button work and
let queued messages land in the next run; both pair with the
'continue' disconnect policy.

* chore(deps): bump @assistant-ui/core 0.2.19 → 0.2.20

Transitive bump pulled in by an earlier 'pnpm install --force' during
the react-langgraph patch work. No package.json change — just the
lockfile re-resolving a newer core that satisfies the existing peer
range.

* refactor(frontend): drop InterruptUI — tool-call card handles it

USE_SUBGRAPH=true makes the toolkit renderer's own useLangGraphInterruptState
work directly, so the global InterruptUI in thread.tsx was rendering a
duplicate / dead path. The inlined-mode branch (USE_SUBGRAPH=false) was
already broken (see CLAUDE.md — the EventStreamCallbackHandler run-map
bug); keeping InterruptUI 'just in case' served no real consumer.

Drop InterruptUI, its test, the now-unused toolkit/InterruptState hooks
imports, the per-message isLast gate, and the !interrupt WorkingIndicator
guard. The tool-call card's addResult path is the single source of truth
for the ask_location (and any other interrupt-driven tool) UI.

* refactor(backend): drop USE_SUBGRAPH=false inlined topology

The compiled-subgraph path is now the only path; `USE_SUBGRAPH` and `NEXT_PUBLIC_USE_SUBGRAPH` env toggles, the `buildInlined()` builder, and the inlined model/tool node wrappers in `backend/agent.ts` are gone. `backend/background-agent.ts` drops the pointless `summarizeNode` wrapper and calls `threadSummarizeNode` directly. Docs (CLAUDE.md, docs/INTERRUPT.md, docs/OBSERVABILITY.md) collapse the dual-mode sections; tool-ui card comments and create-stream.ts / callback-collector.ts comments reword the parent_run_id quirk without referencing the removed toggle. Memory: langgraph-subgraph-run-map-bug.md updated to reflect that the workaround was retired after `streamSubgraphs: true` + `__pregel_resume_map[ns]` were confirmed working for the ask_location interrupt + Command(resume) flow. Specs (002, 003) left as historical snapshots — they describe decisions at the time.

Tests: removes 2 obsolete inlined-topology tests + 2 redundant summarizeNode-wrapper tests; same 7 pre-existing failures (schedule-background-node, memory/{recall,template}), unrelated to this change.

* fix(observability): nest subgraph steps under wrapper + close waiting chain on resume

Two bugs surfaced after USE_SUBGRAPH=true shipped:

1. Inner-subgraph steps (model / tools / subgraph __start__) landed at parent='root' instead of nesting under the weatherAgent wrapper. Cause: transform.ts parentIdFor used 'candidate.step >= s.step' to skip wrappers, but under compiled subgraphs the wrapper chain has langgraph_step HIGHER than its inner steps (outer RunnableSequence fires after the inner CompiledStateGraph ends). When repRaw's parent_span_id resolved to a span in the SAME step (e.g. ChatOpenAI → model RunnableSequence), the fallback couldn't find weatherAgent and dropped to root. Fix: pick the wrapper by longest-ns-strict-prefix of s.ns, not by step number.

2. handleChainError now flips wrapper chains to status='waiting' on GraphInterrupt (matching the synthetic human span). Without backfill they'd stay waiting forever — the chain's eventual handleChainEnd is a no-op because end() skips waiting→completed to protect the synthetic human span. Fix: extend backfillWaitingInterruptSpans to also close kind='chain' / status='waiting' wrappers when a resume tool arrives. Chain wrappers don't carry meta.interrupt_tool, so the second UPDATE closes them per-thread unconditionally (at most one active interrupt per thread in practice).

Tests: +3 (transform parentIdFor regression guard, backfill chain wrapper close, backfill cross-thread isolation); updated 1 (chain wrapper status expected 'waiting' not 'completed'). 56/56 observability tests pass; same 7 pre-existing failures in memory/{recall,template} + schedule-background-node, unrelated.

* refactor(backend): inline builder at top level — drop buildSubgraph wrapper

The 'function buildSubgraph() { return new StateGraph(...).addNode(...).addEdge(...) }' wrapper buys nothing — it gets called once at module load, has no callers from app code (only the smoke test), and just adds an extra frame around a literal. Inline it as 'export const builder = ...' and drop the wrapper.

The smoke test in tests/backend/agent-topologies.test.ts updates the import + describe block name accordingly. The test still asserts the structural shape (the compiled subgraphs wired as opaque nodes) and that compile() doesn't throw — no coverage loss.

* docs(memory): tell save_memory not to duplicate under similar keys

Real-world bug: the model was saving the same fact under 'primaryEmail' AND 'email' (or 'phone' + 'mobile'). Add a CONSTRAINT to the system prompt so each fact lands under one canonical key. Single-line addition, no other change.

* fix(observability): don't backfill chain wrappers — only human spans

User feedback: chain wrapper's status='waiting' shouldn't flip to 'completed' on tool arrival — the chain might still be processing the resume payload when the tool starts. The backfill I added in the previous commit was eager and visually meaningless: transform.ts renders the step as 'completed' via bucket.ended (the raw span status field doesn't drive the panel's step display), so leaving the wrapper at status='waiting' renders identically without overstating progress.

Revert: backfillWaitingInterruptSpans no longer touches kind='chain' rows. The synthetic human span (kind='human', inserted by handleToolError) still gets backfilled on resume — that one has no chain end of its own, so the backfill is the only way to close it. Tests: updated the chain-wrapper test to assert status stays 'waiting' (transform still renders it correctly); removed the handleChainEnd-flips test (handleChainEnd won't fire for the interrupted wrapper, since the chain ended via handleChainError).

* feat(observability): backfill waiting chain wrappers via __end__ signal

When a chain wrapper with output.output="__end__" lands for a thread,
the LangGraph branch has finished — flip any waiting chain wrapper at the
parent ns to status="completed" with ended_at = the __end__ wrapper's
ended_at. The prior turn's wrappers never receive their own handleChainEnd
(handleChainError already finalized them when GraphInterrupt bubbled up),
so the DB stays at status="waiting" / ended_at=null forever without this
trigger. handleChainError now writes ended_at=null (not Date.now()) so the
backfill is the only writer of the final timestamp.

* fix(panel): rotate chevron on collapse toggle

data-[collapsed=true]:-rotate-90 was on the SVG inside the
CollapseToggle button, but SpanPrimitive.CollapseToggle sets
data-collapsed on the BUTTON — the SVG never sees the attribute, so the
chevron never flips. Move the rotation class onto the button itself
(the only visible content is the SVG, so it rotates with the button).

* feat(backend): inherit parent checkpointer in sub-agent subgraphs

Sub-agent StateGraphs compiled with checkpointer: true so the
subgraph pulls the parent's PostgresSaver instead of starting fresh.
Required for sub-agent interrupts to share the thread's checkpoint
namespace and resume cleanly across the parent boundary.

checkpointer: true is invalid on root graphs (langgraph throws); it
only applies to subgraph.compile() when the parent already wired a
checkpointer.

* refactor(observability): wire panel root chain resolution

collectRootChains now keys roots by meta.run_id (invariant: outermost chain's span_id === meta.run_id) and dedupes via Map<run_id, RootChain> so two invokes sharing compile name don't collapse. rootForStep returns null instead of falling back to rootChains[0]; orphan steps are dropped rather than parked under a synthetic 'graph.invoke' root. Also fixes handleChainStart param order in CapturingHandler (runType was parentRunId, parentRunId was being misread), renames scheduleBackground to triggerBackgroundAgentNode, passes memoryJson in the prompt template, and comments out per-subgraph checkpointer. oxfmt pass on touched-by-pre-commit-hook files.

* test: align two tests with current handler behavior

callback-collector.test.ts: chain-wrapper's ended_at stays null through an interrupt (only the synthetic human span alongside the tool gets stamped); markRunningAsFailed reconciles it on restart when the resume fires handleChainEnd. trigger-background-agent-node.test.ts: drop afterSeconds: 3 from the runs.create payload expectation — the dispatch goes out immediately, no delay.

* fix(observability): persist chain at Start + dedupe roots by parent

- CapturingHandler.handleChainStart now calls persistSpan after
  start(), so the outer RunnableSequence row lands in DB before
  handleChainEnd fires. Closes the in-flight visibility gap where
  bg invokes never showed up in the panel because End hadn't fired.
  Inner spans (LLM/tool/retriever) still persist on End only —
  Start-persisting every leaf triples DB writes for no panel gain.
- collectRootChains now skips chains whose parent_span_id is null
  (langgraph-api's outermost RunnableSequence plumbing) and treats
  the inner CompiledStateGraph as the root. Map keyed by
  parent_span_id, so two invokes with different outermost parents
  stay distinct.

End-only persist was the original design — End stamps output / usage /
error / status, ON CONFLICT DO NOTHING dedupes the End overwrite. The
interrupt branch in handleChainError still fires persistSpan early so
waiting chains surface. The chain Start persist is the symmetric
counterpart for the in-flight case.

The transform fixture change is staged separately — the new filter
shape (parent must exist, dedupe by parent) requires fixtures to
model an outermost + inner wrapper pair per invoke. Failing tests:
8 in tests/lib/observability/transform.test.ts.

* feat(observability): surface in-flight bg runs to the panel

The bg agent dispatched via runs.create has no persisted CapturedSpan until its first Start callback fires — the panel otherwise drops the row from the waterfall silently. Stamping metadata.parent_message_id on every bg runs.create lets the observability per-turn GET filter langGraphClient.runs.list(threadId) to the current turn; the response now carries in_flight_runs alongside spans. The panel renders a synthetic skeleton row at the tail of the waterfall when in_flight_runs is non-empty and the sheet polls every 10s (RefreshCountdown owns the 1s/10s ticks so the panel body stays untouched) until the bg agent finishes. Shared lastHumanMessageId helper replaces the inline envelope walk in callback-collector and triggerBackgroundAgentNode. triggerBackgroundAgentNode now awaits dispatchViaCreate so SDK rejections hit the catch instead of leaking as unhandled rejections.

* feat(observability): panel + sheet show in-flight bg runs

Synthetic RunningSkeletonRow at the tail of the waterfall when in_flight_runs is non-empty; click-toggle selection on rows; RefreshCountdown component owns the polling + countdown lifecycle in one place so the parent sheet doesn't re-render every second. APIS.md documents the new in_flight_runs shape; tests cover the API route and the callback-collector parent_message_id stamping.

* refactor(observability): drop lodash-es pick, use destructuring in in-flight fetch

* feat(observability): server-side transform + per-turn detail endpoint

Push CapturedSpan → SpanData transform + aggregate into the route
handlers (lib/observability/transform.ts, aggregate.ts). Per-turn
detail endpoint at [parentMessageId]/spans/[spanId] with SDK fallback
filtered by metadata.parent_message_id. Panel lazy-fetches detail on
click with a glassy refresh overlay; restore hover tooltips, deselect
toggle, filled-triangle disclosure, animated message expansion, and
model name on LLM leaves. parentMessageId stamped onto every SpanData
so the panel can build the per-turn detail URL without re-walking the
waterfall tree. Docs (OBSERVABILITY.md / README.md / CLAUDE.md /
APIS.md) updated to match the new shape.

Also:
- Rename graph node "triggerBackgroundAgentNode" → "triggerBackgroundAgent"
  (string id only; the imported function name is unchanged). The Node-suffix
  was a leak from the function-call layer; the graph topology reads cleaner
  without it. agent-topologies.test.ts updated to match.
- Fix 8 pre-existing failures in transform.test.ts by giving root chain
  spans a non-null parent_span_id — single-root tests use "" so the
  parent's display name still falls through to span.name="chain"; the
  two multi-root tests use distinct placeholders ("pregel" / "pregel-bg"
  and "pregel-A" / "pregel-B") and have their name assertions updated
  to match. The fixtures assumed parent_span_id=null would pass
  collectRootChains; the production filter requires a non-null parent for
  any span meant to anchor a real (non-orphan) tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): invalidate cache on profile key delete

DELETE /api/memory/profile/[key] now calls invalidateMemory(user.id)
after a successful delete, mirroring save-memory-tool's write-side
invalidation. All three mutation paths (save_memory tool, profile PUT,
profile DELETE) now keep the LRU cache honest — no 60s window where
the next chat turn reads a stale doc.

Also tighten the conflict-resolution example to "ask_location_cache /
ask_location_result" so the prompt doesn't name a field the tool
explicitly should not store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(memory): thread-summarize with store-anchored trigger and JSONL transcript

Replace the stateless THRESHOLD/KEEP_RECENT formula with a single KEEP_RECENT knob and a store-anchored trigger: read lastCompressedEndIdx from the persisted SummaryEntry list, re-emit missing chunks on user-driven delete, and write fixed K-sized windows amortized across K turns.

Switch the LLM-facing transcript from "User:/Assistant:" prose to JSONL (one line per human turn, tool_calls first-class). Role labels in plain text collided with ":" in user content, and trailing "[tool_call X]" prose was getting dropped by the model — JSONL iterates tool_calls naturally and is structurally aligned with the LLM's structured OUTPUT refs.

Memory tab frontend groups the flat API list by threadId using Map insertion order — strict passthrough of the backend's createdAt-asc order on both outer and inner levels.

* docs(backend): correct computeCumulativeWindow comment to match round-down behavior

The previous comment claimed the window length was 'fixed at KEEP_RECENT' but the implementation rounds down to the largest K-multiple of uncompressedCount. Update the comment with the actual math, examples, and rationale (round-down amortizes the LLM call cost better than fixed K — each store write covers the maximum summarizable window that fits in one prompt, fewer total passes than fixed-K). No code change.

* feat(memory): store thread summaries as structured entries

Replace SummaryEntry.summary: string with summary: { entries: [{ question, answer, refs }] } — the same shape the LLM produces via withStructuredOutput(summaryOutputSchema). Storage is the verbatim LLM output (no pre-formatted text), so later passes can compare / merge / dedupe across re-runs; the prior string shape lost the structure and produced near-duplicate summaries for the same Q&A.

formatSummaryText moves to lib/langgraph/format-summary.ts and is shared by:
  - chat-agent: passes the structured entries through to the <threads> prompt block (model reads the JSON dump).
  - Memory tab UI: calls formatSummaryText(s.summary.entries) at display time.

Strict schema — old string-typed summaries in the store fail SummaryEntrySchema.safeParse and are silently dropped by getAllUserSummaries. Run pnpm db:reset (or equivalent) to clear stale data, or re-trigger compression on those threads.

Verified end-to-end: seeded a structured summary to the test-obs account via scripts/seed-summary.ts; the dev server's /api/memory/threads and /api/memory/profile return the structured form; the Memory tab UI renders the formatted text identically to the prior string-typed output.

* feat(memory): thread summaries title + UI polish

Two coupled changes in one commit:

- API: GET /api/memory/profile now returns {store, auth, threads[]} with
  threadTitle (joined from the threads table) per summary entry. Wire
  shape stays flat; UI groups client-side by threadId.

- UI: thread header renders threadTitle (raw id as muted meta fallback);
  delete dialogs get a spinner + disabled state during the in-flight
  DELETE; loading state replaced with a skeleton mirroring the real
  layout; description paragraph under Thread summaries; ScrollText icon
  on thread rows.

* fix(memory): neutral summary voice + trailing AI + empty states

Three coupled memory polishing changes:

- Slice: extend the excerpt past humanIndices[endIdx] to the next human
  (or to messages.length if endIdx is the last) so the trailing user
  question captures its assistant reply. The previous slice stopped at
  the user message itself, leaving the last JSONL entry as a Q with
  no A. Locked in by a new test case.

- Prompt: rework THREAD_SUMMARIZE_PROMPT to a third-party observer
  voice — Q is the topic, A is the substance. Drop meta-verb narration
  in the few-shot example and in INPUT's tool_calls description; ban
  first/second-person pronouns and process-wrapping verbs (提供了 /
  请求了 / 已回应 / ...).

- UI: replace the two "No X yet." one-liners with icon-circle + title
  + helper paragraph empty states inside Card chrome.

* fix(memory): summary trigger at K, prompt Q as user question

- Node gate: drop `<=` to `<` so the first trigger fires at
  humanCount == K (was K+1). Round-down writes the same [0..K-1]
  window either way — the K+1 wait stranded users who never sent
  another message. Update the inline-pattern comment that was
  still describing the old K+1 timing. The "below threshold" test
  now uses K-1 humans and a new test locks in the K trigger.

- THREAD_SUMMARIZE_PROMPT: OUTPUT schema `question` field hint
  changes from `<topic of one chunk>` to `<the user question>`
  so the model writes Q as a Q, not as a section title.

* feat(backend): share thread summaries + trim at invoke across all agents

Router classifies from the last HumanMessage only — full history was a token-cost move for a yes/no classifier and could route off a stale topic.

trimMessagesForInvoke drops turns covered by thread summaries from the LLM input array; applied to chat / weather / crypto / code. state.messages is untouched (UI + checkpointer read it directly). Older turns surface via <earlier_conversation> in the SystemMessage, rendered with formatSummaryText so the model sees the same prose the Memory tab shows.

loadThreadSummariesForPrompt lifts out of chat-agent into template.ts so all four sub-agents share the same store-read path. Welcome-screen sample prompt tweaked to request test cases alongside the Two Sum snippet.

* feat(memory): collapse thread summaries by default with animated expand

Per-thread summary blocks were always expanded, which made a long thread list scannable only by scrolling. Defaulting each row to collapsed and surfacing a chevron toggle gives the Memory tab the same shape as a chat list: one header per thread, expand on demand.

Two parts:

- State: rename the Set from 'collapsed' (default-open) to 'expandedThreads' (default-closed) so absent ids == collapsed. A re-click removes the id and the row returns to collapsed with no extra ceremony.
- Motion: wrap each row in Radix Collapsible so the body height interpolates from 0 to auto via the --radix-collapsible-content-height variable. New animate-collapsible-down / animate-collapsible-up keyframes in globals.css read that variable and run for 200ms ease-out. The trigger owns aria-expanded/aria-controls and data-state lands on the body for CSS hooks; aria-label flips between 'Expand <title>' and 'Collapse <title>' so the screen reader announces the next-click action.

Lifted groupThreadsByThreadId out of the component so the grouping + recency sort + null-title branches get pure-function coverage in tests/frontend/settings/memory-view-group-threads.test.ts. Tests target the toggle by data-hint='thread-collapse' and the body by data-slot='thread-body' — both stable against aria-label churn and class changes.

* docs(memory): add MEMORY.md and update CLAUDE/README/TOOLS for this branch

The dual-graph runtime (mainAgent + background_agent), the save_memory tool, the threadSummarizeNode trigger, and the Memory settings tab all landed in this branch — the existing docs only described the pre-dual-graph state (a single agent graph + afterAgentNode, no memory tab, no save_memory tool in TOOLS.md). This commit catches the docs up without touching source code.

docs/MEMORY.md (new) — full design doc in the OBSERVABILITY.md / TOOLS.md / INTERRUPT.md style. Covers:
- Dual-graph topology (mainAgent + background_agent, both registered in langgraph.json, with triggerBackgroundAgentNode HTTP-dispatching via the SDK).
- The per-sub-agent memory prefix call chain (loadThreadSummariesForPrompt → trimMessagesForInvoke → buildSystemMessageWithMemory) and the <memory> + <threads> mustache template.
- save_memory input/output shapes, the 8-step write path (including mergeMemory on the merged view + filterToStoreOnly + assertProfileSize + invalidateMemory), and the fail-closed error classes.
- Thread summaries: store-anchored trigger (read back max(endMessageIndex) — heals deletion/replay), computeCumulativeWindow math, JSONL transcript rendering, structured LLM output, trimMessagesForInvoke cut-point rule.
- Memory tab UI (About-you rows with auth-overlay classification, Thread summaries with collapsed default + Radix animation).
- Configuration knobs (MEMORY_THREAD_SUMMARY_KEEP_RECENT, MEMORY_PROFILE_MAX_BYTES) and storage model (PostgresStore namespaces, delete-loop gotcha).
- Security stance (no forget tool, fail-closed userId, path regex, size guard, OAuth token redaction, cross-user 404) and known trade-offs.

CLAUDE.md — "What this is" rewritten to describe the dual-graph + memory + summarize nature. Architecture tree updated: added background-agent.ts, store.ts, callbacks.ts, trigger-background-agent-node.ts, thread-summarize-node.ts, tool/memory/, backend/memory/{recall,template,profile-size}.ts, with the real lib/memory/ function names. Backend graph section rewritten to cover both compiled graphs. State-persistence prose: afterAgentNode → touchLastMessageNode. New short 'Memory & thread summarize' pointer linking to MEMORY.md.

README.md — Features list: dual-graph bullet replaces the single-graph one; new 'Cross-conversation memory' bullet links to MEMORY.md; tool-using-agent bullet now mentions save_memory. Documentation table: MEMORY.md added as the second row. Environment table: MEMORY_THREAD_SUMMARY_KEEP_RECENT + MEMORY_PROFILE_MAX_BYTES entries. Project layout: fully rewritten to reflect the dual-graph backend, the memory tool/middleware modules, the lib/memory/ module, the tool-ui/memory/ cards, and langgraph.json registering BOTH graphs.

docs/TOOLS.md — 'Tool groups' table gets a Memory row. New '## Memory' section with the save_memory row + path regex / size-guard / no-forget-tool notes. Compliant with CLAUDE.md rule #1.

* chore: drop debug seed script and gate freshAge on NODE_ENV

Addresses two review comments on PR #7:

1. Remove scripts/seed-summary.ts — debug-temp file from the structured-summary
   refactor. No production / test code imports it (verified via repo-wide grep);
   safe to delete outright. Cleanup-observability.ts is the only script left.

2. Gate `freshAge: 0` on NODE_ENV !== 'production' in lib/auth/config.ts. The
   value disables better-auth's SESSION_NOT_FRESH check so dev fixtures can
   keep a single session for days without breaking the Security tab's
   "Active sessions" card. Production keeps the strict 24h default — session
   expiry (7d) still bounds exposure in dev.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
FireTable added a commit that referenced this pull request Jul 12, 2026
* [Feat]: Per-LLM-call credit quota with admin UI (issue #15)

Tracks every LLM call against per-role rolling-window caps and surfaces
admin controls for providers, keys, models, and credit history.

Provider schema:
- Move `baseUrl` from `provider.apiKeys[]` to a top-level required column
  on `provider` (a provider's endpoint is a property of the upstream,
  not of the auth material). Callback metadata → `findProviderId()`
  matches this to recover providerId without scanning jsonb.
- Admin UI rewires baseUrl to a dedicated editable row on each provider
  card; keys table drops its baseUrl column.

Credit accounting (`lib/credit/`):
- `CreditTrackingHandler` (BaseCallbackHandler) caches RunMeta by runId
  in `handleLLMStart` (langgraph_node, ls_model_name, llm.kwargs
  .configuration.baseURL) and looks it up in `handleLLMEnd` /
  `handleLLMError` — handleLLMEnd doesn't reliably get metadata in real
  runtime, so the map is the only way to carry userId across hooks.
- handleLLMEnd records success rows (credits = input/1k * inputPer1k +
  output/1k * outputPer1k, frozen at call time per rate-change
  semantics). handleLLMError records error rows with zero credits.
- handleLLMStart does NOT enforce quota — LangChain's CallbackManager
  swallows throws there, so enforcement moves to `backend/model.ts`'s
  quota-aware wrapper (separate PR) that throws before invoke/stream.

Admin UI (`app/admin/`):
- Providers tab: per-provider cards with editable baseUrl, models table,
  keys table (encrypted on POST, only `name` tail exposed on GET).
- Roles tab: edit creditLimit + windowHours per role.
- Settings → Credits tab: per-user call history with status badge.

Migration (`db/migrations/0003_melted_bedlam.sql`):
- Seeds `role` rows (guest/user/admin) before the FK on user.role_id.
- Seeds the MVP openai provider with env-driven fields via `__VAR__`
  placeholders expanded by `scripts/db-migrate.ts` and `tests/setup.ts`:
  - `__OPENAI_BASE_URL__`      → env or https://api.openai.com/v1
  - `__OPENAI_API_KEY_ENCRYPTED__` → AES-256-GCM blob jsonb literal
    (empty array when env unset)
  - `__OPENAI_MODEL_JSON__`    → model entry jsonb literal with
    inputPer1k=0.25, outputPer1k=1
  - `__SEED_PROVIDER_ID__`     → 'openai' (or OPENAI_SEED_PROVIDER_ID env)

Observability:
- Refactor: move `backend/observability/callback-collector.ts` →
  `lib/observability/callback.ts` (shared between backend and tests).

Tests: 835/835 passing (lib/credit/callback, lib/provider/schema,
api/admin/providers, observability suite).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [Refactor]: Prefix subgraph nodes with per-agent name

Rename generic "model" / "tools" nodes to "chatModel" / "chatTools",
"codeModel" / "codeTools", etc. inside each subgraph. The parent agent
graph embeds these subgraphs by node name, so identical names collide
when two subgraphs are wired in (e.g. both declaring a "tools" node).
Per-agent prefix keeps each subgraph's internal topology namespaced.

Each `*Route` helper stays inline at the subgraph file — it's only
used by `addConditionalEdges`, no need to export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: per-LLM-call credit quota with admin UI (issue #15)

Gate every POST/PUT/PATCH at app/api/[..._path] against checkQuota(userId); when over cap, return a fabricated LangGraph SSE stream that emits a show_quota_card tool_call so the chat surface renders the QuotaCard instead of a 4xx. Quota window is a fixed UTC day (date_trunc('day', now() AT TIME ZONE 'UTC')) so every user shares one resetAt, not a per-user rolling drift. Surface the same numbers as a read-only block inside the UserButton dropdown — single shared <QuotaProgress /> primitive drives both the slot and the card so they can't disagree. roleName is stamped into window.__CONFIG__ by app/layout.tsx (server-side JOIN) so UserView reads it without a fetch or prop drilling. Admins get a one-click Admin Dashboard menu in the dropdown. Tool-UI show_quota_card is client-only — no backend tool, the proxy injects the tool_call directly on the messages/partial event so prior turn history is preserved.

* fix(credit): unify QuotaCard visuals with credit slot

Drop the duplicate CoinsIcon + Usage header inside the card variant — CardHeader above already carries the icon and Credit limit reached title. Shift to pl-12 so the bar and copy align under that header. Swap SparklesIcon → CoinsIcon to match the slot. Tweak copy to 'Reached Nh window' in both branches for parity.

* refactor(credit): extract shared /api/credit/status reader to lib/credit/status.ts

The UserButton dropdown slot and the settings page summary card both read the same /api/credit/status endpoint. Hoisting the module-scope cache + in-flight promise into a dedicated lib/credit/status.ts keeps the two surfaces in lock-step and lets future readers join without duplicating the dedupe logic. The 1s TTL is preserved so opening the menu after a pause still fetches fresh data.

* feat(settings): credit summary section with 4 stat cards + absolute timestamps

Adds a standalone Window Quota section above the call history: 4 compact stat cards (Used / Remaining / Used % / Window) + a thin progress bar, then a separate Call history section. Reuses lib/credit/status.ts so opening the settings tab while the UserButton menu is mounted costs zero extra fetches. Swaps the When column to absolute YYYY-MM-DD HH:mm:ss timestamps for record display, and renames Agent -> LLM.

* fix(ui): swallow the click that opens a Radix dropdown when content overlaps the trigger

When the trigger sits at the bottom of the screen, the menu opens upward and the LAST item (e.g. Sign Out) ends up directly under the trigger. The pointerdown opens the menu; the same physical press continues, and the click event fires on that overlapping item, instantly triggering it. Track pointer movement on the content; if the pointer has not moved since the menu mounted, the first pointerdown is the same press that opened the menu, so preventDefault it. Real clicks after the user moves the mouse pass through untouched.

* refactor(provider): deriveKeyName returns first-3 + ellipsis + last-4

Switches the displayed apiKey identifier from '...xyz9' (tail-only) to 'sk-…xyz9' (first 3 + ellipsis + last 4) so operators recognise the key family (sk-, gsk-, AIza…) at a glance and still confirm the tail. No plaintext exposure: the secret body stays server-side. Tests + public docs (ADMIN.md, APIS.md, DB.md) follow per CLAUDE.md rule #1.

* fix(admin): polish admin tables with sentence case + Dialog confirms + inline add rows

Bring /admin tables to a single visual language:
- Sentence-case column headers + button labels (Edit/Delete/Save/Cancel/Rotate), matching the memory tab
- Replaced native confirm() with Radix Dialog (destructive variant reserved for the in-modal confirm only; row actions use outline)
- Converted the standalone 'Add role' Card into an inline trailing row in the Roles table, matching the inline add row pattern in Models + Keys
- Compacted inline add row inputs (h-7 -> h-5, smaller text, no row tint) so the data rows above stay visually dominant
- 'Add provider' card restyled: dashed border + muted background + Plus icon on Create, drops the CardHeader chrome so the data cards read as primary
- page.tsx: removed the full-bleed background wrapper so /admin renders in the same shell as the rest of the app

* fix(ui): catch opener-press release in Radix dropdowns

7484a01 used onPointerDown, which fires on the next press — not the release of the press that just opened the menu. Switch to onPointerUp so we catch the opener's release directly. Per the Pointer Events spec, preventDefault on pointerup suppresses the compatibility click that would otherwise tunnel through to the item sitting under the trigger. hasMovedRef still gates it: a real click after the user moves the mouse passes through untouched.

* style(credit): mute CoinsIcon in QuotaProgress unlimited view

Mirrors the slot variant in components/auth/user/credit-usage-slot.tsx, where the icon already has text-muted-foreground. Without it the unlimited state rendered the icon at full foreground color while the surrounding text used muted-foreground, making the icon look louder than the label.

* feat(credit): render Skeleton in UserButton slot during load

peekCachedStatus() returning null used to short-circuit to null, so the slot disappeared during the first fetch and then popped in when status landed — visible layout shift in the dropdown. Render a Skeleton that mirrors the QuotaProgress slot layout (header / used / progress+pct / window hint) so the slot's height stays constant. Cache hits skip it: peekCachedStatus returns non-null on the first render and we go straight to QuotaProgress. Also tighten the wrapper padding to py-1.5 to match the slot's tighter chrome.

* style(credit): collapse slot header to single Skeleton

Two rounded rectangles for icon + label read like a tiny progress bar; one wider pill (h-4 w-16, ~icon+gap+Usage width) reads as the header placeholder and matches the rendered bar height. mb-1.5 preserves the original pb-1.5 spacing before the Used row.

* style(credit): render header chrome in slot skeleton

Pulsing the icon and "Usage" label felt noisy — they're constant chrome that never changes between users / states. Only the data rows (used/total, progress+pct, window hint) need to be placeholders. Also bump gap-1→gap-2 for breathing room and widen the bar / used / hint placeholders so the layout doesn't visibly contract when status lands.

* refactor(credit): extract QuotaHeader for shared icon+label chrome

Three call sites rendered the same CoinsIcon + "Usage" label markup: <SlotSkeleton />'s header, the unlimited-state branch of <CreditUsageSlot />, and the "slot" variant of <QuotaProgress />. Centralised in components/credit/quota-header.tsx so the icon, label, and gap/text sizing stay in lock-step. className is forwarded so callers can layer outer spacing (QuotaProgress passes pb-1.5 for extra breathing room before the "Used X / Y" line; the other two callers use the surrounding flex-col gap for spacing).

* feat(credit): navigate to /settings/credit on CreditUsageSlot click

Wrap the unlimited + limited branches in <DropdownMenuItem asChild onSelect={...}>. asChild merges Radix's onSelect (and its auto-close) onto the slot's own div so hover/focus styling lands on the content instead of an extra ring around it. A plain button would leave the menu open over the new page — Radix only auto-closes on DropdownMenuItem.onSelect, not on arbitrary child clicks. Skeleton stays unwrapped: there's nothing to navigate to while loading. hover:bg-accent/40 + focus:bg-accent matches the other menu items' affordance, with a lighter hover so it reads as preview not selected.

* refactor(credit): unify on Credit — Quota* → Credit* across UI + backend lib

Three words were doing the same job: Usage (slot + QuotaProgress header), Quota (lib functions, component names, prop names, tool name, message-id prefixes), and Credit (unit, tab label, summary section). Unify on Credit as the canonical term:\n\n- components/credit/quota-{header,progress}.tsx → credit-{header,progress}.tsx; QuotaHeader/QuotaProgress/QuotaTier/tierFor/tierClass/QuotaProgressProps → Credit*; CreditHeader now renders 'Credits' instead of 'Usage'\n- components/tool-ui/quota/ → credit/; QuotaCard → CreditCard; tool name show_quota_card → show_credit_card\n- components/auth/user/user-button.tsx: quotaSlot prop → creditSlot; app/assistant.tsx call sites updated\n- lib/credit/check.ts: checkQuota → checkCredit, QuotaStatus → CreditStatus\n- lib/credit/errors.ts: QuotaExceededError → CreditExceededError (class name + this.name + message)\n- lib/credit/invoke.ts: quotaExceededReply → creditExceededReply\n- lib/credit/callback.ts: instanceof check + comment\n- app/api/[..._path]/route.ts: import + function name creditBlockedResponse + local var + tool name + runId/messageId prefixes + additional_kwargs flag\n- app/api/credit/status/route.ts: import + call\n- tests/lib/credit/{check,callback}.test.ts: imports, calls, runId string literals\n- components/credit/credit-summary-card.tsx: section heading 'Window Quota' → 'Window credits'; helper imports

* docs: align credit terminology with code rename (quota → credit)

docs/CREDIT.md: heading 'Credit quota' → 'Credit'; checkQuota → checkCredit; QuotaExceededError → CreditExceededError; 'invokeWithQuota' → 'invokeWithCredit'; 'quota check' → 'credit check'; 'quota-exceeded throw' → 'credit-exceeded throw'\n\ndocs/ADMIN.md: 'credit quota enforcement' → 'credit enforcement'; checkQuota → checkCredit\n\ndocs/DB.md: checkQuota → checkCredit; QuotaExceededError → CreditExceededError in the role-table notes + the credit_usage_log row\n\ndocs/AUTH.md: 'credit quota' → 'credit cap'; 'env-quota knob' → 'env-knob for per-user credit'; 'Resend free-tier quota exceeded' → 'Resend free-tier rate limit exceeded' (different concept — email provider rate limit, not our credit system)\n\nUnrelated 'quota' mentions left alone: docs/ATTACHMENTS.md 'storage quotas' (R2 storage, not credit), docs/DEPLOY.md 'project id quota' (WalletConnect project quota, third-party API), lib/email/send-verification.ts EMAIL_QUOTA_EXCEEDED code (third-party email rate limit, code is wire-shaped so we don't rename it).

* style(email): clarify 429 comment (provider rate limit, not our credit)

The constant EMAIL_QUOTA_EXCEEDED is wire-shaped (clients may pattern-match on it) so it stays — only the inline comment gets clarified to point at the right concept: Resend's free-tier rate limit, not the user credit system.

* fix(credit): honor windowHours in SQL and resetAt boundary

windowHours was read but the SQL predicate hardcoded  (UTC midnight) and resetAt was always nextUtcMidnight(). Setting windowHours=8 in admin made the displayed 'resets at HH:MM' drift to whatever UTC midnight happens to be in the user's local zone — the SQL was effectively summing a 24h slice regardless of the configured window.\n\nFix: compute windowStart as the UTC-aligned floor of now into a windowHours bucket (epoch=0 lands on UTC midnight, so 8h buckets land on 00:00 / 08:00 / 16:00; 24h reverts to UTC midnight). Pass windowStart as the SQL  predicate; resetAt = windowStart + windowHours. Display still localizes via toLocaleTimeString. For a UTC+8 user on windowHours=8, the slot now shows 'Reached 8h window · resets at 16:00' when now is in [08:00, 16:00) UTC — matches the cycle the admin configured.

* fix(credit): stringify windowStart before binding to sql template

node-postgres accepts Buffer | string for parameter binding; passing a Date instance triggers ERR_INVALID_ARG_TYPE. windowStart.toISOString() serializes to the same UTC timestamp Postgres parses back to the same timestamp without zone shift.

* fix(credit): utc-anchor windowStart + display polish

windowStart now floor-snapped to UTC multiples of windowHours (epoch=0 on UTC midnight, so windowHours=8 lands on 00/08/16 UTC). Display components already localize via toLocaleTimeString, so a UTC+8 user sees 00/08/16 UTC as 08/16/00 local — same moment, different wall-clock.

Also drops Plus/Trash2 icon prefixes from admin destructive buttons (CLAUDE.md rule 7: tool-UI buttons are text-only) and rephrases the reset copy from 'Reached 8h window · resets at 00:00' to 'Reset every 8 hours, next reset at 00:00' so the line reads as a status, not a state-machine trigger.

* feat(admin): refactor tabs to dialogs and re-derive api key name on rotate

Replace inline row editing with FormDialog-driven flows for Providers, Models, Keys, and Roles. Auto-generate provider id on add; lock id="default" from deletion (server 409 + UI disabled). KeyDialog derives name from plaintext; explicit name still overrides. PATCH /api/admin/providers/[id]/keys/[keyName] now re-derives name from rotate plaintext unless caller overrides, with collision check excluding self. Adds Badge, FormDialog, UnderlineInput primitives.

* feat(admin): add users tab — role dropdown, ban, kick sessions

Migration 0004 adds user.banned. lib/auth/config.ts session.create.before hook blocks signin for banned users. PATCH /api/admin/users/[id] DELETEs every session row when banned flips to true so the cutoff is immediate (last-admin guard mirrors default-provider protection). AdminTabs gains a Users tab with credit-summary-card-style StatCards (Total / Admins / Banned) and a table where Edit uses a Select dropdown for role. Skipped Better Auth's built-in admin plugin to avoid its parallel role text column colliding with our role_id FK.

* fix(auth): surface USER_BANNED message on signin

Throwing APIError(FORBIDDEN, { message, code: "USER_BANNED" }) instead of a bare Error is the only path that survives Better Auth's error router — the typed shape is what reaches the client. Add a belt-and-suspenders toast.error() in the signin onError so the user always sees WHY signin failed (banned / wrong password / etc.); EMAIL_NOT_VERIFIED keeps its existing redirect.

* fix(dev): make dev:stop actually kill stale listeners

lsof -ti :2024,:3000,:3001 parses the comma list as one arg and silently matches nothing — kill was a no-op, stale next-server survived every shutdown. Iterate per-port with -iTCP:$p -sTCP:LISTEN so we target only the listener PID and skip Chrome's ESTABLISHED connections to those ports.

* fix(test): don't crash global setup when LLM_KEY_ENCRYPTION_KEY is absent

loadEnvConfig skips .env.local under NODE_ENV=test, so the KEK never reaches the test process even though .env still feeds OPENAI_API_KEY in. Previously that crashed every vitest run with KekMissingError before any test could load. Treat "no KEK" the same as "no key" — the seed INSERT becomes enabled=true with an empty api_keys array, harmless for the suite.

* fix(credit): share creditTrackingHandler and register on background agent

Two problems fixed in one move: (1) backend/agent.ts and backend/background-agent.ts each instantiated their own CreditTrackingHandler — duplicate singleton. (2) background-agent.ts only registered capturingHandler, so LLM calls in the background graph never entered credit quota. Hoist the singleton into backend/callbacks.ts (next to capturingHandler) and have both graphs import it from there.

* feat(provider): DB-backed model registry with LRU + CUD cache bust

New lib/provider/model-registry.ts: getChatModel({providerId?, modelName?}) resolves a ChatOpenAI from the DB on first call, then reuses an in-process LRUCache (key=`${providerId}:${modelName}`, max=10, ttl=1h). apiKeys are picked at random and decrypted via loadKek + aesGcmDecrypt. invalidateModelCache() (with optional key) drops entries on demand. backend/model.ts re-exports both, keeps the legacy env-only chatModel const for the 7 createAgent({llm:chatModel}) consumers, and fires a fire-and-forget warmup so the LRU is primed before the first /chat request.

All 7 admin provider CUD handlers (POST/PATCH/DELETE on the provider row, on models, on keys) now call invalidateModelCache() after a successful mutation, so admin writes take effect immediately without waiting out the TTL.

.env.test gains a fake LLM_KEY_ENCRYPTION_KEY so model-registry tests can round-trip a real encrypted key through aesGcmDecrypt.

* fix(backend): make chatModel pick up DB-backed provider at module load

Top-level await in backend/model.ts resolves getChatModel() before the export is captured, so the 7 createAgent({llm: chatModel}) consumers now read the DB-configured provider/model on backend boot instead of the env-fallback one. DB unreachable still falls back to env (buildEnvModel). Type stays ChatOpenAI because BaseChatModel.bindTools is optional and would break every consumer's .bindTools(...).invoke(...) chain; today the registry hard-codes ChatOpenAI, so the cast on getChatModel() is safe — see the comment for the future-非-OpenAI boundary.

* fix(admin): make model name editable in Edit dialog

API: ModelPatchBody now accepts an optional `name` (min 1, max 128). Path stays keyed on the original model name; collision check excludes self so a round-trip of the dialog's current value is a no-op. UI: ModelDialog's Name Input is now enabled in edit mode and the PATCH body includes `name`; description updated to reflect the new behavior. Tests: 4 new cases cover rename success, no-op rename, 409 collision, 400 on empty name.

* refactor(model): rename getChatModel → getChatModelFromDB; new getChatModel wrapper

model-registry.ts now exposes getChatModelFromDB(opts) for the pure DB lookup (with LRU + invalidate). backend/model.ts adds getChatModel(opts), the canonical entry point: try getChatModelFromDB, fall back to buildEnvModel() on miss so the backend still serves requests in dev before the seed provider is wired.

All 7 chatModel.X(...) callsites (chat-agent, code-agent, crypto-agent, weather-agent, rename-thread-agent-node, thread-summarize-node, router-agent-node) migrate to (await getChatModel()).X(...). The legacy sync chatModel const is deleted.

4 backend test mocks (agent.test.ts, router-agent-node, rename-thread-agent-node, thread-summarize-node) swap chatModel: { ... } for getChatModel: async () => ({ ... }). model-registry.test.ts imports getChatModelFromDB and all calls follow.

Type stays ChatOpenAI on getChatModel — BaseChatModel.bindTools is optional, the 6 .bindTools(...).invoke(...) consumers need the narrower type.

* fix(provider): shorten LRU TTL to 60s for cross-process visibility

invalidateModelCache() runs in the Next.js process (port 3000), but LangGraph.js (port 2024) is a separate node process with its own in-memory LRU — the Next-side invalidate can't reach it. 60s TTL bounds how long an admin provider edit can stay invisible to LangGraph.js. Short enough to feel live, long enough that hot nodes don't hammer the DB on every request.

* docs: align ADMIN/CREDIT/DB/APIS with the actual branch state

- CREDIT.md: rewrite 'where the cap is enforced' (proxy, not callback);
  split it from 'where the log is written' (callback) so the two paths
  stop getting conflated. The callback does NOT throw — enforcement is
  checkCredit() at app/api/[..._path]/route.ts on POST/PUT/PATCH.
  Document show_credit_card + the synthetic SSE stream.
- CREDIT.md: add GET /api/credit/status; mark CreditExceededError +
  creditExceededReply as unused today (defined, never thrown/called).
- DB.md: windowHours is UTC-anchored (epoch floor), not continuous;
  add base_url column + 'banned' column; point readers at the new
  PROVIDERS doc; fix Code->table map (provider reads from registry,
  build-model is the callback helper).
- ADMIN.md: add /api/admin/users section + Users tab; document the
  default-provider 409 PROTECTED guard; Provider.baseUrl lives on the
  row, not on each apiKey; rename flow + protected-provider UI state.
- APIS.md: add GET /api/credit/status; rename '## Users' -> '## Admin —
  Users' to disambiguate from Better Auth's user routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add PROVIDERS.md for the DB-backed chat-model registry

Covers getChatModel / getChatModelFromDB / invalidateModelCache, the
LRU + 60s cross-process TTL tradeoff (Next.js + LangGraph.js are two
processes with two LRUs — admin writes only bust the Next-side cache,
so the TTL bounds staleness on the LangGraph side), the seeded 'default'
row, the env fallback path that keeps pre-seed dev working, the per-node
callers, and the upgrade path (LISTEN/NOTIFY, per-agent binding,
fallback chains).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: update README + admin copy + package.json metadata

- README: add 'Experience' section (live demo + repo); point features
  list at ADMIN / CREDIT docs; refresh env table (OPENAI_* are now
  optional post-seed; LLM_KEY_ENCRYPTION_KEY + INITIAL_ADMIN_EMAIL
  added); rewrite project-layout tree to cover lib/{auth,credit,provider}
  + components/{credit,settings,auth} + app/api/{credit,admin}; flip
  Database section to enumerate the app-owned tables; add ADMIN /
  CREDIT / PROVIDERS entries to the docs index.
- app/admin/page.tsx: page subtitle reflects Users tab ('providers,
  API keys, models, roles and users').
- package.json: description, homepage, repository, bugs, keywords,
  author. license + type already present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: cover show_credit_card + LLM_KEY_ENCRYPTION_KEY + INITIAL_ADMIN_EMAIL + withAuth role

- TOOLS.md: add the Credit group row to the tool-groups table; document
  show_credit_card as a client-only render (proxy-injected tool_call,
  no backend execute). Note that args ride on the tool_call itself —
  no ToolMessage.
- DEPLOY.md: add LLM_KEY_ENCRYPTION_KEY + INITIAL_ADMIN_EMAIL to the
  Prerequisites list and to the cat > .env heredoc with inline
  generation commands. Note that the env path is now optional after
  migration 0003 seeds the 'default' provider.
- AUTH.md: document the withAuth({ role }, handler) overload in its own
  subsection — exact-match (admin does NOT imply user), 401/403 split,
  runtime roleIdSchema fallback to 'user' for FK-corrupt sessions,
  pointer back to rule #9. Refresh the 'add a second admin' note to
  point at the Users tab (admin UI affordance, not just DB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: drop docs/TODOS.md (open follow-ups now live as GitHub issues)

The 2026-06 entries in TODOS.md (deployment kit, attachments follow-ups,
RAG) are either shipped or superseded by real issues. New follow-ups
(per-agent model binding, fallback chains, dead CreditExceededError
cleanup) are tracked as GitHub tasks / TODO comments rather than a
separate doc that drifts out of date. Drop the file and the matching
CLAUDE.md table row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: cover proxy gates + key rotate/rename split

Proxy section was a single line; this branch wired three new
responsibilities on top of the catch-all that callers now rely on.
ADMIN.md and APIS.md said key PATCH was 'rotate only' — the route
also supports a pure rename via the optional name field.

* fix(admin): encrypt-only key path + typed duplicate on POST

Two greptile findings from PR #35 review:

1. providerPatchSchema accepted a raw apiKeys[] on PATCH, which would
   write caller-supplied bytes straight into the jsonb and fail later
   at aesGcmDecrypt with no signal. Strip apiKeys from the PATCH
   schema — encrypted material must travel through the dedicated
   /keys sub-resource which routes through encryptApiKey. The admin
   UI never assembled apiKeys on PATCH (whole-array replacement goes
   through POST /keys), so this is a defense-in-depth tighten.

2. POST /api/admin/providers surfaced the unique-PK constraint as a
   generic 500. Map to 409 DUPLICATE via onConflictDoNothing +
   returning-empty detection so the admin UI's error toast can
   distinguish the conflict path from other failures.

* fix(admin): unban of sole admin + 3 doc drift fixes

Cubic findings from PR #35 review, real correctness only:

1. Unbanning the only admin (PATCH {banned:false} with no roleId)
   incorrectly returned 409 LAST_ADMIN because the guard
   condition was 'roleId !== "admin" || banned === true' —
   undefined !== "admin" evaluated true. Split the role and ban
   triggers so unban is unconditional. Added a regression test
   in tests/api/admin/users.test.ts.

2-4. Doc drift only:
   - POST /api/admin/providers body example was missing the
     REQUIRED baseUrl field (would 400 on the schema).
   - DELETE /api/admin/providers failure codes missing
     409 PROTECTED for the default provider.
   - POST /api/admin/providers/[id]/keys doc advertised baseUrl
     as a per-key field; baseUrl lives on the provider row, the
     keys route only accepts {plaintext}.

* fix(lint): drop meaningless void + oxfmt --fix normalization

CI lint caught the oxlint no-meaningless-void-operator rule on
three onClick handlers in admin-tabs.tsx — confirmRemove already
returns void, so the void operator was a no-op. Replaced
() => void confirmRemove() with the bare reference. Ran oxfmt
to normalize the rest of the diff.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant