CLAUDE.md · diff

git:20260911.d1a3ef8 to git:20260912.40ee5b4

1 added, 242 removed. Audit A to B.

- # thursday
-
- A local-first voice agent. A realtime speech model ("Thursday") holds the call and only touches
- what can be answered in a glance (memory, one shell command). Anything that takes time — MCP, skills,
- a browser, minute-long jobs — is delegated to text-model bots that run in the background with a
- shell, a browser and skills; a skill is the one thing that can be handed back to the call
- (Settings › Thursday, off by default). Jobs outlive the call: they run on the server, and the
- screen is a projection of server state.
-
- **This is a public open-source repository** (MIT, `github.com/cgoinglove/thursday`, published to
- npm as `thursday-agent`). Everything committed here is read by strangers and shipped to their
- machines. Two things follow, and they are not style preferences:
-
- - **Write for a reader who has never met this code.** English, present tense. `README.ko.md` is
- the one translated file; everywhere else in the tree is English — comments, prompts, strings,
- identifiers, commit messages. Not even as an example inside a comment. No personal names,
- machine paths, keys, or half-finished thoughts in a comment.
- - **Anything private is named `*.local.*`** — a scratch note, a task list, a plan, a local
- override. `.gitignore` covers that shape, so a file named this way can never be committed by
- accident. `CLAUDE.local.md` is the working example: how you like to work with an agent — when to
- ask, how to commit — goes there, and Claude Code loads it beside this file. This file holds only
- what the code requires. Never `git add -f` one, and never rename one into the tree to "keep it
- for later"; if it is worth keeping, it is worth writing properly.
-
- # Layout
-
- ```
- features/<name>/ One domain: its data and its screens. A new feature copies this shape.
- <name>.schema.ts zod is the source of types (z.infer). No server-only imports (used on both sides).
- <name>.query.ts DB access. drizzle is used here only; screens, actions and tools all call this.
- Writes emit appEvents here, so every caller notifies the same way.
- <name>.action.ts "use server" + serverAction. Writes.
- <name>.*.ts run / manager / store files when the domain needs them.
- A second query file (`task.query.ts`) when one table is its own subject.
- components/ Screens for this domain only, including its settings panel.
- features/ai/ Everything the model sees. Composes domain query/schema into prompts and tools.
- tools/<d>.tool.ts The tools one domain answers (memory, bot, workspace, mcp, …): name, description,
- args, execute. Execute calls the domain query.
- tools/tool-name.ts Every name the model sees. Tools and prompts both import from here.
- prompts/thursday.prompt.ts Everything the call hears; loads its own data and assembles the prompt.
- prompts/bot.prompt.ts Everything a bot hears. Shares no text with the call prompt.
- prompts/memory-edit.prompt.ts Everything an edit typed on the Memory screen hears.
- prompts/prompt-helper.ts Voice-less helpers: row-to-line formatters and a few thresholds.
- load-tools.ts Which runtime holds which tools (ToolRun: the call, a bot, a memory edit).
- model.ts / model.schema.ts Which model runs, and how it is built.
- components/ The model's own controls: model picker and browser, provider icons.
- app/ Routing shell only. api/<d>/route.ts is one serverRoute line.
- api/query-key.ts Every endpoint the client reads. Lives next to the routes so they move together.
- api/events/ The server→browser event union, the SSE route and `presence`.
- components/ui/ Domain-agnostic UI (shadcn), plus markdown, notify and toast.
- hooks/ Domain-agnostic React hooks.
- lib/ Domain-agnostic only: protocol/ (Result, actions, routes, SWR, paging, event bus,
- presence), realtime/ (the call seam), sandbox.ts (interface + creator),
- public-error, logger, date-like, queue, tokens, limits, utils.
- database/db.ts The one client, and the lane every request goes through (SQLite has one writer).
- database/tables.ts All drizzle tables (relations and migrations look at one place).
- database/migrations/ Generated by `pnpm db:generate`, applied at boot (`migrate.ts`).
- config.ts App knobs: name, the two roots, paths, page sizes, limits. Not secrets (features/config).
- bin/ What ships and runs outside Next: the `thursday` CLI, where the app's own
- CLIs are, and the port both starters pick. Plain .mjs — it runs before anything is built.
- scripts/dev.mts `pnpm dev`: `next dev` on 127.0.0.1, on a port nothing holds on any address, handed over as `PORT`.
- scripts/reset.mts Wipes local data (calls, jobs, memory) and optionally the build. `pnpm reset`.
- scripts/pack.mts Builds `dist/`, the tree npm publishes. `pnpm release`.
- skills/ Skills shipped with the app (read-only). User skills live in the workspace.
- ```
-
- Domains today: `thursday` (the call), `bot` (bots and the jobs they run), `memory`, `workspace` (the
- files bots work in) and `artifact` (the finished ones), `skills`, `connectors` (MCP servers), `config`
- (keys and models), `intro` (first run), and `settings` (the settings shell only).
-
- Two roots (`config.ts` `APP_DIR` / `DATA_DIR`): the app's files (build, migrations, bundled skills) and
- the user's files (DB, workspace, installed skills). Both default to `process.cwd()` and can be moved
- with `THURSDAY_APP_DIR` / `THURSDAY_HOME`. Nothing else reads `cwd`; every path goes through `config.ts`.
- `npx thursday-agent` is the same two roots pointed elsewhere: the package for one, `~/.thursday` for
- the other (`bin/thursday.mjs`). It is why the app is publishable at all — nothing writes beside itself.
-
- # Conventions
-
- - **Domains hold data, `features/ai` holds the model.** A domain folder has schema, query, action and
- components and knows nothing about prompts or tools. Prompts and `load-tools` are imported only by
- what runs a model: `bot.run` / `bot.runner`, `thursday.action` and the tool-call route,
- `memory.edit`. Anything may import the model's vocabulary — `model.schema` (the model a row
- names), `tools/tool-name` (a screen drawing a tool line), `ai/components` (the model picker).
- - **Tool names come from one file.** `features/ai/tools/tool-name.ts` is the source. Tools and prompts
- import it, and so does code that reads stored tool calls back (`task.query`, the call screen's
- `tool-line`); nobody types a tool name as a string.
- - **Prompts are split by runtime, not by chapter.** Each prompt file loads its own data and exports
- one function. Shared helpers only format rows; they never decide what to say. Tool descriptions say
- *what* a tool is; prompts say *when* to use it. Prompts are assembled per session, never cached.
- - **Tools run on the server.** A call's tool invocation is forwarded by the page to the server, so
- tools call domain queries directly. The one exception is anything that touches the call itself
- (hang up).
- - **Long-running work is the server's, not the request's.** A job's run is a promise the server holds
- (`bot.runner` `launch`), never `after()`: runs also start from a returning browser and a retry
- timer, where `after` throws or waits for the event stream to close. Everything that happens is
- written as rows, so what the screen draws and what the model re-reads are the same rows.
- - **No browser, nothing runs.** `presence` (app/api/events) says whether a browser is on the stream;
- when the last one has been gone a while, jobs stop and wait and open calls close. When one comes
- back, the jobs the app stopped pick themselves back up (`bot.runner` `parkTask`); what a bot or a
- person stopped waits for a person. Wired once at boot (`instrumentation`), not in each domain.
- - **What cannot be won by instruction is enforced by structure**: tool sets, step limits, ask-back
- counts, output truncation, shell env. Do not add prompt sentences for things the code can enforce.
- - **Vocabulary belongs to whoever produces it.** Band counts come from the tap that fills them,
- provider lists from where the drivers live. No re-export doors. A domain's glyph is vocabulary too:
- it lives in that domain as `components/<name>-mark.tsx` (`bot-mark`, `mcp-mark`), and the settings
- nav and the call screen's tool line both import that one. A second icon table is how they end up
- disagreeing. So is a nav badge: `components/<name>-badge.tsx` reads that domain's own key and draws
- `NavBadge`. It loads with the app, so it never lives in the lazily-loaded setting screen.
- - **A number that tunes behaviour is in `config.ts`; a number that *is* the drawing stays where it
- is drawn.** A cap, a deadline, a page size, a step limit, how long something is kept — named in
- `config.ts` with a paragraph saying what moving it does, even when one file reads it. An easing
- time, a radius, a sample rate, a wire timeout the other end also knows: local. The test is who
- the number answers to — the person tuning the app, or the thing being drawn.
- - **A tool two runtimes share names no tool in what it returns.** `memory_recall` answers the call
- and a bot. Past `MEMORY_LIMITS.factsPerNote` it says the note has outgrown its size and to ask the
- user what to drop — the ask, which both can act on, never the mechanism: how to settle it with the
- user is the call prompt's to say.
- - **Don't split files by size.** A long file that does one thing stays one file.
- - **An interface with one implementation is two files, not an interface.** Don't add ports.
-
- # Data flow
-
- **Read** — RSC first. Server components call `<name>.query.ts` directly. Client reads go through
- `app/api/<d>/route.ts` (`serverRoute(() => findAll())`) and `useServerRoute`:
-
- ```ts
- const { data: notes } = useServerRoute<MemoryNote[]>(queryKey.memory);
- const { data: note } = useServerRoute<MemoryNote>(path ? queryKey.notePath(path) : null); // null pauses
- ```
-
- Beyond the key it is plain SWR. Responses are typed, not validated at runtime. Read failures toast
- from the hook. SWR is for revalidation and dedup, not caching. A list that grows reads through
- `useServerPages` instead: the caller's key function addresses a page, and the next one is asked for
- when the end of the list comes into view.
-
- **queryKey** — never write a URL in a screen. All keys live in `app/api/query-key.ts`:
-
- ```ts
- export const queryKey = {
- memory: "/api/memory",
- notePath: (path: string) => ({ url: "/api/memory", query: { path } }),
- mcpServer: (name: string | null) => ({ url: "/api/mcp", pathVariable: [name] }),
- } as const;
- ```
-
- The key is the SWR cache key. `revalidate` matches by URL prefix, so invalidating `queryKey.memory`
- also refreshes `queryKey.notePath(path)` and any loaded pages under it. A null path variable pauses
- the read the same way a null key does. Invalidation is done by the writer's `onOk`, not by the
- action hook:
-
- ```ts
- const [create] = useServerAction(createNoteAction, {
- onOk: () => {
- revalidate(queryKey.memory);
- onDone();
- },
- });
- ```
-
- **Write** — no routes. Only server actions wrapped in `serverAction`. Routes exist for three cases:
- external callers hitting a URL (oauth callback), work that must run in parallel (tool calls during a
- call — actions are serial per client), and a response that streams (the SSE route, and a memory
- edit drawn as the model makes it).
-
- ```ts
- export const createNoteAction = serverAction(async (path: unknown, description: unknown) => {
- const parsedPath = PathSchema.parse(path); // validate inside; ZodError messages reach the UI
- const note = await createNote(parsedPath, z.string().min(1).parse(description));
- if (!note) publicError("Note already exists");
- return { id: note.id }; // plain return → Result wrapping
- });
- ```
-
- Actions run sequentially per client. Parallelism happens inside one action.
-
- **Errors** — throw `publicError("message")` anywhere for user-facing failures. The boundary
- (`serverAction` / `serverRoute`) forwards that message and masks everything else (logged). In screens,
- `useServerAction` handles the toast, pending state and success toast. Tool failures returned to a model
- are not masked: return one line the model can read and recover from.
-
- **What an outside API answered is never masked.** A provider's refusal is the user's to act on — the
- key, the credit, the model id — and only the provider can say which, so the seam that made the call
- raises it public rather than letting the boundary swallow it: `issueClientSecret` (lib/realtime) for
- the call's token, `modelErrorToString` (features/ai/model) for anything the ai sdk wrapped, which also
- carries out the body when the sdk's message is the status word alone.
-
- **Server → browser** — no polling. When a fact happens on the server (a row was written, a browser
- opened, something to say), `appEvents.emit` at that spot; the browser listens on one SSE stream. Two
- kinds of events: a **signal** when a GET exists (the receiver `revalidate`s that key) and **data** when
- none does. New event = one union line + one emit + one handler (+ one `SIGNALS` entry for signals; the
- compiler enforces it). Emit where the fact happens (the query's write, the watcher), not in each caller.
- A 30-second poll remains as a safety net. No WebSockets.
-
- # UI
-
- - Domain-agnostic components are shadcn (`components/ui/`). Check there before writing a new one.
- - Markdown renders through `components/ui/markdown.tsx` (wraps streamdown).
- - Confirmations and prompts: `notify.confirm` / `notify.prompt`. Destructive actions confirm first.
- - Notifications: `toast.add`, only for things that happen off-screen. Skip it when the result is
- visible.
- - Waiting is always a loader: buttons swap their icon for a Loader, lists use `Skeleton`. Never dots
- or pulses, and never a new element that shifts the row when it finishes.
- - Words for something still running shine (`ShinyText`). It takes its colours from the theme —
- `tone="waiting"` for amber, never a colour — and truncates in its own box, not a parent's.
- - Two status colors only: amber (waits on the user — a question, a stopped job, an answer not yet
- opened; `WAITING_INK` in `lib/utils`) and red (failed — `text-destructive`). Success, connected and
- enabled have no color of their own: an unopened answer is amber because it waits on the user, not
- because it worked. There is no brand color, so a green would become one. The settings nav reports
- the same two and nothing else (`NavBadge`).
- - Errors are never swallowed. Inline or toast, they reach the user.
-
- # Rules
-
- - Timestamps are `DateLike` (`lib/date-like`): ISO strings on the wire, `Date` in drizzle.
- `z.coerce.date()` lies on the client.
- - Shared logic goes to `lib/utils.ts` or the matching lib file before it is written twice. Don't
- generalize something used once.
- - Verify with `pnpm typecheck` and `pnpm lint`. Schema changes: `pnpm db:generate` (applied at boot),
- `pnpm db:migrate` for the current DB. Never `db:push`.
- - Prompt or tool-description changes: read the assembled result, not just the file. UI changes: run
- the app and look. Judge bot behavior by counting stored turns and tool calls, not by feel.
- - Comments are English, present tense and short. They explain what the code cannot: an invariant, an
- external constraint, the one-line why behind a surprising choice. No history, no narrative.
- - Model-facing text (prompts, tool descriptions, `.describe()`) is English and imperative.
- - A new file you created goes in the commit with the rest of your change — which is why anything
- private is named `*.local.*` before it is written, not after. Two are easy to get wrong: a
- generated migration (`database/migrations/…`) MUST be committed or a fresh clone boots against
- the wrong schema, and a stray shell redirect at the repo root must not be.
- - **Keep this file true.** When a change makes a line here wrong, fix it in the same commit; when
- it settles something new and non-obvious, add it. Delete what the code no longer does — a stale
- rule is followed as confidently as a live one.
-
- <!-- BEGIN:nextjs-agent-rules -->
-
- # This is NOT the Next.js you know
-
- This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
-
- This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
-
- <!-- END:nextjs-agent-rules -->
-
+ AGENTS.md