Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ jobs:
# so the Alchemy-entrypoints typecheck resolves each graph through
# that app's node_modules.
- shard: quality
install-filters: "@maple/api @maple/electric-sync @maple/alerting @maple/sandbox @maple/web @maple/landing @maple/local-ui"
install-filters: "@maple/api @maple/ai @maple/electric-sync @maple/alerting @maple/sandbox @maple/web @maple/landing @maple/local-ui"
- shard: effect-lint
install-filters: ""
- shard: build-web
Expand All @@ -219,7 +219,7 @@ jobs:
install-filters: "@maple/web"
- shard: typecheck-rest
install-filters: >-
@maple/alerting @maple/cli @maple/clickhouse-builder-docs
@maple/ai @maple/alerting @maple/cli @maple/clickhouse-builder-docs
@maple/electric-sync @maple/landing @maple/local-ui
@maple/sandbox @maple/scraper ./lib/* ./examples/*
- shard: typecheck-packages
Expand All @@ -238,7 +238,7 @@ jobs:
install-filters: ""
- shard: test-rest
install-filters: >-
@maple/alerting @maple/cli @maple/electric-sync @maple/landing
@maple/ai @maple/alerting @maple/cli @maple/electric-sync @maple/landing
@maple/local-ui @maple/sandbox @maple/scraper ./lib/*
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/eval.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ jobs:

- uses: ./.github/actions/bun-install
with:
filters: "@maple/api"
filters: "@maple/ai"

- name: Run MCP evals
run: bun run --filter @maple/api eval
run: bun run --filter @maple/ai eval
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
# Override to eval a different model (defaults to the prod kimi-k2.5).
Expand Down
22 changes: 14 additions & 8 deletions .github/workflows/token-cost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
pull_request:
branches: [main]
paths:
- "apps/api/src/mcp/**"
- "apps/ai/src/mcp/**"
- "packages/domain/src/**"
- ".github/actions/bun-install/action.yml"
- ".github/workflows/token-cost.yml"
Expand All @@ -32,11 +32,14 @@ jobs:

- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0

# This script only needs the API dependency closure. Installing all
# workspaces accounted for roughly half of this job's wall time.
# This script only needs the agent Worker's dependency closure, which
# includes apps/api: the tools import api's services by path alias, and
# those modules resolve `@maple/domain/*` out of api's own node_modules.
# Installing all workspaces accounted for roughly half of this job's
# wall time.
- uses: ./.github/actions/bun-install
with:
filters: "@maple/api @maple-dev/effect-sdk @maple-dev/browser"
filters: "@maple/ai @maple/api @maple-dev/effect-sdk @maple-dev/browser"

- name: Restore turbo cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -52,16 +55,19 @@ jobs:
run: bun run alchemy:build-deps

- name: Measure (PR head)
run: bun run --filter @maple/api measure-tokens -- -o "$RUNNER_TEMP/head.json" | tee "$RUNNER_TEMP/head.txt"
run: bun run --filter @maple/ai measure-tokens -- -o "$RUNNER_TEMP/head.json" | tee "$RUNNER_TEMP/head.txt"

# Re-measure against the base source (reusing the same node_modules) for a
# best-effort delta. Source-only swap avoids a second install.
- name: Measure (base)
continue-on-error: true
run: |
git checkout "${{ github.event.pull_request.base.sha }}" -- apps/api/src packages/domain/src || true
bun run --filter @maple/api measure-tokens -- -o "$RUNNER_TEMP/base.json" || echo '{"total":0}' > "$RUNNER_TEMP/base.json"
git checkout HEAD -- apps/api/src packages/domain/src || true
# The agent Worker did not exist before the split, so on an older
# base both the pathspec and the script are absent. Either way the
# base reads 0 and the delta is the whole cost, once.
git checkout "${{ github.event.pull_request.base.sha }}" -- apps/ai/src apps/api/src packages/domain/src || true
bun run --filter @maple/ai measure-tokens -- -o "$RUNNER_TEMP/base.json" || echo '{"total":0}' > "$RUNNER_TEMP/base.json"
git checkout HEAD -- apps/ai/src apps/api/src packages/domain/src || true

- name: Summary
run: |
Expand Down
33 changes: 25 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ you want the form.

```bash
bun dev # everything, ONE `alchemy dev` stack → https://[<worktree>.]<app>.localhost
bun dev api web # a subset (api, alerting, electric-sync, web, landing, ingest, local-ui, scraper)
bun dev api web # a subset (api, ai, alerting, electric-sync, web, landing, ingest, local-ui, scraper)
bun --filter=@maple/web dev # single app on its raw port, no portless proxy
bun run test # Vitest via turbo (NOT `bun test` — that's Bun's own runner)
bun typecheck
Expand All @@ -50,6 +50,23 @@ Toolchain (bun/node/rust/python) is pinned in [`mise.toml`](mise.toml); `mise ru
first-time install + `.env.local` + portless CA. mise is optional but bump versions there when
upgrading a runtime (keep `bun` in sync with `packageManager`).

## The AI Worker (`apps/ai`)

Every agent surface runs in its own Worker: the public MCP server and its ~47 tools, the chat agent
and its `ChatSession` Durable Object, and the autonomous investigation fan-out. They moved together
because all three reach the same tool registry in-process — extracting any one alone leaves the
registry behind, which is why the first attempt was worth 1%.

`api.maple.dev/mcp` is still the public address. `apps/api` forwards `/mcp`, `/api/chat/*` and
`/internal/chat/*` over a service binding, ahead of building its route graph, which keeps the OAuth
issuer and the RFC 8707 resource identifiers on api's origin. OAuth itself (`McpOAuthService`, the
discovery and consent endpoints) stays in `apps/api`; maple-ai validates the ordinary API key it
mints.

**The alias convention is the opposite of what it looks like.** In `apps/ai`, `@/` is *apps/api's*
source and `@ai/` is its own. This program compiles api's modules too, and those spell their
internal imports `@/` — point it at `apps/ai` and every one resolves into the wrong tree.

## Warehouse queries

**No Tinybird pipes/endpoints exist.** All backend queries use the ClickHouse DSL in
Expand Down Expand Up @@ -180,11 +197,10 @@ Workers via the Hyperdrive binding `MAPLE_DB`.
it had diverged exactly where it mattered — it has `AWS/StageConfig.ts` where the real
package has `AWS/Environment.ts` + `AWS/AuthProvider.ts` — and a code review cited its line
numbers as fact for a bug in the live code.
- **LLM core:** `@opencode-ai/ai` — opencode's Effect-native LLM core, on npm and pinned exactly
(`0.0.0-beta-18050`; the `dev`/`beta` channels carry no semver, so a bump is a read of the diff).
Only `apps/api` depends on it, and every piece of Maple behaviour — layer wiring, the Workers AI
binding shim, model/provider selection, error mapping — lives at the seam in
`apps/api/src/platform/Llm.ts`, never in a wrapper around the package.
- **LLM core:** Effect AI (`@effect/ai-openrouter`, `@effect/ai-openai-compat`) plus
`@effect-agent/*`. Only `apps/ai` depends on them, and every piece of Maple behaviour — layer
wiring, the Workers AI binding shim, model/provider selection, error mapping — lives at the seam
in `apps/ai/src/platform/Llm.ts`, never in a wrapper around the packages.
- **Span status codes:** Title case — `"Ok"`, `"Error"`, `"Unset"`.
- **UI:** shadcn/Base UI + Tailwind 4 (`npx shadcn@latest add <component>`), Recharts, Nucleo icons.
Find an icon in the local Nucleo DB, then port it into `apps/web/src/components/icons/` by copying
Expand All @@ -198,7 +214,7 @@ Workers via the Hyperdrive binding `MAPLE_DB`.

When an org has connected GitHub, every agent surface (chat, investigation lanes, public MCP)
gets `sandbox_grep`, `sandbox_list_files`, `sandbox_read_file` and `sandbox_exec`
(`apps/api/src/mcp/tools/sandbox.ts`). They run against a **full git clone at an exact commit**
(`apps/ai/src/mcp/tools/sandbox.ts`). They run against a **full git clone at an exact commit**
inside Cloudflare's Sandbox container, so history works (`git log`, `git blame`, `git show`).
`git grep` and `git ls-files` back the search and listing tools, because the image ships git and
not ripgrep — and its git is old enough to lack `git grep --max-count`, which is the kind of thing
Expand All @@ -224,7 +240,8 @@ arguments**, because `/proc/<pid>/cmdline` is readable by the account agent comm
Testing it has three layers, and the top one is the only one that catches the image:

```bash
bun run --cwd apps/api test src/services/sandbox src/mcp/tools/sandbox # argument vectors, real git
bun run --cwd apps/ai test src/mcp/tools/sandbox # the tools
bun run --cwd apps/api test src/services/sandbox # argument vectors, real git
bun run --cwd apps/sandbox test # the generated scripts, as text
bun run --cwd apps/sandbox verify:image # the scripts, inside the image
```
Expand Down
17 changes: 14 additions & 3 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "@maple/infra/aws"
import {
ApiWorker,
AiWorker,
SandboxWorker,
stageDeploysSandbox,
formatMapleStage,
Expand All @@ -38,6 +39,7 @@ import * as Acm from "@maple/infra/acm"
import { optionalPlain, plainWithDefault } from "@maple/infra/env"
import * as Portless from "@maple/alchemy-portless"
import { DEV_PROCESS_APPS, selectedDevApps, type DevApp } from "@maple/infra/dev-urls"
import MapleAiLive, { MapleAi } from "./apps/ai/src/worker.ts"
import Alerting from "./apps/alerting/src/worker.ts"
import MapleApi from "./apps/api/src/worker.ts"
import MapleSandbox from "./apps/sandbox/alchemy.run.ts"
Expand Down Expand Up @@ -218,9 +220,18 @@ export default Alchemy.Stack(
// sees a Worker this deploy created rather than stored state, and only on
// the stages that run it — see `stageDeploysSandbox`.
const sandbox = stageDeploysSandbox(stage) ? yield* MapleSandbox : undefined
const api = yield* sandbox === undefined
? MapleApi
: Effect.provideService(MapleApi, SandboxWorker, sandbox)
// Every agent surface — the MCP server and its tools, the chat agent, the
// investigation fan-out. Yielded before api because api binds it, and a
// `Worker.ref` cannot see a sibling this deploy creates.
// The root IS the entry point, and the AI Worker hosts the chat Durable
// Object: yielding the Worker resolves the class, and its Live layer is what
// registers the class in the deployed bundle's exports.
// oxlint-disable-next-line effecttsgo/strict-effect-provide
const ai = yield* Effect.provide(MapleAi, MapleAiLive)
yield* serveWorker("ai", ai)
const api = yield* Effect.provideService(MapleApi, AiWorker, ai).pipe((withAi) =>
sandbox === undefined ? withAi : Effect.provideService(withAi, SandboxWorker, sandbox),
)
yield* serveWorker("api", api)

// Self-hosted ElectricSQL on ECS Fargate (prd/stg — dev stages use the
Expand Down
44 changes: 44 additions & 0 deletions apps/ai/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@maple/ai",
"private": true,
"type": "module",
"scripts": {
"eval": "vitest run --config vitest.eval.config.ts",
"eval:check": "bun run scripts/eval-runtime-check.ts",
"eval:widgets": "bun run scripts/grade-widget-eval.ts",
"mcp:docs": "bun run scripts/generate-dashboard-skill.ts",
"measure-tokens": "bun run scripts/measure-token-cost.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@effect-agent/capabilities": "0.1.0-beta.74",
"@effect-agent/core": "0.1.0-beta.74",
"@effect-agent/engine": "0.1.0-beta.74",
"@effect-agent/sandbox": "0.1.0-beta.74",
"@effect/ai-openai-compat": "catalog:effect",
"@effect/ai-openrouter": "catalog:effect",
"@maple/db": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/infra": "workspace:*",
"@maple/query-engine": "workspace:*",
"@maple/query-model": "workspace:*",
"@maple/widgets": "workspace:*",
"drizzle-orm": "^0.45.1",
"effect": "catalog:effect"
},
"devDependencies": {
"@ai-sdk/openai-compatible": "^2.0.48",
"@cloudflare/workers-types": "catalog:alchemy",
"@effect-agent/testing": "0.1.0-beta.74",
"@effect/language-service": "catalog:effect",
"@effect/vitest": "catalog:effect",
"@maple-dev/effect-sdk": "workspace:*",
"@types/node": "catalog:tooling",
"ai": "^6.0.196",
"gpt-tokenizer": "^3.0.1",
"typescript": "catalog:tooling",
"vitest": "catalog:",
"vitest-evals": "^0.4.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
* Exits non-zero on failure. Keep it as a dev utility — the LLM path
* (execution.eval.ts) only adds tool *selection* on top of what this exercises.
*/
import { installFakeWarehouse, restoreWarehouse } from "@/mcp/__evals__/fake-warehouse"
import { makeEvalRuntime, runToolDirect } from "@/mcp/__evals__/eval-runtime"
import { FIXTURES } from "@/mcp/__evals__/utils"
import { LARGE_TRACE_SPAN_COUNT } from "@/mcp/__evals__/fixtures"
import { installFakeWarehouse, restoreWarehouse } from "@ai/mcp/__evals__/fake-warehouse"
import { makeEvalRuntime, runToolDirect } from "@ai/mcp/__evals__/eval-runtime"
import { FIXTURES } from "@ai/mcp/__evals__/utils"
import { LARGE_TRACE_SPAN_COUNT } from "@ai/mcp/__evals__/fixtures"

const main = async () => {
installFakeWarehouse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
* moved to v3, and its gauge example paired `unit: "percent"` with a 0–100 arc —
* the very mistake the write path now warns about.
*
* bun run --cwd apps/api mcp:docs # write
* bun run --cwd apps/api mcp:docs --check # verify (CI)
* bun run --cwd apps/ai mcp:docs # write
* bun run --cwd apps/ai mcp:docs --check # verify (CI)
*/
import { readFileSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"
import { DASHBOARD_SCHEMA_SECTIONS, renderDashboardSchemaSection } from "@/mcp/lib/dashboard-schema-doc"
import { DASHBOARD_SCHEMA_SECTIONS, renderDashboardSchemaSection } from "@ai/mcp/lib/dashboard-schema-doc"

const SKILL_PATH = resolve(import.meta.dirname, "../../../skills/maple-dashboard-widgets/SKILL.md")

Expand All @@ -29,7 +29,7 @@ const INTRO = `
# Maple dashboard widgets via MCP

Everything below is generated from the live widget schema by
\`bun run --cwd apps/api mcp:docs\`. **Do not edit this file by hand** — edit
\`bun run --cwd apps/ai mcp:docs\`. **Do not edit this file by hand** — edit
\`apps/api/src/mcp/lib/dashboard-schema-doc.ts\` and regenerate. The same module backs the
\`describe_dashboard_schema\` MCP tool, so an agent at runtime and a reader here see one truth.

Expand Down Expand Up @@ -70,7 +70,7 @@ if (process.argv.includes("--check")) {
const current = readFileSync(SKILL_PATH, "utf8")
if (current !== content) {
console.error(
"skills/maple-dashboard-widgets/SKILL.md is out of date.\nRun: bun run --cwd apps/api mcp:docs",
"skills/maple-dashboard-widgets/SKILL.md is out of date.\nRun: bun run --cwd apps/ai mcp:docs",
)
process.exit(1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { readFileSync } from "node:fs"
import { Schema } from "effect"
import { DashboardWidgetSchema, WIDGET_TYPES, type PanelType } from "@maple/domain/http"
import { validateWidgetRenderability } from "@/mcp/lib/validate-widget-renderability"
import { validateWidgetRenderability } from "@ai/mcp/lib/validate-widget-renderability"
import { TASKS } from "./widget-eval-tasks"

const decodeWidget = Schema.decodeUnknownSync(DashboardWidgetSchema)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
import { writeFileSync } from "node:fs"
import { encode } from "gpt-tokenizer"
import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry"
import { mapleToolCatalog, toInputSchema } from "@ai/mcp/tools/registry"

interface ToolTokens {
readonly name: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
type ChatToolCall,
type ChatTurnTenantEncoded,
} from "@maple/domain/chat-session"
import type { ChatSessionStub } from "./session"
import { type ChatSessionStub } from "@maple/domain/chat-session-stub"

/** What the class reads off its Durable Object state: the SQLite handle and the object's own `waitUntil`. */
interface ChatSessionState {
Expand Down Expand Up @@ -673,8 +673,30 @@ export const activateChatSession = Effect.map(
([state, env]) => Effect.sync(() => chatSessionRpc(new ChatSession(state.raw, env))),
)

/** The Durable Object: one per `"<orgId>:<tabId>"`, SQLite-backed, bound to the api Worker as `ChatSession`. */
export default class ChatSessionObject extends Cloudflare.DurableObject<ChatSessionObject>()(
"ChatSession",
activateChatSession,
) {}
/**
* The Durable Object: one per `"<orgId>:<tabId>"`, SQLite-backed, hosted by this Worker and bound
* as `ChatSession` — the name `chatSessionStub` reads off `env` on both sides.
*
* `transferredFrom` names apps/api, which hosted this class until the agent surfaces moved here.
* Alchemy turns that into a data-preserving `transferred_classes` migration, so live transcripts
* follow the class rather than being stranded in a namespace nothing binds any more. Without it
* the api's own deploy fails with `DurableObjectTransferRequired`, because dropping a locally
* hosted class while keeping a cross-script reference to it is exactly the shape that silently
* destroys a namespace, and alchemy refuses it before any upload.
*
* It is inert once every stage has transferred — a fresh stage creates the class outright — so it
* stays here rather than being cleaned up later and breaking whichever stage lagged behind.
*
* The props-carrying class form is what makes room for that: the single-argument overload takes an
* implementation and no props, so the implementation moves to `ChatSessionLive` below.
*/
export class ChatSessionObject extends Cloudflare.DurableObject<
ChatSessionObject,
EffectRpc<ChatSessionStub>
>()("ChatSession", { transferredFrom: "api" }) {}

/** The activation, as the layer the host Worker provides. */
// `<never>` pinned: the activation's requirements are all `DurableObjectServices`,
// which `.make` already discharges, but inference otherwise widens them into the
// layer's own requirements and they surface all the way up in `alchemy.run.ts`.
export const ChatSessionLive = ChatSessionObject.make<never>(activateChatSession)
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ChatMode, makeChatSessionId } from "@maple/domain/chat-session"
import { evaluatePermission } from "@maple/domain/permission"
import { assert, describe, it } from "vitest"
import { AGENTS, agentForSession, buildSystemPrompt, delegationToolName, spawnableFor } from "./agents"
import { mapleToolCatalog } from "@/mcp/tools/registry"
import { mapleToolCatalog } from "@ai/mcp/tools/registry"

const subagents = Object.values(AGENTS).filter((agent) => agent.mode === "subagent")

Expand Down
Loading