Compare commits
77 Commits
9cc179dec6
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078ec029da | ||
|
|
4af1036423 | ||
|
|
7a13c75655 | ||
|
|
57462b52ff | ||
|
|
a56a4db963 | ||
|
|
58c57412a9 | ||
|
|
ddd07ae7eb | ||
|
|
61b6247df9 | ||
|
|
75685e7b67 | ||
|
|
fe18e4ee77 | ||
|
|
937355cabe | ||
|
|
5950ef5f0f | ||
|
|
a220fcaf2b | ||
|
|
d1c8e3396f | ||
|
|
3b79311fdd | ||
|
|
7baf8d7e7a | ||
|
|
a8de04c7b6 | ||
|
|
87cf9d0afc | ||
|
|
46adaf2148 | ||
|
|
c11763472c | ||
|
|
189ff89c34 | ||
|
|
c7e0192486 | ||
| 1c3c9de550 | |||
| d0edc1a725 | |||
|
|
5b207425ed | ||
|
|
cb51ff7ba1 | ||
|
|
43a8255272 | ||
|
|
78be3d1f9c | ||
|
|
7139a3ca74 | ||
|
|
c509ae2a5f | ||
|
|
228ee57d4c | ||
|
|
bee4bb3c1f | ||
|
|
d72454d929 | ||
|
|
cf94d14922 | ||
|
|
78a43d6a42 | ||
|
|
ca933eef46 | ||
|
|
88782de07c | ||
|
|
083c2d7db9 | ||
|
|
751f410ca6 | ||
|
|
3a99d5e20e | ||
|
|
9a258ca32a | ||
|
|
2a5a74f7c0 | ||
|
|
d40a5ac890 | ||
|
|
b77820534a | ||
|
|
db64ecb1d9 | ||
|
|
ea29e5ebb8 | ||
|
|
ccf080db59 | ||
|
|
69c038478b | ||
|
|
b6bcc93048 | ||
|
|
51e01233a4 | ||
|
|
f49850d23b | ||
|
|
928f23ab1b | ||
|
|
1b9c4905a5 | ||
|
|
400025715a | ||
|
|
986e3e1d12 | ||
|
|
593d1a4c6d | ||
|
|
417bf224eb | ||
|
|
37dbd22eff | ||
|
|
cbf5cab5e7 | ||
|
|
af52f501fe | ||
|
|
b3b1fde825 | ||
|
|
ab4cfaaeb7 | ||
|
|
eb844edb29 | ||
|
|
317ec20392 | ||
|
|
eab8775f5f | ||
|
|
a0d0914a85 | ||
|
|
8f9642df69 | ||
|
|
cd5f3c0175 | ||
|
|
ed4966927c | ||
|
|
3c4e8e8bb8 | ||
|
|
5c88eff46f | ||
|
|
646a86f2c3 | ||
|
|
adf0504116 | ||
|
|
d44427e71f | ||
|
|
2635cdcaa7 | ||
|
|
e922471229 | ||
|
|
87ff1f907c |
@@ -36,6 +36,18 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
|
session. Do not leave agent branches open between sessions.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -46,9 +58,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -58,9 +71,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -68,7 +84,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -100,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
|
berget URL (see knowledge entry on namespace mismatches).
|
||||||
|
|
||||||
|
### Quick reflex checks
|
||||||
|
|
||||||
|
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||||
|
|
||||||
|
- "I think the issue might be..."
|
||||||
|
- "Let me try X and see..."
|
||||||
|
- "I'll just write a script to..."
|
||||||
|
- "This is probably a new bug..."
|
||||||
|
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||||
|
|
||||||
## Client work rules
|
## Client work rules
|
||||||
|
|
||||||
@@ -216,13 +278,30 @@ Key skills:
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,30 @@
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
|
session. Do not leave agent branches open between sessions.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -51,9 +63,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -63,9 +76,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -73,7 +89,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -105,18 +121,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
|
berget URL (see knowledge entry on namespace mismatches).
|
||||||
|
|
||||||
|
### Quick reflex checks
|
||||||
|
|
||||||
|
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||||
|
|
||||||
|
- "I think the issue might be..."
|
||||||
|
- "Let me try X and see..."
|
||||||
|
- "I'll just write a script to..."
|
||||||
|
- "This is probably a new bug..."
|
||||||
|
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||||
|
|
||||||
## Client work rules
|
## Client work rules
|
||||||
|
|
||||||
@@ -221,13 +283,30 @@ Key skills:
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
115
.cursorrules
115
.cursorrules
@@ -39,6 +39,18 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
|
session. Do not leave agent branches open between sessions.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -49,9 +61,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -61,9 +74,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -71,7 +87,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -103,18 +119,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
|
berget URL (see knowledge entry on namespace mismatches).
|
||||||
|
|
||||||
|
### Quick reflex checks
|
||||||
|
|
||||||
|
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||||
|
|
||||||
|
- "I think the issue might be..."
|
||||||
|
- "Let me try X and see..."
|
||||||
|
- "I'll just write a script to..."
|
||||||
|
- "This is probably a new bug..."
|
||||||
|
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||||
|
|
||||||
## Client work rules
|
## Client work rules
|
||||||
|
|
||||||
@@ -219,13 +281,30 @@ Key skills:
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
@@ -11,37 +11,16 @@ jobs:
|
|||||||
name: Build and deploy
|
name: Build and deploy
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||||
|
environment: staging
|
||||||
env:
|
env:
|
||||||
SERVICE: supervisor
|
|
||||||
IMAGE: gitea.d-ma.be/mathias/supervisor
|
|
||||||
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
||||||
|
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
|
||||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
||||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and push supervisor image
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
trap 'rm -f /tmp/supervisor-image.tar' EXIT
|
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
|
||||||
echo "Building ${IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
buildctl --addr "${BUILDKIT_HOST}" build \
|
|
||||||
--frontend dockerfile.v0 \
|
|
||||||
--local context=. \
|
|
||||||
--local dockerfile=. \
|
|
||||||
--opt build-arg:VERSION="${IMAGE_TAG}" \
|
|
||||||
--output type=oci,dest=/tmp/supervisor-image.tar
|
|
||||||
|
|
||||||
skopeo copy \
|
|
||||||
oci-archive:/tmp/supervisor-image.tar \
|
|
||||||
docker://${IMAGE}:${IMAGE_TAG} \
|
|
||||||
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
|
||||||
|
|
||||||
echo "Built and pushed ${IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
- name: Build and push ingestion image
|
- name: Build and push ingestion image
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -62,6 +41,28 @@ jobs:
|
|||||||
|
|
||||||
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Build and push routing image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
trap 'rm -f /tmp/routing-image.tar' EXIT
|
||||||
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
echo "Building ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
buildctl --addr "${BUILDKIT_HOST}" build \
|
||||||
|
--frontend dockerfile.v0 \
|
||||||
|
--local context=. \
|
||||||
|
--local dockerfile=. \
|
||||||
|
--opt filename=Dockerfile.routing \
|
||||||
|
--opt build-arg:VERSION="${IMAGE_TAG}" \
|
||||||
|
--output type=oci,dest=/tmp/routing-image.tar
|
||||||
|
|
||||||
|
skopeo copy \
|
||||||
|
oci-archive:/tmp/routing-image.tar \
|
||||||
|
docker://${ROUTING_IMAGE}:${IMAGE_TAG} \
|
||||||
|
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
||||||
|
|
||||||
|
echo "Built and pushed ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
- name: Update infra repo
|
- name: Update infra repo
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -77,17 +78,89 @@ jobs:
|
|||||||
|
|
||||||
cd /tmp/infra-update
|
cd /tmp/infra-update
|
||||||
|
|
||||||
sed -i "s|gitea.d-ma.be/mathias/supervisor:.*|gitea.d-ma.be/mathias/supervisor:${IMAGE_TAG}|" \
|
|
||||||
"k3s/apps/${SERVICE}/deployment.yaml"
|
|
||||||
|
|
||||||
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||||
"k3s/apps/${SERVICE}/ingestion-deployment.yaml"
|
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
|
|
||||||
|
sed -i "s|gitea.d-ma.be/mathias/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
||||||
|
"k3s/apps/routing/deployment.yaml"
|
||||||
|
|
||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/${SERVICE}/deployment.yaml" "k3s/apps/${SERVICE}/ingestion-deployment.yaml"
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
||||||
git commit -m "chore(deploy): ${SERVICE}+ingestion → ${IMAGE_TAG}"
|
"k3s/apps/routing/deployment.yaml"
|
||||||
|
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "Infra repo updated: ${SERVICE}+ingestion → ${IMAGE_TAG}"
|
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Trigger Flux reconcile (immediate)
|
||||||
|
run: |
|
||||||
|
kubectl -n flux-system annotate gitrepository flux-system \
|
||||||
|
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||||
|
kubectl -n flux-system annotate kustomization apps \
|
||||||
|
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||||
|
|
||||||
|
- name: Wait for Flux to apply new ingestion image
|
||||||
|
run: |
|
||||||
|
EXPECTED="gitea.d-ma.be/mathias/ingestion:${{ github.sha }}"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||||
|
echo "✓ Flux applied ingestion image after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kubectl get deploy ingestion -n supervisor \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||||
|
| grep -qx "$EXPECTED" \
|
||||||
|
|| { echo "✗ Flux did not apply ingestion image within 60s"; exit 1; }
|
||||||
|
|
||||||
|
- name: Verify ingestion rollout
|
||||||
|
run: |
|
||||||
|
kubectl rollout status deployment/ingestion \
|
||||||
|
--namespace supervisor \
|
||||||
|
--timeout=120s \
|
||||||
|
|| {
|
||||||
|
echo "── pod status ──"
|
||||||
|
kubectl get pods -n supervisor -o wide
|
||||||
|
echo "── events ──"
|
||||||
|
kubectl get events -n supervisor --sort-by='.lastTimestamp' | tail -20
|
||||||
|
echo "── describe ──"
|
||||||
|
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Wait for Flux to apply new routing image
|
||||||
|
run: |
|
||||||
|
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
CURRENT=$(kubectl get deploy routing -n routing \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||||
|
echo "✓ Flux applied routing image after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kubectl get deploy routing -n routing \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||||
|
| grep -qx "$EXPECTED" \
|
||||||
|
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
||||||
|
|
||||||
|
- name: Verify routing rollout
|
||||||
|
run: |
|
||||||
|
kubectl rollout status deployment/routing \
|
||||||
|
--namespace routing \
|
||||||
|
--timeout=120s \
|
||||||
|
|| {
|
||||||
|
echo "── pod status ──"
|
||||||
|
kubectl get pods -n routing -o wide
|
||||||
|
echo "── events ──"
|
||||||
|
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
||||||
|
echo "── describe ──"
|
||||||
|
kubectl describe pods -n routing -l app=routing | tail -40
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"supervisor": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://koala:30320/mcp"
|
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
AGENTS.md
115
AGENTS.md
@@ -36,6 +36,18 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
|
session. Do not leave agent branches open between sessions.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -46,9 +58,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -58,9 +71,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -68,7 +84,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -100,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
|
berget URL (see knowledge entry on namespace mismatches).
|
||||||
|
|
||||||
|
### Quick reflex checks
|
||||||
|
|
||||||
|
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||||
|
|
||||||
|
- "I think the issue might be..."
|
||||||
|
- "Let me try X and see..."
|
||||||
|
- "I'll just write a script to..."
|
||||||
|
- "This is probably a new bug..."
|
||||||
|
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||||
|
|
||||||
## Client work rules
|
## Client work rules
|
||||||
|
|
||||||
@@ -216,13 +278,30 @@ Key skills:
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -45,13 +45,30 @@
|
|||||||
- Client data never leaves local network unless explicitly cleared
|
- Client data never leaves local network unless explicitly cleared
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
## Knowledge base access
|
## MCP endpoints
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||||
|
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||||
|
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||||
|
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||||
|
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||||
|
(opt-in). Only `mode client-local` registers this endpoint.
|
||||||
|
|
||||||
|
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||||
|
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||||
|
the routing pod; brain tools moved to the brain MCP.
|
||||||
|
|
||||||
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
|
for shell scripts and non-MCP clients.
|
||||||
|
|
||||||
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||||
|
|
||||||
## Agent instructions
|
## Agent instructions
|
||||||
|
|
||||||
|
|||||||
93
DECISIONS.md
93
DECISIONS.md
@@ -67,6 +67,50 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plan 6: routing pod reuses internal/skills/{review,debug,retrospective,trainer}
|
||||||
|
|
||||||
|
Plan 6 (Mode 2 routing pod, 2026-05-04) introduces a second consumer of
|
||||||
|
the four cost-routable skill packages. The routing pod constructs each
|
||||||
|
skill via `<pkg>.New(Config{...})` and hands it `routing.Router.Run` as
|
||||||
|
the `CompleteFunc`.
|
||||||
|
|
||||||
|
**Preserved code (do not delete):**
|
||||||
|
- `internal/skills/{review,debug,retrospective,trainer}/`
|
||||||
|
- `internal/registry`, `internal/mcp`, `internal/exec/litellm.go`
|
||||||
|
- `internal/routing/`, `cmd/routing/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan 7: supervisor pod retired (2026-05-12)
|
||||||
|
|
||||||
|
**What was deleted:** `cmd/supervisor/`, `internal/skills/{tdd,spec}/`,
|
||||||
|
root `Dockerfile`, supervisor k8s manifests (Deployment, Service, Ingress,
|
||||||
|
NodePort 30320), `supervisor` entry removed from all `.mcp.json` configs.
|
||||||
|
|
||||||
|
**Coverage:** `tdd`/`spec` → SKILL.md files in `~/dev/.skills/`; `review`,
|
||||||
|
`debug`, `retrospective`, `trainer` → routing pod; `brain_*`/`session_log` →
|
||||||
|
brain MCP; `tier` → `hyperguild tier` CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-12 — brain_answer and brain_classify: LLM routing via berget.ai → iguana
|
||||||
|
|
||||||
|
**Context:** Brain MCP returned raw BM25 excerpts with no synthesis. Adding
|
||||||
|
LLM-backed tools enables Q&A and ingestion enrichment without a separate service.
|
||||||
|
|
||||||
|
**Decision:** Two new MCP tools in the ingestion service (`ingestion/internal/mcp/`):
|
||||||
|
- `brain_answer(query)` — BM25 top-10 → LLM synthesis → answer + sources
|
||||||
|
- `brain_classify(text)` — LLM classifies doc into type/title/tags
|
||||||
|
|
||||||
|
Primary LLM: berget.ai `gemma4:31b` (EU cloud, spend tokens while available).
|
||||||
|
Fallback: iguana `gemma4:31b` (local Ollama). Reranker deferred to follow-up.
|
||||||
|
Router lives in `ingestion/internal/llm.Router`; opt-in via `BRAIN_LLM_PRIMARY_URL`.
|
||||||
|
|
||||||
|
**Consequences:** Brain becomes a knowledge assistant, not just a search index.
|
||||||
|
When berget.ai tokens run out, flip `BRAIN_LLM_PRIMARY_URL` to iguana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-08 — Mistral Vibe gets its own adapter
|
## 2026-04-08 — Mistral Vibe gets its own adapter
|
||||||
|
|
||||||
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
|
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
|
||||||
@@ -74,3 +118,52 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
|||||||
**Decision**: The root context-sync generates a `mathias.md` prompt and `mathias.toml` agent config in `~/.vibe/`. This is the one tool that needs a custom adapter path.
|
**Decision**: The root context-sync generates a `mathias.md` prompt and `mathias.toml` agent config in `~/.vibe/`. This is the one tool that needs a custom adapter path.
|
||||||
|
|
||||||
**Consequences**: Run `vibe --agent mathias` to use your conventions. Other Vibe users on the machine aren't affected.
|
**Consequences**: Run `vibe --agent mathias` to use your conventions. Other Vibe users on the machine aren't affected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-18 — project_create commits staging namespace directly to infra main
|
||||||
|
|
||||||
|
**Context:** `project_create` writes a k8s namespace manifest into the infra
|
||||||
|
repo so Flux brings up a staging environment for the new project. Initial
|
||||||
|
implementation pushed to a `staging/<name>` branch, which required manual PR
|
||||||
|
merge before Flux saw the namespace — defeating the "one tool call, project
|
||||||
|
exists, staging reconciling within 60s" goal.
|
||||||
|
|
||||||
|
**Decision:** Option A — commit directly to `main`. `callInfraCommit` passes
|
||||||
|
`branch: "main"` to gitea-mcp's `file_write_branch`; no PR, no merge step.
|
||||||
|
|
||||||
|
**Consequences:** Staging namespace appears in cluster within ~60s of the
|
||||||
|
`project_create` call. Consistent with project-wide TBD policy (CLAUDE.md):
|
||||||
|
commit directly to main, every commit deployable. Acceptable because the
|
||||||
|
manifest is a fresh namespace under `k3s/staging/<name>/` — isolated, low
|
||||||
|
blast-radius, and Flux will simply recreate it if the file is bad. Manual
|
||||||
|
review gating was friction for no compensating safety gain on experiment
|
||||||
|
namespaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-18 — pgvector over Qdrant for brain hybrid retrieval (supersedes 2026-04-08)
|
||||||
|
|
||||||
|
**Context:** The 2026-04-08 ADR chose Qdrant for vector store. Since then,
|
||||||
|
postgres18 with pgvector has been deployed in the `databases` namespace on
|
||||||
|
koala and is already the shared default for the rest of the project
|
||||||
|
(CLAUDE.md lists `pgvector (vector), BM25` as the primary search layer and
|
||||||
|
Qdrant only as a fallback "when >1M vectors or hybrid retrieval"). Qdrant
|
||||||
|
itself has never been deployed — `kubectl get` finds no pod, service, or
|
||||||
|
manifest. Standing up a new vector engine for a single consumer is friction
|
||||||
|
that the original ADR did not weigh.
|
||||||
|
|
||||||
|
**Decision:** Use pgvector for brain hybrid retrieval. Issue #8 — and any
|
||||||
|
follow-on embedding work — targets the existing `postgres18` instance:
|
||||||
|
|
||||||
|
- one table `brain_embeddings(path TEXT PRIMARY KEY, embedding VECTOR(768), updated_at TIMESTAMPTZ)`,
|
||||||
|
IVFFlat or HNSW index by feel once volume warrants
|
||||||
|
- BM25 stays as today (file walk + token frequency); cosine via pgvector
|
||||||
|
- hybrid scoring done in SQL or Go; pick once we measure
|
||||||
|
- nomic-embed-text on iguana ollama provides 768-dim vectors
|
||||||
|
|
||||||
|
**Consequences:** One database engine instead of two. Backups, monitoring,
|
||||||
|
and connection pooling already solved. Trade-off: pgvector at >1M vectors
|
||||||
|
or under hybrid-search load may underperform Qdrant — revisit only when
|
||||||
|
benchmarks hurt. The 2026-04-08 ADR is superseded for the brain use case;
|
||||||
|
Qdrant remains the noted fallback path in CLAUDE.md if scale demands it.
|
||||||
|
|||||||
50
Dockerfile
50
Dockerfile
@@ -1,50 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
|
||||||
FROM golang:1.26-bookworm AS builder
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
||||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
|
||||||
-o /out/supervisor ./cmd/supervisor
|
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
|
||||||
# Node.js 22 slim — needed for claude CLI subprocess
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
# Install claude CLI (provides the `claude` binary the supervisor shells out to)
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code \
|
|
||||||
&& claude --version \
|
|
||||||
&& echo "claude CLI installed"
|
|
||||||
|
|
||||||
# Copy supervisor binary
|
|
||||||
COPY --from=builder /out/supervisor /usr/local/bin/supervisor
|
|
||||||
|
|
||||||
# Bake in config (models.yaml + skill discipline files)
|
|
||||||
COPY config/ /app/config/
|
|
||||||
|
|
||||||
# Run as non-root
|
|
||||||
RUN groupadd -r supervisor && useradd -r -g supervisor -d /app supervisor
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# brain/ is writable state — mount a PersistentVolume here
|
|
||||||
VOLUME /app/brain
|
|
||||||
|
|
||||||
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
|
||||||
ENV SUPERVISOR_MODELS_FILE=/app/config/models.yaml
|
|
||||||
ENV SUPERVISOR_BRAIN_DIR=/app/brain
|
|
||||||
ENV SUPERVISOR_SESSIONS_DIR=/app/brain/sessions
|
|
||||||
ENV SUPERVISOR_PORT=3200
|
|
||||||
|
|
||||||
USER supervisor
|
|
||||||
|
|
||||||
EXPOSE 3200
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/supervisor"]
|
|
||||||
30
Dockerfile.routing
Normal file
30
Dockerfile.routing
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||||
|
FROM golang:1.26-bookworm AS builder
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
||||||
|
-o /out/routing ./cmd/routing
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM gcr.io/distroless/base-debian12
|
||||||
|
|
||||||
|
COPY --from=builder /out/routing /usr/local/bin/routing
|
||||||
|
COPY config/ /app/config/
|
||||||
|
|
||||||
|
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
||||||
|
ENV ROUTING_PORT=3210
|
||||||
|
|
||||||
|
EXPOSE 3210
|
||||||
|
|
||||||
|
USER 65532:65532
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/routing"]
|
||||||
37
README.md
37
README.md
@@ -11,9 +11,11 @@ into a searchable brain.
|
|||||||
Your Claude Code session (in any project)
|
Your Claude Code session (in any project)
|
||||||
│
|
│
|
||||||
│ MCP over HTTP (Tailscale)
|
│ MCP over HTTP (Tailscale)
|
||||||
▼
|
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
||||||
supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, retrospective
|
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
||||||
ingestion :3300 — brain HTTP API: query wiki, write notes
|
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
||||||
|
│
|
||||||
|
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
brain/
|
brain/
|
||||||
@@ -57,16 +59,26 @@ Create `.mcp.json` in your project root:
|
|||||||
"supervisor": {
|
"supervisor": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://koala:30320/mcp"
|
"url": "http://koala:30320/mcp"
|
||||||
|
},
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://koala:30330/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The supervisor MCP server is reachable over Tailscale at `koala:30320` (NodePort
|
Two MCP servers are exposed today, both reachable over Tailscale:
|
||||||
to the in-cluster service on port 3200). No local binary or stdio shim is
|
|
||||||
required — Claude Code talks to it directly via HTTP.
|
|
||||||
|
|
||||||
Open Claude Code in your project — run `/mcp` to confirm `supervisor` is listed.
|
- **`supervisor`** at `koala:30320` — skill workers (`tdd_red/green/refactor`,
|
||||||
|
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
|
||||||
|
- **`brain`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
|
||||||
|
`brain_ingest`, `brain_ingest_raw`) and `session_log`. Hosted by the ingestion
|
||||||
|
service directly, no separate pod.
|
||||||
|
|
||||||
|
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
|
||||||
|
|
||||||
|
Open Claude Code in your project — run `/mcp` to confirm both servers are listed.
|
||||||
|
|
||||||
## A typical TDD session
|
## A typical TDD session
|
||||||
|
|
||||||
@@ -100,6 +112,17 @@ The supervisor probes connectivity at call time:
|
|||||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
||||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
||||||
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
||||||
|
| `SUPERVISOR_MCP_TOKEN` | — | Optional bearer token for the supervisor MCP HTTP endpoint; when empty, no auth is enforced |
|
||||||
|
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
|
||||||
|
| `ROUTING_MCP_TOKEN` | — | Optional bearer token for the routing MCP HTTP endpoint |
|
||||||
|
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | Routing pod → brain (in-cluster) |
|
||||||
|
| `HYPERGUILD_FAST_MODEL` | `koala/qwen35-9b-fast` | Fast model for high-pass-rate skill calls |
|
||||||
|
| `HYPERGUILD_THINKING_MODEL` | `iguana/gemma4-26b` | Thinking model for low-pass-rate skill calls |
|
||||||
|
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | At/above pass rate, route to fast model |
|
||||||
|
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
|
||||||
|
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
|
||||||
|
|
||||||
|
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL` and `HYPERGUILD_THINKING_MODEL` for routing to do useful work. If a model is missing, LiteLLM returns 4xx, the routing pod's fast route fails, the fail-open retry on the thinking model likely also fails (since both are missing), and the only signal is `final_status: "fail"` on `_routing` entries in the brain.
|
||||||
|
|
||||||
## Phase 2 (planned)
|
## Phase 2 (planned)
|
||||||
|
|
||||||
|
|||||||
21
Taskfile.yml
21
Taskfile.yml
@@ -39,6 +39,22 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go run ./cmd/supervisor
|
- go run ./cmd/supervisor
|
||||||
|
|
||||||
|
hyperguild:dev:
|
||||||
|
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||||
|
cmds:
|
||||||
|
- go run ./cmd/hyperguild {{.CLI_ARGS}}
|
||||||
|
|
||||||
|
hyperguild:build:
|
||||||
|
desc: Build the hyperguild binary into ./bin/hyperguild
|
||||||
|
cmds:
|
||||||
|
- mkdir -p bin
|
||||||
|
- go build -o bin/hyperguild ./cmd/hyperguild
|
||||||
|
|
||||||
|
hyperguild:install:
|
||||||
|
desc: Install hyperguild into $GOBIN
|
||||||
|
cmds:
|
||||||
|
- go install ./cmd/hyperguild
|
||||||
|
|
||||||
ingestion:dev:
|
ingestion:dev:
|
||||||
desc: Run ingestion server in development mode
|
desc: Run ingestion server in development mode
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
@@ -112,6 +128,11 @@ tasks:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
||||||
|
|
||||||
|
smoke:routing:
|
||||||
|
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
||||||
|
cmds:
|
||||||
|
- bash scripts/smoke-routing.sh
|
||||||
|
|
||||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
140
cmd/hyperguild/README.md
Normal file
140
cmd/hyperguild/README.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# hyperguild CLI
|
||||||
|
|
||||||
|
A small Go binary for tier probing, brain HTTP REST access, and
|
||||||
|
`.mcp.json` mode bootstrap. Replaces the supervisor's `tier` MCP and
|
||||||
|
gives shell scripts a stable interface to the brain.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task hyperguild:install
|
||||||
|
# or: go install ./cmd/hyperguild
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary lands at `$(go env GOBIN)/hyperguild` (typically
|
||||||
|
`~/go/bin/hyperguild`). Make sure that's on your PATH.
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
### `hyperguild tier`
|
||||||
|
|
||||||
|
Probes Anthropic and LiteLLM and reports the current operating tier.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hyperguild tier
|
||||||
|
tier 1 (full-online) managed_agents=true
|
||||||
|
|
||||||
|
$ hyperguild tier --json
|
||||||
|
{
|
||||||
|
"tier": 1,
|
||||||
|
"label": "full-online",
|
||||||
|
"available_models": null,
|
||||||
|
"managed_agents": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe URLs are read from environment:
|
||||||
|
|
||||||
|
| Var | Default |
|
||||||
|
|-----------------------|-------------------------------|
|
||||||
|
| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` |
|
||||||
|
| `LITELLM_BASE_URL` | (empty → falls through to airplane) |
|
||||||
|
|
||||||
|
### `hyperguild brain query <topic>`
|
||||||
|
|
||||||
|
BM25 search over the brain's knowledge + wiki entries.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hyperguild brain query "find -H symlink"
|
||||||
|
knowledge/2026-05-03-find-h-not-l-symlinked-root.md score=12 Use find -H, not find -L
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
- `--limit N` — max results (default 5)
|
||||||
|
- `--json` — emit the raw response envelope
|
||||||
|
|
||||||
|
### `hyperguild brain write <type> <slug>`
|
||||||
|
|
||||||
|
Reads markdown from stdin, writes a knowledge entry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
||||||
|
# Example lesson
|
||||||
|
|
||||||
|
## Lesson
|
||||||
|
...
|
||||||
|
EOF
|
||||||
|
knowledge/example-lesson.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hyperguild brain pass-rate <skill>`
|
||||||
|
|
||||||
|
Returns the pass rate for a skill over a lookback window. Computed
|
||||||
|
on-demand from `brain/sessions/*.jsonl`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hyperguild brain pass-rate tdd
|
||||||
|
tdd: 47 / 50 = 94% (window: 7d)
|
||||||
|
|
||||||
|
$ hyperguild brain pass-rate tdd --window 30d --json
|
||||||
|
{
|
||||||
|
"skill": "tdd",
|
||||||
|
"window": "30d",
|
||||||
|
"pass": 142,
|
||||||
|
"fail": 8,
|
||||||
|
"skip": 5,
|
||||||
|
"total": 155,
|
||||||
|
"pass_rate": 0.9467
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
- `--window` — lookback window (default `7d`; accepts `Nh`, `Nd`)
|
||||||
|
- `--json` — emit the raw response envelope
|
||||||
|
|
||||||
|
Skills with no logged invocations return zero counts and `pass_rate: null`
|
||||||
|
(indicating "no data", distinct from "always passes").
|
||||||
|
|
||||||
|
### `hyperguild mode <cloud|client-local|sovereign>`
|
||||||
|
|
||||||
|
Writes a `.mcp.json` template for the chosen operating mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hyperguild mode cloud --out ./.mcp.json
|
||||||
|
wrote ./.mcp.json (mode: cloud)
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
- `--out PATH` — output file (default `./.mcp.json`)
|
||||||
|
- `--force` — overwrite an existing file
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
|
||||||
|
- **cloud** — brain MCP only. Claude Code with no routing.
|
||||||
|
- **client-local** — brain + routing pod. The `routing` entry points at
|
||||||
|
`koala:30310/mcp` (the routing pod, deployed in Plan 6). The
|
||||||
|
`X-Hyperguild-Mode: client-local` header is forward-compat for future
|
||||||
|
modes; the pod treats absent or unknown values as `client-local`.
|
||||||
|
- **sovereign** — brain only, with a `_mode_note` explaining that this
|
||||||
|
mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude
|
||||||
|
Code fallback for emergency offline use.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Var | Default | Used by |
|
||||||
|
|-----------------------|--------------------------|---------------------|
|
||||||
|
| `BRAIN_URL` | `http://koala:30330` | `brain *`, `mode *` |
|
||||||
|
| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` | `tier` |
|
||||||
|
| `LITELLM_BASE_URL` | (empty) | `tier` |
|
||||||
|
|
||||||
|
Override `BRAIN_URL` if your brain pod is at a different Tailscale name
|
||||||
|
or port.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md` — full spec
|
||||||
|
- `docs/superpowers/plans/2026-05-03-hyperguild-cli.md` — implementation plan
|
||||||
106
cmd/hyperguild/brain.go
Normal file
106
cmd/hyperguild/brain.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("subcommand required (query|write|pass-rate)")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "query":
|
||||||
|
return runBrainQuery(ctx, args[1:], stdin, stdout, stderr)
|
||||||
|
case "write":
|
||||||
|
return runBrainWrite(ctx, args[1:], stdin, stdout, stderr)
|
||||||
|
case "pass-rate":
|
||||||
|
return runBrainPassRate(ctx, args[1:], stdin, stdout, stderr)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown subcommand: %s (expected query|write|pass-rate)", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("brain query", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
|
||||||
|
limit := fs.Int("limit", 5, "maximum number of results")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
|
}
|
||||||
|
if fs.NArg() < 1 {
|
||||||
|
return errors.New("topic required")
|
||||||
|
}
|
||||||
|
topic := fs.Arg(0)
|
||||||
|
|
||||||
|
res, err := newBrainClient().Query(ctx, topic, *limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *asJSON {
|
||||||
|
enc := json.NewEncoder(stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(res)
|
||||||
|
}
|
||||||
|
for _, hit := range res.Results {
|
||||||
|
fmt.Fprintf(stdout, "%s score=%d %s\n", hit.Path, hit.Score, hit.Title) //nolint:errcheck
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("brain write", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
|
}
|
||||||
|
if fs.NArg() < 2 {
|
||||||
|
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
||||||
|
}
|
||||||
|
kind := fs.Arg(0)
|
||||||
|
slug := fs.Arg(1)
|
||||||
|
|
||||||
|
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, res.Path) //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBrainPassRate(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("brain pass-rate", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
|
||||||
|
window := fs.String("window", "7d", "lookback window (e.g. 1h, 24h, 7d, 30d)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
|
}
|
||||||
|
if fs.NArg() < 1 {
|
||||||
|
return errors.New("skill required")
|
||||||
|
}
|
||||||
|
skill := fs.Arg(0)
|
||||||
|
|
||||||
|
res, err := newBrainClient().PassRate(ctx, skill, *window)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *asJSON {
|
||||||
|
enc := json.NewEncoder(stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(res)
|
||||||
|
}
|
||||||
|
if res.PassRate == nil {
|
||||||
|
fmt.Fprintf(stdout, "%s: no data (window: %s)\n", res.Skill, res.Window) //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "%s: %d / %d = %.0f%% (window: %s)\n", res.Skill, res.Pass, res.Total, *res.PassRate*100, res.Window) //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
220
cmd/hyperguild/brain_test.go
Normal file
220
cmd/hyperguild/brain_test.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func brainQueryServer(t *testing.T, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainQuery_Human(t *testing.T) {
|
||||||
|
srv := brainQueryServer(t, `{"results":[{"path":"knowledge/a.md","title":"A","excerpt":"...","score":9},{"path":"knowledge/b.md","title":"B","excerpt":"...","score":3}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"query", "topic"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := out.String()
|
||||||
|
assert.Contains(t, got, "knowledge/a.md")
|
||||||
|
assert.Contains(t, got, "score=9")
|
||||||
|
assert.Contains(t, got, "knowledge/b.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainQuery_JSON(t *testing.T) {
|
||||||
|
srv := brainQueryServer(t, `{"results":[{"path":"x.md","title":"X","excerpt":"e","score":5}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"query", "--json", "topic"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), `"path": "x.md"`)
|
||||||
|
assert.Contains(t, out.String(), `"score": 5`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainQuery_Limit(t *testing.T) {
|
||||||
|
gotLimit := -1
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var p struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &p)
|
||||||
|
gotLimit = p.Limit
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"query", "--limit", "12", "topic"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 12, gotLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainQuery_MissingTopic(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"query"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrain_NoSubsubcommand(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "subcommand required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrain_UnknownSubsubcommand(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainWrite_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/write", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"knowledge/test-slug.md"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(
|
||||||
|
context.Background(),
|
||||||
|
[]string{"write", "knowledge", "test-slug"},
|
||||||
|
strings.NewReader("# Test\n\nSome body content.\n"),
|
||||||
|
&out, &errBuf,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), "knowledge/test-slug.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "type and slug required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("invalid slug"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(
|
||||||
|
context.Background(),
|
||||||
|
[]string{"write", "knowledge", "bad slug"},
|
||||||
|
strings.NewReader("body"),
|
||||||
|
&out, &errBuf,
|
||||||
|
)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "400")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainWrite_EmptyStdin(t *testing.T) {
|
||||||
|
gotLen := -1
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var p struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &p)
|
||||||
|
gotLen = len(p.Content)
|
||||||
|
_, _ = w.Write([]byte(`{"path":"x.md"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainPassRate_Human(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := out.String()
|
||||||
|
assert.Contains(t, got, "tdd")
|
||||||
|
assert.Contains(t, got, "47 / 50")
|
||||||
|
assert.Contains(t, got, "94%")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainPassRate_NoData(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), "no data")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainPassRate_JSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"pass-rate", "--json", "tdd"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), `"pass_rate": 0.94`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainPassRate_MissingSkill(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"pass-rate"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "skill required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBrainPassRate_WindowFlag(t *testing.T) {
|
||||||
|
gotWindow := ""
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotWindow = r.URL.Query().Get("window")
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"30d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runBrain(context.Background(), []string{"pass-rate", "--window", "30d", "tdd"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "30d", gotWindow)
|
||||||
|
}
|
||||||
159
cmd/hyperguild/http.go
Normal file
159
cmd/hyperguild/http.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBrainURL = "http://koala:30330"
|
||||||
|
|
||||||
|
// brainClient calls the brain HTTP REST API exposed alongside the MCP
|
||||||
|
// endpoint at the same host:port. /mcp serves MCP framing; /query and /write
|
||||||
|
// serve plain REST. We use the REST surface because the CLI is a
|
||||||
|
// shell-friendly client; MCP framing is unnecessary.
|
||||||
|
type brainClient struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBrainClient() *brainClient {
|
||||||
|
u := os.Getenv("BRAIN_URL")
|
||||||
|
if u == "" {
|
||||||
|
u = defaultBrainURL
|
||||||
|
}
|
||||||
|
return &brainClient{
|
||||||
|
baseURL: u,
|
||||||
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryHit mirrors a single result from the brain's /query endpoint.
|
||||||
|
type QueryHit struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryResult mirrors the /query response envelope.
|
||||||
|
type QueryResult struct {
|
||||||
|
Results []QueryHit `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*QueryResult, error) {
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}{Query: topic, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := c.baseURL + "/query"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("brain POST /query: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("brain POST /query: status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out QueryResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode /query response: %w", err)
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteResult mirrors the /write response envelope.
|
||||||
|
type WriteResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
|
||||||
|
body, err := io.ReadAll(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read content: %w", err)
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Type: kind, Slug: slug, Content: string(body)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := c.baseURL + "/write"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("brain POST /write: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("brain POST /write: status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
var out WriteResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode /write response: %w", err)
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassRateResult mirrors the /pass-rate response envelope.
|
||||||
|
type PassRateResult struct {
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
Pass int `json:"pass"`
|
||||||
|
Fail int `json:"fail"`
|
||||||
|
Skip int `json:"skip"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
PassRate *float64 `json:"pass_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*PassRateResult, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("skill", skill)
|
||||||
|
q.Set("window", window)
|
||||||
|
u := c.baseURL + "/pass-rate?" + q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("brain GET /pass-rate: status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out PassRateResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode /pass-rate response: %w", err)
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
131
cmd/hyperguild/http_test.go
Normal file
131
cmd/hyperguild/http_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBrainClient_Query_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var got struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
|
assert.Equal(t, "find-h", got.Query)
|
||||||
|
assert.Equal(t, 3, got.Limit)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"path":"knowledge/x.md","title":"x","excerpt":"...","score":7}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
|
res, err := c.Query(context.Background(), "find-h", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Results, 1)
|
||||||
|
assert.Equal(t, "knowledge/x.md", res.Results[0].Path)
|
||||||
|
assert.Equal(t, 7, res.Results[0].Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClient_Query_TransportError(t *testing.T) {
|
||||||
|
c := &brainClient{baseURL: "http://127.0.0.1:1", http: http.DefaultClient}
|
||||||
|
_, err := c.Query(context.Background(), "x", 5)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClient_Query_Non200(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
|
_, err := c.Query(context.Background(), "x", 5)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClient_Write_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/write", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var got struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
|
assert.Equal(t, "knowledge", got.Type)
|
||||||
|
assert.Equal(t, "find-h", got.Slug)
|
||||||
|
assert.Equal(t, "# body\n", got.Content)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
|
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/pass-rate", r.URL.Path)
|
||||||
|
assert.Equal(t, "tdd", r.URL.Query().Get("skill"))
|
||||||
|
assert.Equal(t, "7d", r.URL.Query().Get("window"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
|
res, err := c.PassRate(context.Background(), "tdd", "7d")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "tdd", res.Skill)
|
||||||
|
assert.Equal(t, 47, res.Pass)
|
||||||
|
assert.Equal(t, 3, res.Fail)
|
||||||
|
require.NotNil(t, res.PassRate)
|
||||||
|
assert.InDelta(t, 0.94, *res.PassRate, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClient_PassRate_NullRate(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
|
res, err := c.PassRate(context.Background(), "tdd", "7d")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, res.PassRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBrainClient_DefaultURL(t *testing.T) {
|
||||||
|
t.Setenv("BRAIN_URL", "")
|
||||||
|
c := newBrainClient()
|
||||||
|
assert.Equal(t, "http://koala:30330", c.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBrainClient_OverrideURL(t *testing.T) {
|
||||||
|
t.Setenv("BRAIN_URL", "http://localhost:9999")
|
||||||
|
c := newBrainClient()
|
||||||
|
assert.Equal(t, "http://localhost:9999", c.baseURL)
|
||||||
|
}
|
||||||
71
cmd/hyperguild/main.go
Normal file
71
cmd/hyperguild/main.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Package main implements the hyperguild CLI: tier probe, brain HTTP REST
|
||||||
|
// access, and .mcp.json mode bootstrap. See docs/superpowers/specs/.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// subcommand is the contract every hyperguild subcommand satisfies.
|
||||||
|
// Functions take an explicit context, args (without the subcommand name
|
||||||
|
// itself), and explicit IO so tests can exercise full flows without
|
||||||
|
// touching os.Stdin / os.Stdout / os.Exit.
|
||||||
|
type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error
|
||||||
|
|
||||||
|
func subcommands() map[string]subcommand {
|
||||||
|
return map[string]subcommand{
|
||||||
|
"tier": runTier,
|
||||||
|
"brain": runBrain,
|
||||||
|
"mode": runMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = `Usage: hyperguild <subcommand> [options]
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
tier Probe Anthropic + LiteLLM, print current operating tier.
|
||||||
|
brain query <q> BM25 search the brain (HTTP REST).
|
||||||
|
brain write <t> <s>
|
||||||
|
Write stdin as a knowledge entry of type <t>, slug <s>.
|
||||||
|
mode <name> Bootstrap .mcp.json for a chosen mode:
|
||||||
|
cloud | client-local | sovereign
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
BRAIN_URL Brain HTTP REST + MCP base URL.
|
||||||
|
Default: http://koala:30330
|
||||||
|
ANTHROPIC_PROBE_URL Tier probe URL for the Anthropic API.
|
||||||
|
Default: https://api.anthropic.com
|
||||||
|
LITELLM_BASE_URL Tier probe URL for the LiteLLM gateway.
|
||||||
|
Optional; if empty, falls through to airplane tier.
|
||||||
|
`
|
||||||
|
|
||||||
|
// dispatch routes args to a subcommand and returns the process exit code.
|
||||||
|
// Split from main() so tests can drive it without process exit.
|
||||||
|
func dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprint(stderr, usage) //nolint:errcheck
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
fmt.Fprint(stdout, usage) //nolint:errcheck
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cmd, ok := subcommands()[args[0]]
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(stderr, "hyperguild: unknown subcommand: %s\n%s", args[0], usage) //nolint:errcheck
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if err := cmd(ctx, args[1:], stdin, stdout, stderr); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "hyperguild %s: %v\n", args[0], err) //nolint:errcheck
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
45
cmd/hyperguild/main_test.go
Normal file
45
cmd/hyperguild/main_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatch_Help_PrintsUsageAndReturnsZero(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
code := dispatch(context.Background(), []string{"--help"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Contains(t, out.String(), "Usage: hyperguild")
|
||||||
|
assert.Contains(t, out.String(), "tier")
|
||||||
|
assert.Contains(t, out.String(), "brain")
|
||||||
|
assert.Contains(t, out.String(), "mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_NoArgs_PrintsUsageAndReturnsTwo(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
code := dispatch(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Equal(t, 2, code)
|
||||||
|
assert.Contains(t, errBuf.String(), "Usage: hyperguild")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
code := dispatch(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Equal(t, 2, code)
|
||||||
|
assert.Contains(t, errBuf.String(), "unknown subcommand: bogus")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_KnownSubcommand_RoutesToHandler(t *testing.T) {
|
||||||
|
// "mode" without args fails → exit 1, message on stderr.
|
||||||
|
// (Confirms dispatch reached the handler rather than printing "unknown
|
||||||
|
// subcommand: mode".)
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, errBuf.String(), "name required")
|
||||||
|
assert.NotContains(t, errBuf.String(), "unknown subcommand")
|
||||||
|
}
|
||||||
101
cmd/hyperguild/mode.go
Normal file
101
cmd/hyperguild/mode.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("mode", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
out := fs.String("out", ".mcp.json", "output file path")
|
||||||
|
force := fs.Bool("force", false, "overwrite an existing file")
|
||||||
|
// Pull the first positional (mode name) out so flags after it still parse
|
||||||
|
// with stdlib flag (which stops at the first non-flag arg).
|
||||||
|
if len(args) < 1 {
|
||||||
|
return errors.New("name required (cloud|client-local|sovereign)")
|
||||||
|
}
|
||||||
|
name := args[0]
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brainURL := os.Getenv("BRAIN_URL")
|
||||||
|
if brainURL == "" {
|
||||||
|
brainURL = defaultBrainURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc map[string]any
|
||||||
|
switch name {
|
||||||
|
case "cloud":
|
||||||
|
doc = modeCloud(brainURL)
|
||||||
|
case "client-local":
|
||||||
|
doc = modeClientLocal(brainURL)
|
||||||
|
case "sovereign":
|
||||||
|
doc = modeSovereign(brainURL)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown mode: %s (expected cloud|client-local|sovereign)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*force {
|
||||||
|
if _, err := os.Stat(*out); err == nil {
|
||||||
|
return fmt.Errorf("%s exists (use --force to overwrite)", *out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal mode doc: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, append(body, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", *out, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "wrote %s (mode: %s)\n", *out, name) //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeCloud(brainURL string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"mcpServers": map[string]any{
|
||||||
|
"brain": map[string]any{
|
||||||
|
"url": brainURL + "/mcp",
|
||||||
|
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeClientLocal(brainURL string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"mcpServers": map[string]any{
|
||||||
|
"brain": map[string]any{
|
||||||
|
"url": brainURL + "/mcp",
|
||||||
|
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||||
|
},
|
||||||
|
"routing": map[string]any{
|
||||||
|
"url": "http://koala:30310/mcp",
|
||||||
|
"description": "Mode 2 routing pod — routes skill calls to LiteLLM/local",
|
||||||
|
"headers": map[string]any{
|
||||||
|
"X-Hyperguild-Mode": "client-local",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeSovereign(brainURL string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
|
||||||
|
"mcpServers": map[string]any{
|
||||||
|
"brain": map[string]any{
|
||||||
|
"url": brainURL + "/mcp",
|
||||||
|
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
148
cmd/hyperguild/mode_test.go
Normal file
148
cmd/hyperguild/mode_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readJSON(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var out map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(b, &out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_Cloud_Default(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := readJSON(t, outPath)
|
||||||
|
servers, ok := got["mcpServers"].(map[string]any)
|
||||||
|
require.True(t, ok, "mcpServers must be a JSON object")
|
||||||
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.NotContains(t, servers, "routing")
|
||||||
|
assert.NotContains(t, got, "_mode_note")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"client-local", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := readJSON(t, outPath)
|
||||||
|
servers := got["mcpServers"].(map[string]any)
|
||||||
|
require.Contains(t, servers, "brain")
|
||||||
|
require.Contains(t, servers, "routing")
|
||||||
|
|
||||||
|
routing := servers["routing"].(map[string]any)
|
||||||
|
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
||||||
|
|
||||||
|
headers, ok := routing["headers"].(map[string]any)
|
||||||
|
require.True(t, ok, "routing entry should have headers block")
|
||||||
|
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/mcp.json"
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
require.NoError(t, runMode(context.Background(), []string{"client-local", "--out", tmp}, nil, out, stderr))
|
||||||
|
|
||||||
|
body, err := os.ReadFile(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var doc map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(body, &doc))
|
||||||
|
|
||||||
|
servers := doc["mcpServers"].(map[string]any)
|
||||||
|
routing := servers["routing"].(map[string]any)
|
||||||
|
assert.Equal(t, "http://koala:30310/mcp", routing["url"])
|
||||||
|
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
||||||
|
|
||||||
|
headers, ok := routing["headers"].(map[string]any)
|
||||||
|
require.True(t, ok, "routing entry should have headers block")
|
||||||
|
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"sovereign", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := readJSON(t, outPath)
|
||||||
|
assert.Contains(t, got, "_mode_note")
|
||||||
|
servers := got["mcpServers"].(map[string]any)
|
||||||
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.NotContains(t, servers, "routing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_DefaultsOutToCwd(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir) // Go 1.24+ — replaces the older os.Chdir-with-cleanup pattern
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"cloud"}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, ".mcp.json"))
|
||||||
|
assert.NoError(t, statErr, ".mcp.json should exist in cwd")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_UnknownMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"bogus", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_NoArgs(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_RefusesToOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644))
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMode_Force(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
|
require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644))
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runMode(context.Background(), []string{"cloud", "--out", outPath, "--force"}, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := readJSON(t, outPath)
|
||||||
|
assert.Contains(t, got, "mcpServers")
|
||||||
|
assert.NotContains(t, got, "existing")
|
||||||
|
}
|
||||||
42
cmd/hyperguild/tier.go
Normal file
42
cmd/hyperguild/tier.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||||
|
|
||||||
|
func runTier(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("tier", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
anthropicURL := os.Getenv("ANTHROPIC_PROBE_URL")
|
||||||
|
if anthropicURL == "" {
|
||||||
|
anthropicURL = defaultAnthropicProbe
|
||||||
|
}
|
||||||
|
liteLLMURL := os.Getenv("LITELLM_BASE_URL") // empty → tier falls through to airplane
|
||||||
|
|
||||||
|
info := tier.Detect(ctx, anthropicURL, liteLLMURL)
|
||||||
|
|
||||||
|
if *asJSON {
|
||||||
|
enc := json.NewEncoder(stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(info); err != nil {
|
||||||
|
return fmt.Errorf("encode json: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "tier %d (%s) managed_agents=%t\n", int(info.Tier), info.Label, info.ManagedAgents) //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
77
cmd/hyperguild/tier_test.go
Normal file
77
cmd/hyperguild/tier_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func okServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTier_Full_Human(t *testing.T) {
|
||||||
|
anthropic := okServer(t)
|
||||||
|
defer anthropic.Close()
|
||||||
|
litellm := okServer(t)
|
||||||
|
defer litellm.Close()
|
||||||
|
|
||||||
|
t.Setenv("ANTHROPIC_PROBE_URL", anthropic.URL)
|
||||||
|
t.Setenv("LITELLM_BASE_URL", litellm.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), "tier 1")
|
||||||
|
assert.Contains(t, out.String(), "full-online")
|
||||||
|
assert.Contains(t, out.String(), "managed_agents=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTier_LANOnly_JSON(t *testing.T) {
|
||||||
|
litellm := okServer(t)
|
||||||
|
defer litellm.Close()
|
||||||
|
|
||||||
|
t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1") // unreachable
|
||||||
|
t.Setenv("LITELLM_BASE_URL", litellm.URL)
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runTier(context.Background(), []string{"--json"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
ManagedAgents bool `json:"managed_agents"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
|
||||||
|
assert.Equal(t, 2, got.Tier)
|
||||||
|
assert.Equal(t, "lan-only", got.Label)
|
||||||
|
assert.False(t, got.ManagedAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTier_Airplane_NoLiteLLMBaseURL(t *testing.T) {
|
||||||
|
t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1")
|
||||||
|
t.Setenv("LITELLM_BASE_URL", "")
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), "tier 3")
|
||||||
|
assert.Contains(t, out.String(), "airplane")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTier_UnknownFlag_ReturnsError(t *testing.T) {
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
err := runTier(context.Background(), []string{"--bogus"}, strings.NewReader(""), &out, &errBuf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
170
cmd/routing/main.go
Normal file
170
cmd/routing/main.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The internal/skills/{debug,retrospective,review,trainer} packages imported
|
||||||
|
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
|
||||||
|
// MUST NOT delete these four packages — the routing pod is their second
|
||||||
|
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
|
||||||
|
// that don't route to local), the supervisor binary, and supervisor manifests.
|
||||||
|
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/auth"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/config"
|
||||||
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/mcp"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/project"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/review"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
cfg, err := config.LoadRouting()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("config load failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
|
||||||
|
mustRead := func(path string) string {
|
||||||
|
b, err := os.ReadFile(configDir + "/" + path)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("read prompt failed", "path", path, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
||||||
|
|
||||||
|
router := &routing.Router{
|
||||||
|
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
||||||
|
Logger: routing.NewLogger(cfg.BrainURL),
|
||||||
|
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
||||||
|
FastModel: cfg.FastModel,
|
||||||
|
ThinkingModel: cfg.ThinkingModel,
|
||||||
|
Complete: llm.Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
|
||||||
|
// or project_root in the signature. Rather than modifying every skill's API
|
||||||
|
// (and inflating Plan 6's blast radius), the routing pod logs every decision
|
||||||
|
// under a fixed session_id "_routing". Operators query
|
||||||
|
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
|
||||||
|
const routingSessionID = "_routing"
|
||||||
|
wrap := func(skillName string) routing.CompleteFunc {
|
||||||
|
return func(ctx context.Context, _, system, user string) (string, int64, error) {
|
||||||
|
// The model param is ignored: the router picks the model based on policy.
|
||||||
|
return router.Run(ctx, routing.RunInput{
|
||||||
|
Skill: skillName,
|
||||||
|
System: system,
|
||||||
|
User: user,
|
||||||
|
SessionID: routingSessionID,
|
||||||
|
ProjectRoot: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := registry.New()
|
||||||
|
reg.Register(review.New(review.Config{
|
||||||
|
SkillPrompt: mustRead("review.md"),
|
||||||
|
DefaultModel: cfg.FastModel,
|
||||||
|
CompleteFunc: review.CompleteFunc(wrap("review")),
|
||||||
|
}))
|
||||||
|
reg.Register(debug.New(debug.Config{
|
||||||
|
SkillPrompt: mustRead("debug.md"),
|
||||||
|
DefaultModel: cfg.FastModel,
|
||||||
|
CompleteFunc: debug.CompleteFunc(wrap("debug")),
|
||||||
|
}))
|
||||||
|
reg.Register(retrospective.New(retrospective.Config{
|
||||||
|
SkillPrompt: mustRead("retrospective.md"),
|
||||||
|
DefaultModel: cfg.FastModel,
|
||||||
|
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
|
||||||
|
}))
|
||||||
|
reg.Register(trainer.New(trainer.Config{
|
||||||
|
ReaderPrompt: mustRead("trainer-reader.md"),
|
||||||
|
WriterPrompt: mustRead("trainer-writer.md"),
|
||||||
|
DefaultModel: cfg.FastModel,
|
||||||
|
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if cfg.GiteaMCPURL != "" {
|
||||||
|
mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
var ghClient *githubclient.Client
|
||||||
|
if cfg.GitHubPAT != "" {
|
||||||
|
ghClient = githubclient.New(cfg.GitHubPAT)
|
||||||
|
}
|
||||||
|
reg.Register(project.New(project.Config{
|
||||||
|
Client: mcpC,
|
||||||
|
GitHub: ghClient,
|
||||||
|
GiteaOwner: cfg.GiteaOwner,
|
||||||
|
GitHubOwner: cfg.GitHubOwner,
|
||||||
|
GitHubPAT: cfg.GitHubPAT,
|
||||||
|
InfraRepo: cfg.InfraRepo,
|
||||||
|
}))
|
||||||
|
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
|
||||||
|
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
|
||||||
|
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
|
||||||
|
} else {
|
||||||
|
logger.Info("project_create skipped — GITEA_MCP_URL not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
var validator *auth.Validator
|
||||||
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
|
audience := os.Getenv("MCP_AUDIENCE")
|
||||||
|
v, err := auth.NewValidator(dexURL, audience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
validator = v
|
||||||
|
logger.Info("jwt auth enabled", "issuer", dexURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/mcp", srv)
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
|
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
logger.Info("routing pod starting", "addr", addr,
|
||||||
|
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
|
||||||
|
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
|
||||||
|
logger.Error("server stopped", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
135
cmd/routing/main_test.go
Normal file
135
cmd/routing/main_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers,
|
||||||
|
// calls tools/list and one tools/call, and verifies the brain saw a session_log POST.
|
||||||
|
func TestRoutingPodEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("end-to-end binary boot")
|
||||||
|
}
|
||||||
|
|
||||||
|
var brainHits int
|
||||||
|
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer llm.Close()
|
||||||
|
|
||||||
|
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/pass-rate":
|
||||||
|
brainHits++
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95})
|
||||||
|
case "/mcp":
|
||||||
|
brainHits++
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer brain.Close()
|
||||||
|
|
||||||
|
port := freePort(t)
|
||||||
|
addr := "127.0.0.1:" + port
|
||||||
|
baseURL := "http://" + addr
|
||||||
|
|
||||||
|
bin := buildRouting(t)
|
||||||
|
cmd := exec.Command(bin)
|
||||||
|
cmd.Env = []string{
|
||||||
|
"ROUTING_PORT=" + port,
|
||||||
|
"LITELLM_BASE_URL=" + llm.URL,
|
||||||
|
"LITELLM_API_KEY=stub",
|
||||||
|
"BRAIN_URL=" + brain.URL,
|
||||||
|
"SUPERVISOR_CONFIG_DIR=../../config/supervisor",
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||||
|
|
||||||
|
require.NoError(t, waitForPort(t, addr, 30*time.Second))
|
||||||
|
|
||||||
|
resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||||
|
assert.Contains(t, resp, `"review"`)
|
||||||
|
assert.Contains(t, resp, `"debug"`)
|
||||||
|
assert.Contains(t, resp, `"retrospective"`)
|
||||||
|
assert.Contains(t, resp, `"trainer"`)
|
||||||
|
|
||||||
|
resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`)
|
||||||
|
_ = resp // shape varies by skill; we only need a 200
|
||||||
|
|
||||||
|
// Wait briefly for the async session_log to land.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && brainHits < 2 {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRouting(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
bin := t.TempDir() + "/routing"
|
||||||
|
out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput()
|
||||||
|
require.NoError(t, err, "build failed: %s", out)
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPort(_ *testing.T, addr string, dur time.Duration) error {
|
||||||
|
deadline := time.Now().Add(dur)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx
|
||||||
|
if err == nil {
|
||||||
|
_ = c.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`))
|
||||||
|
if err == nil {
|
||||||
|
r, err := http.DefaultClient.Do(conn)
|
||||||
|
if err == nil {
|
||||||
|
_ = r.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpCall(t *testing.T, url, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// freePort grabs an OS-assigned TCP port and releases it. There is a small
|
||||||
|
// race window before the subprocess re-binds it, but it is acceptable for
|
||||||
|
// test isolation against a hardcoded port colliding with another test or
|
||||||
|
// stray process.
|
||||||
|
func freePort(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
require.NoError(t, l.Close())
|
||||||
|
return strconv.Itoa(port)
|
||||||
|
}
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/org"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
||||||
skilldebug "github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/spec"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("load config", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
models, err := config.LoadModels(cfg.ModelsFile)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("load models", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
protocolsPrompt, err := os.ReadFile(cfg.ConfigDir + "/protocols.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read protocols.md", "path", cfg.ConfigDir+"/protocols.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prependProtocols prepends the shared protocols to a skill discipline file.
|
|
||||||
prependProtocols := func(skillPrompt []byte) string {
|
|
||||||
return string(protocolsPrompt) + "\n---\n\n" + string(skillPrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
reviewPrompt, err := os.ReadFile(cfg.ConfigDir + "/review.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read review.md", "path", cfg.ConfigDir+"/review.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrompt, err := os.ReadFile(cfg.ConfigDir + "/debug.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read debug.md", "path", cfg.ConfigDir+"/debug.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
specPrompt, err := os.ReadFile(cfg.ConfigDir + "/spec.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read spec.md", "path", cfg.ConfigDir+"/spec.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
trainerReaderPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-reader.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read trainer-reader.md", "path", cfg.ConfigDir+"/trainer-reader.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
trainerWriterPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-writer.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read trainer-writer.md", "path", cfg.ConfigDir+"/trainer-writer.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
litellm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
|
||||||
|
|
||||||
tierFn := func(ctx context.Context) tier.Info {
|
|
||||||
return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := registry.New()
|
|
||||||
reg.Register(tdd.New(tdd.Config{
|
|
||||||
SkillPrompt: prependProtocols(tddPrompt),
|
|
||||||
DefaultModel: models.ModelFor("tdd", ""),
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
}))
|
|
||||||
reg.Register(brain.New(brain.Config{
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
IngestSvcURL: cfg.IngestSvcURL,
|
|
||||||
KBRetrievalURL: cfg.KBRetrievalURL,
|
|
||||||
}))
|
|
||||||
reg.Register(org.New(org.Config{
|
|
||||||
TierFn: tierFn,
|
|
||||||
}))
|
|
||||||
reg.Register(sessionlog.New(sessionlog.Config{
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: prependProtocols(retroPrompt),
|
|
||||||
DefaultModel: models.ModelFor("retrospective", ""),
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
}))
|
|
||||||
reg.Register(review.New(review.Config{
|
|
||||||
SkillPrompt: prependProtocols(reviewPrompt),
|
|
||||||
DefaultModel: models.ModelFor("review", ""),
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
}))
|
|
||||||
reg.Register(skilldebug.New(skilldebug.Config{
|
|
||||||
SkillPrompt: prependProtocols(debugPrompt),
|
|
||||||
DefaultModel: models.ModelFor("debug", ""),
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
}))
|
|
||||||
reg.Register(spec.New(spec.Config{
|
|
||||||
SkillPrompt: prependProtocols(specPrompt),
|
|
||||||
DefaultModel: models.ModelFor("spec", ""),
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
}))
|
|
||||||
reg.Register(trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: prependProtocols(trainerReaderPrompt),
|
|
||||||
WriterPrompt: prependProtocols(trainerWriterPrompt),
|
|
||||||
DefaultModel: models.ModelFor("trainer", ""),
|
|
||||||
CompleteFunc: litellm.Complete,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
BrainDir: cfg.BrainDir,
|
|
||||||
}))
|
|
||||||
|
|
||||||
srv := mcp.NewServer(reg)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.Handle("/mcp", srv)
|
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
|
||||||
logger.Info("supervisor starting", "addr", addr, "version", "v0.5.0")
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
logger.Error("server stopped", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os/exec"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBinaryCompiles(t *testing.T) {
|
|
||||||
cmd := exec.Command("go", "build", "./...")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build failed: %s\n%s", err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1102
docs/superpowers/plans/2026-05-03-pass-rate-logging.md
Normal file
1102
docs/superpowers/plans/2026-05-03-pass-rate-logging.md
Normal file
File diff suppressed because it is too large
Load Diff
2449
docs/superpowers/plans/2026-05-04-mode-2-routing-pod.md
Normal file
2449
docs/superpowers/plans/2026-05-04-mode-2-routing-pod.md
Normal file
File diff suppressed because it is too large
Load Diff
79
docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md
Normal file
79
docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Spec: hyperguild CLI
|
||||||
|
|
||||||
|
> Plan 4 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Three needs converge on a single small Go binary:
|
||||||
|
|
||||||
|
1. **Tier probing as MCP is overkill.** The supervisor's `tier` MCP runs on `koala:30320` and answers a one-shot question (which models are reachable right now?). Pulling Claude Code through MCP startup, tool listing, and a JSON-RPC call for a 2-second probe is wasteful and adds a network hop the answer doesn't need.
|
||||||
|
2. **Brain access from shell scripts has no good front door.** The brain's HTTP REST API exists (Plan 1) at `koala:3300` for non-MCP clients, but every shell script that wants to query or write to the brain re-implements the curl invocation. A CLI gives shell pipelines, ad-hoc agent prompts, and quick-debug scenarios a stable interface.
|
||||||
|
3. **Mode bootstrap is manual.** Each new project that wants to operate in a chosen mode (cloud / client-local / sovereign) needs a `.mcp.json` written by hand. Without automation, mode adoption is gated on remembering the right MCP server URLs.
|
||||||
|
|
||||||
|
**Why now:** Plans 1–3 are merged. The CLI is the next building block in shrinking the supervisor pod toward a thin Mode-2 routing layer. Plans 5 and 6 build on the CLI's tier and brain helpers.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
||||||
|
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
||||||
|
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
|
||||||
|
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
||||||
|
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
||||||
|
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Stdlib only.** No `cobra`, `urfave/cli`, `viper`, etc. CLI router and flag parsing use `flag.NewFlagSet`.
|
||||||
|
- **Go 1.26.1**, project default.
|
||||||
|
- **Module:** `github.com/mathiasbq/supervisor`, peer to `cmd/supervisor/`. New code at `cmd/hyperguild/`. The module name keeps its historical `supervisor` value — renaming the module is out of scope and would touch every import.
|
||||||
|
- **Reuse `internal/tier`** unchanged. The CLI is a thin wrapper around `tier.Detect`.
|
||||||
|
- **Brain endpoint configurable** via `BRAIN_URL` env var (default `http://koala:30330` — Tailscale-exposed NodePort, both MCP at `/mcp` and HTTP REST at `/query`, `/write`, etc., share the port). No hostname literals embedded in the CLI body — sourced from env per the existing "logical-addresses-in-instructions" memory.
|
||||||
|
- **Test discipline:** table-driven, testify, fakes for HTTP and tier probing. No live network in tests.
|
||||||
|
- **Errors:** wrapped via `fmt.Errorf("op: %w", err)`. No naked returns. Stderr for errors, stdout for results.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- The Mode 6 routing pod itself — `mode client-local` writes a placeholder entry pointing at the future routing URL with a `_routing_pending` annotation; the CLI does not provision the pod.
|
||||||
|
- Pass-rate logging (Plan 5) — the CLI's `brain write` does not emit `session_log` events.
|
||||||
|
- Skill worker CLIs (`hyperguild tdd_red`, `hyperguild review`, etc.) — those stay on the supervisor MCP until Plan 7.
|
||||||
|
- Brain HTTP server changes — the REST endpoints already exist.
|
||||||
|
- Authentication / TLS — Tailscale provides network isolation; no auth currently.
|
||||||
|
- Windows/Linux binaries — macOS-only per the user's setup. `go build` is portable but no cross-compilation in CI.
|
||||||
|
- A `crush` config writer for Mode 3 — Mode 3 (sovereign) writes a Claude-Code-compatible `.mcp.json` with brain-only MCP, on the assumption that even Crush-primary users may fall back to Claude Code with brain access. Crush's own config is owned by the user manually.
|
||||||
|
- A unified `--config` file for the CLI — env var + flags is enough today.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- **Single binary, inline subcommand router.** `cmd/hyperguild/main.go` dispatches on `os.Args[1]` to per-subcommand functions, each owning its own `flag.NewFlagSet`. Rationale: 4 top-level subcommands (`tier`, `brain`, `mode`, plus `--help`) and one nested level (`brain query`, `brain write`); ~80 lines of routing plumbing in stdlib beats pulling cobra's ~3 KLOC of dependencies for a tiny CLI. The router is testable by injecting `args []string` instead of reading `os.Args` directly.
|
||||||
|
|
||||||
|
- **`tier` subcommand reuses `internal/tier.Detect` verbatim.** Probe URLs (`https://api.anthropic.com` and the LiteLLM base URL) come from environment: `ANTHROPIC_PROBE_URL` (default the literal Anthropic URL) and `LITELLM_BASE_URL` (no default — error if `--mode-needs-llm` and unset). Rationale: matching the supervisor's existing wiring means the CLI cannot disagree with the supervisor about tier; a single source of truth.
|
||||||
|
|
||||||
|
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
||||||
|
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
||||||
|
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
||||||
|
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
||||||
|
|
||||||
|
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||||
|
- `cloud` — `mcpServers` contains only `brain` at `http://koala:30330/mcp`.
|
||||||
|
- `client-local` — `mcpServers` contains `brain` at `http://koala:30330/mcp` and a `routing` placeholder entry with `url` set to a marker (`http://koala:30310/mcp`) and an extra field `"_routing_pending": "Plan 6 — routing pod not deployed yet"`. Rationale: keeping strict-JSON parseable means using a placeholder field rather than a JSON comment, which the spec parser would reject.
|
||||||
|
- `sovereign` — `mcpServers` contains only `brain`, plus a top-level `"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback."`.
|
||||||
|
All three are valid JSON and all three round-trip through `jq` for verification.
|
||||||
|
Rationale: a single subcommand with three clearly-different outputs is easier to evolve than three nearly-duplicate subcommands. The placeholder fields are intentional documentation in the file itself, which the user actually opens and edits.
|
||||||
|
|
||||||
|
- **No global state.** Each subcommand is a function `(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error`, allowing table-driven tests to exercise full subcommand flows without `os.Exit` or fd capture.
|
||||||
|
|
||||||
|
- **HTTP client injection.** A package-level `http.Client` with 5s timeout for `brain` calls, overridable in tests via a constructor. Real client for `main`, `httptest.Server` for tests.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`.mcp.json` schema may evolve.** Claude Code's MCP config format is defined by the harness, and Anthropic could change it. Mitigation: document the format in the CLI's `--help` text and in the spec; if it breaks, the fix is local to one template function.
|
||||||
|
|
||||||
|
- **Brain endpoint hostname drift.** If the brain moves off `koala`, the env-var override avoids breaking the CLI but the `mode` template's hardcoded `koala:30330` becomes stale. Mitigation: source the URL in the `mode` template from the same env var (`BRAIN_URL`) so all three subcommands stay in lockstep with the user's actual environment.
|
||||||
|
|
||||||
|
- **`tier` probe URL gap.** The CLI inherits the supervisor's hardcoded `https://api.anthropic.com` probe URL via `internal/tier`. If Anthropic changes the URL, both supervisor and CLI break together. Mitigation: env-var override `ANTHROPIC_PROBE_URL`; default unchanged.
|
||||||
|
|
||||||
|
- **No HTTP retry logic.** The CLI returns first-error to the user. For ad-hoc shell use this is fine; for automation a future `--retry` flag may be needed. Out of scope for this iteration.
|
||||||
|
|
||||||
|
- **Tests don't cover live network.** Pure-fake tests catch regression but not "does the brain pod actually answer." Mitigation: add a smoke-test `task hyperguild:smoke` in a follow-up that runs against the real brain — separate concern, not in Plan 4.
|
||||||
|
|
||||||
|
- **Mode 3 sovereign output may surprise users** who expect Mode 3 to skip writing a `.mcp.json` entirely (since Crush is the primary harness). Mitigation: the `_mode_note` field explains the choice; the `--out /dev/null` escape hatch lets users skip the write if they want.
|
||||||
125
docs/superpowers/specs/2026-05-03-pass-rate-logging-design.md
Normal file
125
docs/superpowers/specs/2026-05-03-pass-rate-logging-design.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Spec: Pass-rate logging
|
||||||
|
|
||||||
|
> Plan 5 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Plan 6 (Mode 2 routing pod) needs a per-skill signal to decide whether to route a call to the local model or keep it on Claude. The natural signal is recent pass rate: a skill that succeeds 95% of the time on local is safe to route; a skill that succeeds 60% is not. Today there is no such signal — the `session_log` MCP exists (shipped in Plan 1) but skills don't reliably call it, and no endpoint computes pass rate from the resulting logs.
|
||||||
|
|
||||||
|
Two consequences:
|
||||||
|
1. **Plan 6 cannot be trusted without baseline data.** Routing decisions made on guesses will produce regressions that erode confidence in Mode 2 entirely.
|
||||||
|
2. **The skill library has no observability.** When a skill regresses (model swap, prompt drift, environment change), there's no way to notice until a downstream task explicitly fails.
|
||||||
|
|
||||||
|
**Why now:** Plans 1–4 are merged. Plan 5 instruments the discipline that Plan 6 will consume. Several weeks of usage data between Plan 5 merge and Plan 6 deploy will mean Plan 6 lands on real numbers, not synthetic.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] After Plan 5 merges, every invocation of `tdd` (pilot skill) calls `session_log` at the end of each phase (red, green, refactor) with `final_status` ∈ {pass, fail, skip}.
|
||||||
|
- [ ] At least 6 of the remaining "binary-outcome" skills get the same treatment: `code-review`, `debug`, `feature-spec`, `session-retrospective`, `trainer`, `spec-driven-dev`. (Skills with no clear pass/fail — `clean-code`, `cognitive-load`, `solid`, `refactoring`, `test-design`, `problem-analysis`, `user-stories`, `planning`, `atdd`, `gitea-ci` — are out of scope.)
|
||||||
|
- [ ] A new HTTP REST endpoint `GET /pass-rate?skill=X&window=7d` on the brain pod returns valid JSON `{skill, window, pass, fail, skip, total, pass_rate}` for any skill name. Skills with no logged invocations return zeros (not 404, not error). Pass rate is `pass / (pass + fail)`; if `pass + fail == 0`, returns `pass_rate: null`.
|
||||||
|
- [ ] The endpoint's aggregator normalizes legacy values: `pass` ≡ `ok`, `fail` ≡ `error`, `skip` ≡ `skipped`. No data loss when scanning historical logs.
|
||||||
|
- [ ] An optional CLI subcommand `hyperguild brain pass-rate <skill> [--window 7d] [--json]` calls the endpoint and prints either human-readable (`tdd: 47 / 50 = 94% (window: 7d)`) or JSON.
|
||||||
|
- [ ] `task check` passes (lint + test + vet + drift + govulncheck) on each task and on the merged branch.
|
||||||
|
- [ ] One week post-merge, `GET /pass-rate?skill=tdd&window=7d` returns non-zero counts and a real `pass_rate`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Stdlib + existing deps only.** The endpoint adds to the existing ingestion pod's HTTP handler (Go, `net/http`). No new service, no new pod, no new persistence layer.
|
||||||
|
- **No auth on `/pass-rate`.** Same model as the rest of the brain HTTP REST API: Tailscale-only network, no token.
|
||||||
|
- **Schema:** the SKILL.md template uses `pass | fail | skip` for `final_status`. The aggregator treats `pass` and `ok` as equivalent, `fail` and `error` as equivalent, `skip` and `skipped` as equivalent. New writes from skills MUST use the new vocabulary; the aggregator handles both for read-back.
|
||||||
|
- **Storage:** continues to use the existing JSONL files at `<pod>/brain/sessions/*.jsonl`. No format change. No materialized aggregates. If on-demand scans become slow (>500ms p99), revisit in a follow-up; not now.
|
||||||
|
- **Backwards compatibility:** the existing `session_log` MCP tool's signature does not change. Its docstring should be updated to reflect the new vocabulary, but argument types stay the same.
|
||||||
|
- **Pilot-before-rollout:** the first SKILL.md instrumentation (`tdd`) must dogfood successfully — at least one real `tdd` invocation post-instrumentation produces a session log entry — before the other six skills get their updates.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Plan 6 routing pod itself (the consumer of `/pass-rate`).
|
||||||
|
- Materialized rolling counters (compute on-demand for now).
|
||||||
|
- Auth, rate limiting, or per-user filtering on `/pass-rate`.
|
||||||
|
- Dashboards or visualization (`hyperguild brain pass-rate` text/JSON is the only UI).
|
||||||
|
- Real-time streaming or push notifications (`/pass-rate` is poll-only).
|
||||||
|
- Skills with no clear binary outcome (the 10 skills listed in Success Criteria).
|
||||||
|
- Per-model or per-mode breakdown (`session_log` already records `model_used`; the endpoint aggregates across all models for now). Plan 6 may want sharper aggregation; we'll add fields when it lands.
|
||||||
|
- Migration of the one historical entry in `2026-04-17-validate-hyperguild.jsonl` from `pass` (which is the new vocabulary, by accident) — no migration needed.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Component A — SKILL.md instrumentation pattern
|
||||||
|
|
||||||
|
Each instrumented skill gets a standardized "Logging" subsection under its existing "Brain MCP Integration" section. The subsection names the required `session_log` fields with explicit copy-paste examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
**At each phase end:** call `session_log` with:
|
||||||
|
- `skill`: "<this-skill-name>"
|
||||||
|
- `phase`: "<the-phase>"
|
||||||
|
- `final_status`: "pass" | "fail" | "skip"
|
||||||
|
- `message`: "<one-line summary>"
|
||||||
|
- `duration_ms`: <wall clock>
|
||||||
|
- `project_root`: "<absolute path to the project under work>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The pilot SKILL.md (`~/dev/.skills/tdd/SKILL.md`) gets instrumented first. The implementation defines the contract; the rollout commits replicate the pattern across the other six SKILL.md files.
|
||||||
|
|
||||||
|
Rationale: SKILL.md as the source of truth means the contract is visible to every agent that loads the skill — no hidden middleware. Mode-agnostic: the agent calls `session_log` whether it's Claude (Mode 1), Claude+routing (Mode 2), or Crush (Mode 3). The pattern is uniform; only the skill name + phase set differ.
|
||||||
|
|
||||||
|
### Component B — `/pass-rate` HTTP endpoint
|
||||||
|
|
||||||
|
New handler at the existing ingestion pod, peer to `/query`, `/write`, `/ingest`, etc.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /pass-rate?skill=<name>&window=<duration>
|
||||||
|
→ 200 { "skill": "tdd", "window": "7d", "pass": 47, "fail": 3, "skip": 0, "total": 50, "pass_rate": 0.94 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Parse `skill` (required) and `window` (default `7d`, accept Go-style `1h`, `12h`, `7d`, `30d`).
|
||||||
|
2. Walk `brain/sessions/*.jsonl` in the pod's volume. For each line: parse JSON, filter by `skill == query.skill` and `timestamp >= now - window`.
|
||||||
|
3. Tally `pass` (counts both `pass` and `ok`), `fail` (`fail` and `error`), `skip` (`skip` and `skipped`).
|
||||||
|
4. Compute `pass_rate = pass / (pass + fail)`; if `pass + fail == 0`, return `pass_rate: null`.
|
||||||
|
5. Return JSON.
|
||||||
|
|
||||||
|
Rationale for on-demand: the JSONL files are append-only and small (one entry per skill phase, kilobytes per session at most). For the first months of Plan 5 usage, scanning all sessions for a single query is fast enough. If it ever isn't, a materialized index is a follow-up — the endpoint shape doesn't change.
|
||||||
|
|
||||||
|
### Component C — Optional CLI subcommand
|
||||||
|
|
||||||
|
`hyperguild brain pass-rate <skill> [--window 7d] [--json]`. Adds a third nested verb under `brain` (sibling to `query` and `write`). Calls `GET /pass-rate?skill=<>&window=<>` via the existing `brainClient` infrastructure. Default human output: `tdd: 47 / 50 = 94% (window: 7d)`. `--json` passes through the response envelope.
|
||||||
|
|
||||||
|
Rationale: shell access to pass-rate without curl + jq. Optional in the strict sense — Plan 6's routing pod will call the endpoint directly, not via the CLI — but cheap to add (one new method on `brainClient`, one new dispatch case in `runBrain`).
|
||||||
|
|
||||||
|
### Schema and normalization
|
||||||
|
|
||||||
|
`session_log` JSONL line shape (unchanged today, codified by this plan):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "<id>",
|
||||||
|
"timestamp": "2026-05-03T20:30:00Z",
|
||||||
|
"skill": "tdd",
|
||||||
|
"phase": "red",
|
||||||
|
"project_root": "/abs/path",
|
||||||
|
"final_status": "pass",
|
||||||
|
"duration_ms": 12345,
|
||||||
|
"message": "Test written, function undefined, red confirmed."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`final_status` values:
|
||||||
|
- New writes (this plan onward): `pass | fail | skip`
|
||||||
|
- Read aggregator accepts both new and legacy: `pass`/`ok` → pass, `fail`/`error` → fail, `skip`/`skipped` → skip
|
||||||
|
- Anything else → counted as `skip` for safety (don't pollute pass/fail with malformed entries)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Endpoint: table-driven tests with a temp `brain/sessions/` directory containing JSONL files spanning multiple skills, multiple statuses (both vocabularies), edge cases (empty file, malformed line, timestamp outside window, future timestamp). Tests run via `httptest.NewServer` against the real handler.
|
||||||
|
- CLI: tests for `runBrainPassRate` against `httptest.Server` fake of `/pass-rate`. Human and `--json` output paths.
|
||||||
|
- Pilot dogfood: after instrumenting `tdd/SKILL.md`, one real TDD task in this plan exercises the logging path. The corresponding session log entry verifies end-to-end.
|
||||||
|
- `task check` per task.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Skills that don't reliably log produce missing data.** The aggregator returns zero counts for those, which Plan 6 may misread as "this skill always passes" or "this skill is broken". Mitigation: the endpoint returns `pass_rate: null` when `pass + fail == 0`, signalling "no data" distinct from "always passes". Plan 6 must check for null.
|
||||||
|
- **Agents may forget to call `session_log` mid-skill.** No way to enforce in cloud Mode 1 — Claude may skip the call if instructions are unclear. Mitigation: SKILL.md template makes the call literal and copy-pasteable. After 1 week, if instrumentation rate is < 80% of expected calls, escalate; consider a wrapper at the routing-pod layer in Plan 6 as belt-and-suspenders.
|
||||||
|
- **Schema drift between legacy `ok` and new `pass`.** Mitigation: the aggregator's normalization rule. Documented in the endpoint's response and in the `session_log` tool docstring update.
|
||||||
|
- **`/pass-rate` walks all session files for each request.** With ~1 file per session and tens of sessions per week, this is microseconds today. At hundreds of files per day, may need a date-bounded directory layout. Mitigation: monitor; if scan time > 100ms p99, revisit. Not in this plan.
|
||||||
|
- **The pilot may fail on the first dogfood.** If `tdd` instrumentation doesn't produce a log entry (e.g. agent didn't call `session_log`, JSON shape wrong, file permissions), the rollout to the other six skills is blocked until the pilot succeeds. Mitigation: explicit "pilot validates end-to-end" gate as the last step of Component A.
|
||||||
|
- **Adding a third verb under `brain` slightly stretches the inline-router pattern.** Three verbs in a switch is still simple; if it grows to five, the CLI may want a per-verb registration map. Mitigation: deferred — three is fine.
|
||||||
240
docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md
Normal file
240
docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Spec: Mode 2 routing pod
|
||||||
|
|
||||||
|
> Plan 6 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Mode 2 (`client-local`) is the cost-and-sovereignty mode for paid client work — keep skill calls inside Tailscale, save tokens, but stay reliable. Plans 1–5 produced everything Mode 2 needs except the consumer: the brain MCP at `:30330` is live, four skills are instrumented to log `pass | fail | skip`, and `GET /pass-rate?skill=X&window=Y` returns honest numbers (or `null` when there is no data). What is still missing is the policy layer that reads pass-rate and acts on it.
|
||||||
|
|
||||||
|
The supervisor pod (`:30320`) historically hosted full skill workers (`tdd_red/green/refactor`, `code_review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`) but with no routing — every call ran local regardless of skill quality, and Claude Code in client-local mode silently lost access to Claude-quality work even when local was wrong. That's the regression Plan 6 fixes.
|
||||||
|
|
||||||
|
**Why now:** the supervisor pod is scheduled for retirement (Plan 7) and the data plumbing for routing decisions exists but has no consumer. Without Plan 6, Plan 7 cannot land.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] A new pod `routing` is deployed via Flux at NodePort `:30310`, alongside (not replacing) the supervisor and ingestion pods. Image built by gitea CI, deployment manifest under `infra/k3s/apps/routing/`. `kubectl -n routing get deployment` shows `1/1 Ready`.
|
||||||
|
- [ ] `POST http://koala:30310/mcp` responds to `tools/list` with exactly four tools: `code_review`, `debug`, `retrospective`, `trainer`. Each tool's name + JSON schema is byte-identical to the supervisor's current advertisement (verified by snapshot test).
|
||||||
|
- [ ] Bearer-token auth via env var `ROUTING_MCP_TOKEN` (same opt-in pattern as `SUPERVISOR_MCP_TOKEN` shipped in `f49850d`). Empty token = no auth; populated token = `Authorization: Bearer <token>` required, otherwise HTTP 401 + JSON-RPC `-32001`.
|
||||||
|
- [ ] On every tool call, the pod queries `${BRAIN_URL}/pass-rate?skill=<tool>&window=7d` and applies a configurable policy:
|
||||||
|
- `pass_rate == null` → route to local (default-to-local)
|
||||||
|
- `pass_rate ≥ HYPERGUILD_ROUTE_LOCAL_FLOOR` (default `0.90`) → route to local
|
||||||
|
- `HYPERGUILD_ROUTE_LOCAL_CEIL ≤ pass_rate < FLOOR` (CEIL default `0.70`) → 50/50 deterministic sample (hash of canonical request body)
|
||||||
|
- `pass_rate < CEIL` → route to Claude
|
||||||
|
- [ ] Both routes resolve to a LiteLLM call: local route uses `HYPERGUILD_LOCAL_MODEL` (default `qwen35`), Claude route uses `HYPERGUILD_CLAUDE_MODEL` (default `claude-sonnet-4-6`). LiteLLM at `${LITELLM_BASE_URL}` (default `http://piguard:4000`) handles provider routing. The routing pod has no direct Anthropic SDK.
|
||||||
|
- [ ] Every routing decision is logged via `session_log` to the brain pod with `{skill: "_routing", phase: "decide", final_status: "skip", message: "<tool>: <decision>", duration_ms, project_root}`. `final_status: "skip"` keeps these entries out of any skill's pass-rate aggregation.
|
||||||
|
- [ ] LiteLLM unreachable → fail open to a Claude decision *and* log `final_status: "fail"` for `_routing`. The pod must still serve requests even if LiteLLM is down for hours.
|
||||||
|
- [ ] `cmd/hyperguild/mode.go` updated: `mode client-local` writes the routing entry with `"headers": {"X-Hyperguild-Mode": "client-local"}` and the `_routing_pending` placeholder field is removed. The pod accepts but does not branch on the header (forward-compat only).
|
||||||
|
- [ ] `task check` (lint + test + vet + drift + govulncheck) passes on each task and on the merged branch. The CI gate that bit Plan 1 must not bite Plan 6 (per `feedback_per_task_verification` memory).
|
||||||
|
- [ ] A new `task smoke:routing` target boots the binary against the live LiteLLM at `piguard:4000` and the live `/pass-rate` at `koala:30330`, calls each of the four advertised tools once, and verifies a `_routing` entry appears in the brain via `GET /pass-rate?skill=_routing&window=1h`. This is the live-contract test (per `2026-05-03-fake-tests-vs-real-contract` brain entry); fake-server unit tests verify policy logic, the smoke step verifies the contract.
|
||||||
|
- [ ] Mode 1 (`cloud`) and Mode 3 (`sovereign`) are byte-identically unchanged. Verified by `git diff` showing no changes to `mode.go`'s `modeCloud` or `modeSovereign` functions.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Stdlib + existing deps only.** The routing pod reuses `internal/exec/litellm.go` (`NewLiteLLM`, `Complete`), `internal/registry`, and `internal/skills/{review,debug,retrospective,trainer}/`. No new third-party dependency. Auth code may be duplicated from `internal/mcp/server.go` or extracted to a shared helper — implementer's call.
|
||||||
|
- **No new persistence.** Pass-rate data lives in the brain pod's session JSONL files (Plan 5). Routing-decision logs land in the same place via `session_log`. Routing pod has no DB, no cache, no on-disk state beyond an optional in-memory pass-rate cache (TTL = 60 seconds — protects the brain from per-call hammering during an active session).
|
||||||
|
- **MCP wire format identical to supervisor's.** Tools have the same names and JSON schemas as today. A consumer switches modes by changing only the URL in `.mcp.json` — no schema-level differences. Snapshot tests pin this.
|
||||||
|
- **Pod must start and serve degraded.** If LiteLLM is down at startup, the pod still binds to `:3210`, advertises tools, and serves requests with the fail-open-to-Claude behavior described in success criteria.
|
||||||
|
- **`internal/skills/{review,debug,retrospective,trainer}/` survives Plan 6.** Plan 7's note about deleting them is amended: those four packages are reused by the routing pod and must NOT be deleted in Plan 7. Plan 7 deletes only `internal/skills/{tdd,spec}/`, the supervisor binary, the supervisor manifests, and frees NodePort `:30320`. This spec calls out the change so Plan 7's author doesn't delete needed code (per `2026-05-03-implicit-cleanup-third-category` brain entry).
|
||||||
|
- **No retries beyond fail-open.** A LiteLLM call that errors becomes a Claude decision and a `final_status: "fail"` log. No exponential backoff, no circuit breaker — that's policy for a future plan once the failure shape is observed.
|
||||||
|
- **Determinism in sampling.** When pass-rate is in the sample band (`CEIL ≤ pr < FLOOR`), the local-vs-Claude choice for a given request is reproducible: hash a canonical JSON of the request body, low bit picks local. Same input → same decision. Avoids per-call variance confusing the operator during a debugging session.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- **Plan 7 (supervisor retirement).** Separate plan, executed after Plan 6 stabilizes. Plan 6 leaves the supervisor pod running; nothing about supervisor changes in this plan.
|
||||||
|
- **Routing for `tdd_red/green/refactor`, `spec`, `tier`.** Per `project_per_skill_routing.md`, these are SKILL.md or CLI, not routing-pod tools. They never appear in the routing pod's `tools/list`. If a future plan changes that decision, it adds them then.
|
||||||
|
- **Routing for `brain_ingest`.** Already routed at the brain pod (Plan 1). No change.
|
||||||
|
- **Per-mode policy branching.** The pod accepts `X-Hyperguild-Mode` for forward-compat but treats absent or unknown values as `client-local`. No code path differs on the header value yet.
|
||||||
|
- **OAuth, IP allowlisting, rate limiting, audit logging.** Bearer-token only; same risk model as the supervisor MCP after `f49850d`.
|
||||||
|
- **Decision-log read endpoints.** Routing decisions land in the brain via `session_log`. Reads happen via the existing `GET /pass-rate` endpoint and JSONL inspection. No new read API.
|
||||||
|
- **Materialized routing-decision aggregates.** Out of scope for the same reason Plan 5 deferred materialized counters: on-demand scans are fast enough at current data volumes.
|
||||||
|
- **Tunable per-skill thresholds.** `FLOOR` and `CEIL` are global. If the operator decides `debug` needs a different floor than `code_review`, that's a follow-up plan with real data behind the choice.
|
||||||
|
- **Sampling beyond a 50/50 hash split.** No epsilon-decay schedules, no Thompson sampling, no per-skill exploration policies. Add when data justifies.
|
||||||
|
- **Migration of any existing supervisor-skill `.mcp.json` registrations.** Consumers update their `.mcp.json` (via `hyperguild mode client-local`) when they want Mode 2 behavior. No silent redirect.
|
||||||
|
- **Routing-pod-side prompt customization.** The four skill packages already own their prompts; the routing pod just calls into them via the existing `Skill` interface. Prompt edits remain a SKILL.md or `internal/skills/<x>/` concern.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### A. Binary layout: `cmd/routing/`
|
||||||
|
|
||||||
|
A new Go binary at `cmd/routing/main.go`. Stdlib + `internal/*`. Wires:
|
||||||
|
1. Config from env (typed struct in `internal/config/routing.go` — peer to `Config` for the supervisor; deliberately a separate type because the surfaces are different and merging would force every routing-pod field onto the supervisor and vice versa).
|
||||||
|
2. `internal/exec/litellm.NewLiteLLM(...)` — same client the supervisor uses.
|
||||||
|
3. `internal/skills/{review,debug,retrospective,trainer}.New(...)` constructors, each receiving a `CompleteFunc` that wraps the routing decision (see C below).
|
||||||
|
4. `internal/registry.New()` populated with the four skills.
|
||||||
|
5. `internal/mcp.NewServer(reg, cfg.MCPAuthToken)` — reuse the existing handler with bearer auth from `f49850d`. The handler is generic; nothing in it is supervisor-specific.
|
||||||
|
|
||||||
|
**Rationale:** the supervisor's runtime is already 80% of what the routing pod needs. Reusing it saves the routing pod from re-implementing skill dispatch, MCP protocol handling, and bearer auth. The only new code is the routing decision itself (C below) and the deployment manifests (G).
|
||||||
|
|
||||||
|
### B. Configuration via env
|
||||||
|
|
||||||
|
Typed struct, parsed at startup. New env vars introduced by Plan 6:
|
||||||
|
|
||||||
|
| Env var | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `ROUTING_PORT` | `3210` | Pod's HTTP port (NodePort `:30310` maps to this) |
|
||||||
|
| `ROUTING_MCP_TOKEN` | — | Bearer token, opt-in (empty = no auth) |
|
||||||
|
| `LITELLM_BASE_URL` | `http://piguard:4000` | LiteLLM proxy (reused) |
|
||||||
|
| `LITELLM_API_KEY` | — | Reused, sourced from `routing-secrets` Secret |
|
||||||
|
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | In-cluster brain pod for `/pass-rate` and `session_log` |
|
||||||
|
| `HYPERGUILD_LOCAL_MODEL` | `qwen35` | Model name passed to LiteLLM for the local decision |
|
||||||
|
| `HYPERGUILD_CLAUDE_MODEL` | `claude-sonnet-4-6` | Model name for the Claude decision |
|
||||||
|
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | At/above this pass-rate, always local |
|
||||||
|
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below this, always Claude. Between CEIL and FLOOR is the sample band. |
|
||||||
|
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill in-memory cache TTL |
|
||||||
|
|
||||||
|
**Rationale:** every value an operator might want to tune is an env var, not a hardcoded constant. Defaults are the recommendations from the kickoff and the per-skill-routing memory; sensible cluster values flow in via the Flux-managed Secret. No config file to manage.
|
||||||
|
|
||||||
|
### C. Decision policy (`internal/routing/policy.go`)
|
||||||
|
|
||||||
|
Pure function, no I/O:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Decision int
|
||||||
|
const (
|
||||||
|
DecideLocal Decision = iota
|
||||||
|
DecideClaude
|
||||||
|
)
|
||||||
|
|
||||||
|
type Policy struct{ Floor, Ceil float64 }
|
||||||
|
|
||||||
|
// Decide returns the routing decision. passRate may be nil when the brain has no data.
|
||||||
|
// requestHash is a deterministic 64-bit hash of the canonical request body — used only
|
||||||
|
// when passRate is in the sample band; same hash → same decision.
|
||||||
|
func (p Policy) Decide(passRate *float64, requestHash uint64) Decision { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules (in order):
|
||||||
|
1. `passRate == nil` → `DecideLocal` (default-to-local)
|
||||||
|
2. `*passRate >= p.Floor` → `DecideLocal`
|
||||||
|
3. `*passRate < p.Ceil` → `DecideClaude`
|
||||||
|
4. Otherwise (sample band) → `requestHash & 1` picks local on `0`, claude on `1`
|
||||||
|
|
||||||
|
**Rationale:** no I/O in the policy means the function is trivially testable (table-driven, no fixtures, no servers). Network calls happen in a wrapping layer that calls `Decide` — same separation as `internal/skills/*/skill.go` keeps prompt strings separate from `Complete` calls. Default-to-local rule is justified in `project_per_skill_routing.md`: the four advertised skills are exactly the skills marked "MCP→local" in that target architecture.
|
||||||
|
|
||||||
|
### D. Pass-rate fetcher (`internal/routing/passrate.go`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Fetcher struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client // 1s timeout
|
||||||
|
Cache *ttlCache // map[string]*float64 with 60s TTL, struct-internal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `GET ${BaseURL}/pass-rate?skill=<skill>&window=7d`. On success, caches the parsed `pass_rate` (which may be `null`) for `HYPERGUILD_PASS_RATE_TTL_SECONDS`. On error, returns `(nil, err)`; the dispatch wrapper treats this as `*passRate == nil` and routes to local (the default-to-local fallback also covers brain-pod-down).
|
||||||
|
|
||||||
|
**Rationale:** GET is correct REST per `2026-05-03-rest-semantics-vs-precedent` (this is a pure read with query params; it shouldn't follow the legacy POST-everywhere precedent). Cache TTL of 60s prevents per-call hammering during a tight Claude Code loop while staying fresh enough that a flapping pass-rate visibly affects routing within a minute. No persistence — restart loses cache, that's fine.
|
||||||
|
|
||||||
|
### E. Dispatch wrapper
|
||||||
|
|
||||||
|
The four skills are constructed with their existing `CompleteFunc` signature (`(ctx, model, system, user) (string, int64, error)`). The routing pod wraps it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Router) Complete(ctx context.Context, skill, model, system, user string) (string, int64, error) {
|
||||||
|
pr, _ := r.fetcher.Get(ctx, skill)
|
||||||
|
decision := r.policy.Decide(pr, hashCanonical(system, user))
|
||||||
|
chosenModel := r.cfg.ClaudeModel
|
||||||
|
if decision == DecideLocal {
|
||||||
|
chosenModel = r.cfg.LocalModel
|
||||||
|
}
|
||||||
|
out, ms, err := r.litellm.Complete(ctx, chosenModel, system, user)
|
||||||
|
r.logDecision(skill, decision, err, ms)
|
||||||
|
if err != nil {
|
||||||
|
// fail open: try Claude once if we routed local; if Claude also fails, return error.
|
||||||
|
if decision == DecideLocal {
|
||||||
|
chosenModel = r.cfg.ClaudeModel
|
||||||
|
out, ms, err = r.litellm.Complete(ctx, chosenModel, system, user)
|
||||||
|
r.logDecision(skill, DecideClaude, err, ms) // second log entry, marked fail if still erroring
|
||||||
|
}
|
||||||
|
return out, ms, err
|
||||||
|
}
|
||||||
|
return out, ms, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The skill packages don't know about routing — they receive a `CompleteFunc` and call it. The wrapper substitutes routing logic at construction time.
|
||||||
|
|
||||||
|
**Rationale:** keeps the skill packages oblivious to mode. Same `internal/skills/review/` works under the supervisor (no routing) and under the routing pod (routed) without any conditional logic in the skill itself. Plan 7's deletion of the supervisor leaves the skills' shape intact for the routing pod.
|
||||||
|
|
||||||
|
### F. Decision logging (`internal/routing/log.go`)
|
||||||
|
|
||||||
|
After every decision, POST a session log entry to `${BRAIN_URL}/write` (the brain pod's existing endpoint, which appends to `brain/sessions/<session>.jsonl`). Entry shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skill": "_routing",
|
||||||
|
"phase": "decide",
|
||||||
|
"final_status": "skip",
|
||||||
|
"message": "<original_skill>: <decision> (pass_rate=<value or 'null'>, model=<chosen>)",
|
||||||
|
"duration_ms": <litellm_round_trip>,
|
||||||
|
"project_root": "<path from request, or 'unknown'>",
|
||||||
|
"timestamp": "<RFC3339>",
|
||||||
|
"session_id": "<from request, or generated>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`final_status: "skip"` keeps these entries out of any real skill's pass-rate aggregation (Plan 5's aggregator counts only `pass`/`fail`). Operators can still query `GET /pass-rate?skill=_routing&window=7d` for routing-failure visibility (when LiteLLM down → `final_status: "fail"` in the second log entry).
|
||||||
|
|
||||||
|
**Rationale:** closes the observability loop without adding a new endpoint or schema. `_routing` namespaces routing entries away from skill names. `skip` is the only honest classification — routing isn't itself a pass/fail event in the skill sense.
|
||||||
|
|
||||||
|
### G. Deployment
|
||||||
|
|
||||||
|
New manifest directory `infra/k3s/apps/routing/` mirroring `infra/k3s/apps/supervisor/`'s shape:
|
||||||
|
|
||||||
|
- `namespace.yaml` — namespace `routing` (peer to `supervisor`)
|
||||||
|
- `deployment.yaml` — single replica, nodeSelector koala, image from gitea registry, `envFrom: secretRef: routing-secrets`
|
||||||
|
- `service.yaml` — ClusterIP on port 3210
|
||||||
|
- `nodeport.yaml` — NodePort 30310 → service 3210
|
||||||
|
- `secrets.enc.yaml` — SOPS-encrypted, contains `LITELLM_API_KEY` and (optionally) `ROUTING_MCP_TOKEN`
|
||||||
|
- `kustomization.yaml` — bundles the above
|
||||||
|
|
||||||
|
The supervisor pod's CI image build pattern (gitea Actions → `gitea.d-ma.be/mathias/supervisor:<sha>`) is replicated for `gitea.d-ma.be/mathias/routing:<sha>`. Flux's existing image-automation will bump the manifest's image tag on each push.
|
||||||
|
|
||||||
|
**Rationale:** copying the supervisor pod's manifest shape (rather than designing from scratch) is the YAGNI move. Flux + image automation already proven on supervisor; same pattern, same operator mental model. Mode 2 setup is now a Flux change, not a one-off `kubectl` ritual.
|
||||||
|
|
||||||
|
### H. Live smoke test
|
||||||
|
|
||||||
|
`task smoke:routing` (in the project Taskfile) does:
|
||||||
|
1. Boot the binary locally with `LITELLM_BASE_URL=http://piguard:4000` and `BRAIN_URL=http://koala:30330`. Bind to a random localhost port (so it doesn't conflict with anything else).
|
||||||
|
2. Send `tools/list` and assert four tool names.
|
||||||
|
3. For each tool, send a minimal valid `tools/call`. Don't assert on response content — assert response shape (no error, has content).
|
||||||
|
4. After all four calls, query `GET http://koala:30330/pass-rate?skill=_routing&window=1h` and assert `total >= 4`.
|
||||||
|
5. Tear down.
|
||||||
|
|
||||||
|
Skipped automatically when LiteLLM is unreachable or when run outside Tailscale (tier 3) — emits a `SKIP` line and exits 0. `task check` does NOT include `task smoke:routing` (CI runner doesn't have Tailscale); operator runs it manually before bumping production.
|
||||||
|
|
||||||
|
**Rationale:** unit tests with `httptest.Server` fakes verify the policy and the dispatch wrapper logic. The smoke test is the only thing that will catch a contract drift between the routing pod's `Complete` calls and the actual LiteLLM API, or a schema drift between `/pass-rate` and what the fetcher expects (per `2026-05-03-fake-tests-vs-real-contract`).
|
||||||
|
|
||||||
|
### I. Mode-template update (`cmd/hyperguild/mode.go`)
|
||||||
|
|
||||||
|
`modeClientLocal` is amended:
|
||||||
|
- The `routing` entry's `url` stays at `http://koala:30310/mcp`.
|
||||||
|
- A new key `headers` is added with `{"X-Hyperguild-Mode": "client-local"}`.
|
||||||
|
- The placeholder `_routing_pending` field is **removed**, since the routing pod now exists.
|
||||||
|
|
||||||
|
Tests in `cmd/hyperguild/mode_test.go` are updated to assert the new structure. README in `cmd/hyperguild/README.md` updated to drop the "not deployed yet" note.
|
||||||
|
|
||||||
|
**Rationale:** Plan 4 deliberately scaffolded the placeholder for Plan 6 to fill in. This is the fill-in. Removing `_routing_pending` is the implicit cleanup the kickoff anticipates — making it explicit in the spec avoids a Plan-completeness gap (per `2026-05-03-implicit-cleanup-third-category`).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Empty pass-rate window in the first weeks.** Plans 3–5 merged on 2026-05-03; usage data has not accumulated. With default-to-local active for all four routed skills, the first weeks of Mode 2 = "everything goes local." If local quality is rough on `code_review` or `debug`, the operator's first impression of Mode 2 is bad, and confidence in Plan 6 erodes before data lands. **Mitigation:** the FLOOR / CEIL are env-tunable. If local quality is unworkable in the first week, set `HYPERGUILD_ROUTE_LOCAL_FLOOR=2.0` (impossible threshold) and the pod becomes default-to-Claude with no code change. This is a deliberate kill switch for the early window.
|
||||||
|
|
||||||
|
- **LiteLLM-as-single-dependency.** The routing pod has exactly one upstream LLM provider: `piguard:4000`. If LiteLLM is misconfigured (wrong model name routed to wrong provider, expired Anthropic key in LiteLLM's config), every routing-pod call returns garbage. **Mitigation:** the smoke test catches gross misconfig before deploy; once deployed, LiteLLM's own `/health` endpoint is the canary (the pod doesn't probe it — operator monitors LiteLLM separately). If a deeper failure mode emerges, add a routing-pod liveness probe in a follow-up.
|
||||||
|
|
||||||
|
- **Skill-schema drift.** The routing pod's `tools/list` is asserted byte-identical to the supervisor's via snapshot test. If someone evolves the supervisor's schemas between Plan 6 merge and Plan 7 (a long window), the snapshot drifts. **Mitigation:** the spec documents that Plan 6 freezes the schemas; supervisor edits to skill schemas are out of scope until Plan 7 deletes the supervisor. This is a soft constraint enforced by the spec, not by code. If the supervisor genuinely needs a schema change before Plan 7, that's a separate plan.
|
||||||
|
|
||||||
|
- **Flux drift on `kubectl rollout restart`.** Demonstrated during the bearer-auth rollout earlier today: Flux server-side-applies the deployment every 30s and strips the `kubectl.kubernetes.io/restartedAt` annotation, which deletes the new ReplicaSet's pod. **Mitigation:** the Plan 6 implementer prompt and the README note that `kubectl delete pod -l app=routing` is the correct way to force a restart on Flux-managed deployments — the existing ReplicaSet recreates without an annotation Flux can revert. (This finding is worth a brain entry; capture in retrospective.)
|
||||||
|
|
||||||
|
- **Mode header not forwarded by Claude Code.** Plan 6 assumes Claude Code propagates `headers` from `.mcp.json`. The bearer-auth rollout proved this works for `Authorization`. The same path should work for `X-Hyperguild-Mode`. **Mitigation:** the pod treats absent header as `client-local` (the only mode that registers the pod). If forwarding silently breaks, behavior is identical — header is forward-compat only.
|
||||||
|
|
||||||
|
- **Sample-band hash collision producing skewed routing.** Hash inputs are `(system, user)` strings. If skill prompts produce highly similar bodies (debug bug A vs debug bug B with similar wording), low-bit hash distribution might cluster on one side. **Mitigation:** at the volumes Plan 6 expects (single operator, ~10s of routed calls/hour at peak), bias is statistically invisible. If volume ever rises, swap `hash & 1` for a stronger split. Not the first failure mode worth pre-engineering.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- Spec for Plan 5 (consumer of `/pass-rate`): `docs/superpowers/specs/2026-05-03-pass-rate-logging-design.md`
|
||||||
|
- Spec for Plan 4 (which scaffolded the `:30310` placeholder): `docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md`
|
||||||
|
- Auto-memory entries `project_three_modes`, `project_skill_migration_plans`, `project_per_skill_routing`, `feedback_per_task_verification`, `feedback_sudo`
|
||||||
|
- Brain entries `2026-05-03-rest-semantics-vs-precedent`, `2026-05-03-aggregator-normalization-backwards-compat`, `2026-05-03-fake-tests-vs-real-contract`, `2026-05-03-implicit-cleanup-third-category`, `2026-05-03-code-reviewer-output-as-candidates`, `2026-05-03-done-with-concerns-vs-blocked`, `2026-05-03-verification-depth-formula`, `2026-05-03-plan-canonical-dispatch-ephemeral`
|
||||||
17
go.mod
17
go.mod
@@ -2,10 +2,23 @@ module github.com/mathiasbq/supervisor
|
|||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require github.com/stretchr/testify v1.11.1
|
require (
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
27
go.sum
27
go.sum
@@ -1,10 +1,37 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -6,17 +6,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// redactDSN parses a Postgres URL and replaces its password with `***`
|
||||||
|
// for safe inclusion in logs. Falls back to a non-leaking placeholder
|
||||||
|
// if parsing fails — we never log a raw DSN.
|
||||||
|
func redactDSN(dsn string) string {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err != nil || u.User == nil {
|
||||||
|
return "postgres://***"
|
||||||
|
}
|
||||||
|
return u.Redacted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// vectorAdapter bridges *vectorstore.PGStore (returns []vectorstore.Hit)
|
||||||
|
// to the search.VectorSearcher interface (which uses []search.VectorHit).
|
||||||
|
// Kept here, not in either package, so neither has to import the other.
|
||||||
|
type vectorAdapter struct{ s *vectorstore.PGStore }
|
||||||
|
|
||||||
|
func (a vectorAdapter) Search(ctx context.Context, q []float32, limit int) ([]search.VectorHit, error) {
|
||||||
|
hits, err := a.s.Search(ctx, q, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]search.VectorHit, len(hits))
|
||||||
|
for i, h := range hits {
|
||||||
|
out[i] = search.VectorHit{Path: h.Path, Distance: h.Distance}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func envOr(key, fallback string) string {
|
func envOr(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -55,7 +91,67 @@ func main() {
|
|||||||
|
|
||||||
h := api.NewHandler(brainDir, logger, pipelineCfg)
|
h := api.NewHandler(brainDir, logger, pipelineCfg)
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete)
|
var answerComplete pipeline.CompleteFunc
|
||||||
|
if primaryURL := os.Getenv("BRAIN_LLM_PRIMARY_URL"); primaryURL != "" {
|
||||||
|
primaryModel := envOr("BRAIN_LLM_PRIMARY_MODEL", "gemma4:31b")
|
||||||
|
primaryKey := os.Getenv("BERGET_API_KEY")
|
||||||
|
timeoutMS := envInt("BRAIN_LLM_TIMEOUT_MS", 10000)
|
||||||
|
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||||
|
|
||||||
|
primary := llm.New(primaryURL, primaryKey, primaryModel, timeout)
|
||||||
|
router := &llm.Router{Primary: primary}
|
||||||
|
|
||||||
|
if fallbackURL := os.Getenv("BRAIN_LLM_FALLBACK_URL"); fallbackURL != "" {
|
||||||
|
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||||
|
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||||
|
}
|
||||||
|
answerComplete = router.Complete
|
||||||
|
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete, answerComplete)
|
||||||
|
if rerankURL := os.Getenv("BRAIN_RERANKER_URL"); rerankURL != "" {
|
||||||
|
rerankModel := envOr("BRAIN_RERANKER_MODEL", "dengcao/Qwen3-Reranker-0.6B:F16")
|
||||||
|
mcpSrv = mcpSrv.WithReranker(reranker.New(rerankURL, rerankModel))
|
||||||
|
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
|
var vectorStore *vectorstore.PGStore
|
||||||
|
pgDSN := os.Getenv("BRAIN_PG_DSN")
|
||||||
|
embedURL := os.Getenv("BRAIN_EMBED_URL")
|
||||||
|
switch {
|
||||||
|
case pgDSN != "" && embedURL != "":
|
||||||
|
embedModel := envOr("BRAIN_EMBED_MODEL", "nomic-embed-text:latest")
|
||||||
|
store, err := vectorstore.New(context.Background(), pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("vector store init", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := store.Init(context.Background()); err != nil {
|
||||||
|
logger.Error("vector store migrate", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
vectorStore = store
|
||||||
|
embedder := embed.New(embedURL, embedModel)
|
||||||
|
mcpSrv = mcpSrv.WithHybridRetrieval(vectorAdapter{s: store}, embedder)
|
||||||
|
h.WithEmbedSync(store, embedder)
|
||||||
|
logger.Info("brain hybrid retrieval enabled",
|
||||||
|
"pg", redactDSN(pgDSN),
|
||||||
|
"embed_url", embedURL, "embed_model", embedModel)
|
||||||
|
case pgDSN == "" && embedURL == "":
|
||||||
|
// disabled — fine
|
||||||
|
default:
|
||||||
|
logger.Error("BRAIN_PG_DSN and BRAIN_EMBED_URL must be set together")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpToken := os.Getenv("BRAIN_MCP_TOKEN")
|
||||||
|
if mcpToken == "" {
|
||||||
|
logger.Error("BRAIN_MCP_TOKEN not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
@@ -65,15 +161,79 @@ func main() {
|
|||||||
Pipeline: pipelineCfg,
|
Pipeline: pipelineCfg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if vectorStore != nil {
|
||||||
|
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||||
|
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||||
|
embed.New(os.Getenv("BRAIN_EMBED_URL"),
|
||||||
|
envOr("BRAIN_EMBED_MODEL", "nomic-embed-text:latest")),
|
||||||
|
time.Duration(embedSyncInterval)*time.Second)
|
||||||
|
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /query", h.Query)
|
mux.HandleFunc("POST /query", h.Query)
|
||||||
mux.HandleFunc("POST /write", h.Write)
|
mux.HandleFunc("POST /write", h.Write)
|
||||||
|
mux.HandleFunc("POST /index", h.Index)
|
||||||
mux.HandleFunc("POST /ingest", h.Ingest)
|
mux.HandleFunc("POST /ingest", h.Ingest)
|
||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
mux.Handle("POST /mcp", mcpSrv)
|
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
|
var jwtValidator *auth.Validator
|
||||||
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
|
audience := os.Getenv("MCP_AUDIENCE")
|
||||||
|
v, err := auth.NewValidator(dexURL, audience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
jwtValidator = v
|
||||||
|
logger.Info("jwt auth enabled", "issuer", dexURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
|
// configured. Static-Bearer-only deployments leave this empty so
|
||||||
|
// clients never see an OAuth challenge.
|
||||||
|
var resourceMetadataURL string
|
||||||
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
|
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
|
if resourceURL != "" {
|
||||||
|
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv))
|
||||||
|
|
||||||
|
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||||
|
// integration UI, which has no static-Bearer field. Setting both
|
||||||
|
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
||||||
|
// setting only one is misconfiguration → fail fast.
|
||||||
|
oauthID := os.Getenv("OAUTH_CLIENT_ID")
|
||||||
|
oauthSecret := os.Getenv("OAUTH_CLIENT_SECRET")
|
||||||
|
switch {
|
||||||
|
case oauthID != "" && oauthSecret != "":
|
||||||
|
issuer := os.Getenv("MCP_RESOURCE_URL")
|
||||||
|
if issuer == "" {
|
||||||
|
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
|
||||||
|
oauth.MetadataHandler(issuer))
|
||||||
|
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
|
||||||
|
ClientID: oauthID,
|
||||||
|
ClientSecret: oauthSecret,
|
||||||
|
AccessToken: mcpToken,
|
||||||
|
}))
|
||||||
|
logger.Info("oauth client_credentials enabled", "issuer", strings.TrimRight(issuer, "/"))
|
||||||
|
case oauthID == "" && oauthSecret == "":
|
||||||
|
// disabled — that's fine
|
||||||
|
default:
|
||||||
|
logger.Error("OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET must be set together")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
addr := ":" + port
|
addr := ":" + port
|
||||||
watchIntervalLog := "disabled"
|
watchIntervalLog := "disabled"
|
||||||
|
|||||||
@@ -2,10 +2,29 @@ module github.com/mathiasbq/hyperguild/ingestion
|
|||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require github.com/stretchr/testify v1.11.1
|
require (
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler serves the ingestion HTTP API.
|
// Handler serves the ingestion HTTP API.
|
||||||
@@ -21,6 +23,8 @@ type Handler struct {
|
|||||||
brainDir string
|
brainDir string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
pipeline pipeline.Config
|
pipeline pipeline.Config
|
||||||
|
embedStore vectorstore.Store
|
||||||
|
embedClient vectorstore.Embedder
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
|
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
|
||||||
@@ -31,9 +35,19 @@ func NewHandler(brainDir string, logger *slog.Logger, pipelineCfg pipeline.Confi
|
|||||||
return &Handler{brainDir: brainDir, logger: logger, pipeline: pipelineCfg}
|
return &Handler{brainDir: brainDir, logger: logger, pipeline: pipelineCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmbedSync wires the optional vector store + embedder used by the
|
||||||
|
// POST /backfill-embeddings endpoint. Calling with either nil is a no-op.
|
||||||
|
func (h *Handler) WithEmbedSync(store vectorstore.Store, embedder vectorstore.Embedder) *Handler {
|
||||||
|
h.embedStore = store
|
||||||
|
h.embedClient = embedder
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
type queryRequest struct {
|
type queryRequest struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type writeRequest struct {
|
type writeRequest struct {
|
||||||
@@ -41,6 +55,8 @@ type writeRequest struct {
|
|||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingestRequest struct {
|
type ingestRequest struct {
|
||||||
@@ -75,7 +91,12 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Limit = 5
|
req.Limit = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := search.Query(h.brainDir, req.Query, req.Limit)
|
results, err := search.Query(h.brainDir, search.QueryOptions{
|
||||||
|
Query: req.Query,
|
||||||
|
Limit: req.Limit,
|
||||||
|
Wing: req.Wing,
|
||||||
|
Hall: req.Hall,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("query failed", "err", err)
|
h.logger.Error("query failed", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "search error")
|
writeError(w, http.StatusInternalServerError, "search error")
|
||||||
@@ -85,13 +106,78 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]any{"results": results})
|
writeJSON(w, map[string]any{"results": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteNote writes a markdown file to brainDir/knowledge/<filename>, optionally
|
// WriteNoteOptions configures how a brain note is written.
|
||||||
// prefixed with YAML frontmatter built from typ and domain. Returns the path
|
//
|
||||||
|
// When both Wing and Hall are non-empty, the note routes into the
|
||||||
|
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||||
|
// wing/hall/created_at injected into its YAML frontmatter.
|
||||||
|
//
|
||||||
|
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||||
|
// with optional type/domain frontmatter (legacy behaviour).
|
||||||
|
type WriteNoteOptions struct {
|
||||||
|
Content string
|
||||||
|
Filename string
|
||||||
|
Type string
|
||||||
|
Domain string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteNote writes a markdown note into the brain. Returns the path
|
||||||
// relative to brainDir (forward-slashed). Filename traversal is rejected.
|
// relative to brainDir (forward-slashed). Filename traversal is rejected.
|
||||||
func WriteNote(brainDir, content, filename, typ, domain string) (string, error) {
|
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
if content == "" {
|
if opts.Content == "" {
|
||||||
return "", fmt.Errorf("content is required")
|
return "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Wing != "" && opts.Hall != "" {
|
||||||
|
return writeHallNote(brainDir, opts)
|
||||||
|
}
|
||||||
|
if opts.Wing != "" || opts.Hall != "" {
|
||||||
|
return "", fmt.Errorf("wing and hall must be set together")
|
||||||
|
}
|
||||||
|
return writeLegacyNote(brainDir, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
||||||
|
// wing/hall/created_at frontmatter.
|
||||||
|
func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
|
slug := opts.Filename
|
||||||
|
if slug == "" {
|
||||||
|
slug = time.Now().UTC().Format("2006-01-02-150405") + "-auto"
|
||||||
|
}
|
||||||
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fm strings.Builder
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||||
|
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||||
|
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if opts.Type != "" {
|
||||||
|
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||||
|
}
|
||||||
|
if opts.Domain != "" {
|
||||||
|
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||||
|
}
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return filepath.ToSlash(rel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||||
|
// callers that have not adopted the wing/hall taxonomy.
|
||||||
|
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
|
filename := opts.Filename
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
}
|
}
|
||||||
@@ -101,26 +187,24 @@ func WriteNote(brainDir, content, filename, typ, domain string) (string, error)
|
|||||||
return "", fmt.Errorf("create raw dir: %w", err)
|
return "", fmt.Errorf("create raw dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalContent := content
|
finalContent := opts.Content
|
||||||
if typ != "" || domain != "" {
|
if opts.Type != "" || opts.Domain != "" {
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
if typ != "" {
|
if opts.Type != "" {
|
||||||
fmt.Fprintf(&fm, "type: %s\n", typ)
|
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||||
}
|
}
|
||||||
if domain != "" {
|
if opts.Domain != "" {
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", domain)
|
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
finalContent = fm.String() + content
|
finalContent = fm.String() + opts.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject path separators outright; any non-flat filename is misuse.
|
|
||||||
if strings.ContainsAny(filename, `/\`) {
|
if strings.ContainsAny(filename, `/\`) {
|
||||||
return "", fmt.Errorf("invalid filename")
|
return "", fmt.Errorf("invalid filename")
|
||||||
}
|
}
|
||||||
base := filepath.Base(filename)
|
base := filepath.Base(filename)
|
||||||
// After Base, "." and ".." remain. Reject those before adding .md.
|
|
||||||
if base == "." || base == ".." || base == "" {
|
if base == "." || base == ".." || base == "" {
|
||||||
return "", fmt.Errorf("invalid filename")
|
return "", fmt.Errorf("invalid filename")
|
||||||
}
|
}
|
||||||
@@ -143,15 +227,77 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
relPath, err := WriteNote(h.brainDir, req.Content, req.Filename, req.Type, req.Domain)
|
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Wing != "" && req.Hall != "" {
|
||||||
|
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||||
|
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"path": relPath})
|
writeJSON(w, map[string]string{"path": relPath})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackfillEmbeddings handles POST /backfill-embeddings — synchronously
|
||||||
|
// embeds every note under brain/wiki/ that's not yet in the vector
|
||||||
|
// store, and deletes rows for files no longer on disk.
|
||||||
|
func (h *Handler) BackfillEmbeddings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.embedStore == nil || h.embedClient == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable,
|
||||||
|
"embeddings not configured (set BRAIN_PG_DSN and BRAIN_EMBED_URL)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := vectorstore.Sync(r.Context(), h.brainDir, h.embedStore, h.embedClient)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("backfill failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "backfill error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errStrs := make([]string, 0, len(res.Errors))
|
||||||
|
for _, e := range res.Errors {
|
||||||
|
errStrs = append(errStrs, e.Error())
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"added": res.Added,
|
||||||
|
"deleted": res.Deleted,
|
||||||
|
"errors": errStrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type indexRequest struct {
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index handles POST /index — regenerate the _index.md MOC for one wing
|
||||||
|
// (when "wing" is set) or for every wing (when omitted).
|
||||||
|
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req indexRequest
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Wing == "" {
|
||||||
|
if err := brain.BuildAllWingIndexes(h.brainDir); err != nil {
|
||||||
|
h.logger.Error("index all failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "index error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"status": "ok", "scope": "all"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||||
|
h.logger.Error("index failed", "wing", req.Wing, "err", err)
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"status": "ok", "scope": req.Wing})
|
||||||
|
}
|
||||||
|
|
||||||
// Ingest handles POST /ingest — run the pipeline on provided content.
|
// Ingest handles POST /ingest — run the pipeline on provided content.
|
||||||
func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
||||||
var req ingestRequest
|
var req ingestRequest
|
||||||
|
|||||||
140
ingestion/internal/api/passrate.go
Normal file
140
ingestion/internal/api/passrate.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type passRateResponse struct {
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
Pass int `json:"pass"`
|
||||||
|
Fail int `json:"fail"`
|
||||||
|
Skip int `json:"skip"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
PassRate *float64 `json:"pass_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassRate handles GET /pass-rate?skill=X&window=Y.
|
||||||
|
// Walks brainDir/sessions/*.jsonl, filters by skill name and timestamp,
|
||||||
|
// returns aggregated counts and pass rate.
|
||||||
|
func (h *Handler) PassRate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
skill := r.URL.Query().Get("skill")
|
||||||
|
if skill == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "skill is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowStr := r.URL.Query().Get("window")
|
||||||
|
if windowStr == "" {
|
||||||
|
windowStr = "7d"
|
||||||
|
}
|
||||||
|
window, err := parseWindow(windowStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid window: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-window)
|
||||||
|
pass, fail, skip := 0, 0, 0
|
||||||
|
|
||||||
|
sessionsDir := filepath.Join(h.brainDir, "sessions")
|
||||||
|
entries, err := os.ReadDir(sessionsDir)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
writeError(w, http.StatusInternalServerError, "read sessions dir: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue // skip unreadable files
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
FinalStatus string `json:"final_status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
continue // malformed — skip
|
||||||
|
}
|
||||||
|
if rec.Skill != skill {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339, rec.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ts.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch normalizeStatus(rec.FinalStatus) {
|
||||||
|
case "pass":
|
||||||
|
pass++
|
||||||
|
case "fail":
|
||||||
|
fail++
|
||||||
|
case "skip":
|
||||||
|
skip++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := pass + fail + skip
|
||||||
|
resp := passRateResponse{
|
||||||
|
Skill: skill,
|
||||||
|
Window: windowStr,
|
||||||
|
Pass: pass,
|
||||||
|
Fail: fail,
|
||||||
|
Skip: skip,
|
||||||
|
Total: total,
|
||||||
|
}
|
||||||
|
if pass+fail > 0 {
|
||||||
|
rate := float64(pass) / float64(pass+fail)
|
||||||
|
resp.PassRate = &rate
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeStatus maps both new (pass/fail/skip) and legacy (ok/error/skipped)
|
||||||
|
// vocabularies to the canonical pass/fail/skip set. Unknown values are treated
|
||||||
|
// as skip for safety.
|
||||||
|
func normalizeStatus(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "pass", "ok":
|
||||||
|
return "pass"
|
||||||
|
case "fail", "error":
|
||||||
|
return "fail"
|
||||||
|
case "skip", "skipped":
|
||||||
|
return "skip"
|
||||||
|
default:
|
||||||
|
return "skip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWindow accepts Go-style durations plus "Nd" for days.
|
||||||
|
func parseWindow(s string) (time.Duration, error) {
|
||||||
|
if strings.HasSuffix(s, "d") {
|
||||||
|
// Replace "d" with "h" * 24
|
||||||
|
days := strings.TrimSuffix(s, "d")
|
||||||
|
d, err := time.ParseDuration(days + "h")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return d * 24, nil
|
||||||
|
}
|
||||||
|
return time.ParseDuration(s)
|
||||||
|
}
|
||||||
172
ingestion/internal/api/passrate_test.go
Normal file
172
ingestion/internal/api/passrate_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeSession writes one or more JSONL entries to <dir>/sessions/<sessionID>.jsonl.
|
||||||
|
// The handler scans <brainDir>/sessions/, so test fixtures must mirror that layout.
|
||||||
|
func writeSession(t *testing.T, dir, sessionID string, entries ...string) {
|
||||||
|
t.Helper()
|
||||||
|
sessionsDir := filepath.Join(dir, "sessions")
|
||||||
|
require.NoError(t, os.MkdirAll(sessionsDir, 0o755))
|
||||||
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||||
|
body := ""
|
||||||
|
for _, e := range entries {
|
||||||
|
body += e + "\n"
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_HappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
writeSession(t, dir, "s1",
|
||||||
|
`{"timestamp":"`+recent+`","skill":"tdd","phase":"red","final_status":"pass"}`,
|
||||||
|
`{"timestamp":"`+recent+`","skill":"tdd","phase":"green","final_status":"pass"}`,
|
||||||
|
`{"timestamp":"`+recent+`","skill":"tdd","phase":"refactor","final_status":"fail"}`,
|
||||||
|
)
|
||||||
|
writeSession(t, dir, "s2",
|
||||||
|
`{"timestamp":"`+recent+`","skill":"code-review","phase":"review","final_status":"pass"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
assert.Equal(t, "tdd", got.Skill)
|
||||||
|
assert.Equal(t, "24h", got.Window)
|
||||||
|
assert.Equal(t, 2, got.Pass)
|
||||||
|
assert.Equal(t, 1, got.Fail)
|
||||||
|
assert.Equal(t, 0, got.Skip)
|
||||||
|
assert.Equal(t, 3, got.Total)
|
||||||
|
require.NotNil(t, got.PassRate)
|
||||||
|
assert.InDelta(t, 0.6667, *got.PassRate, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_LegacyVocabulary(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
writeSession(t, dir, "s1",
|
||||||
|
`{"timestamp":"`+now+`","skill":"tdd","final_status":"ok"}`,
|
||||||
|
`{"timestamp":"`+now+`","skill":"tdd","final_status":"error"}`,
|
||||||
|
`{"timestamp":"`+now+`","skill":"tdd","final_status":"skipped"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
|
||||||
|
assert.Equal(t, 1, got.Pass, "ok→pass")
|
||||||
|
assert.Equal(t, 1, got.Fail, "error→fail")
|
||||||
|
assert.Equal(t, 1, got.Skip, "skipped→skip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_OutsideWindow_Excluded(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
recent := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||||
|
writeSession(t, dir, "s1",
|
||||||
|
`{"timestamp":"`+old+`","skill":"tdd","final_status":"pass"}`,
|
||||||
|
`{"timestamp":"`+recent+`","skill":"tdd","final_status":"pass"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
|
||||||
|
assert.Equal(t, 1, got.Pass)
|
||||||
|
assert.Equal(t, 1, got.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_NoData_ReturnsZerosAndNullRate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
|
||||||
|
assert.Equal(t, 0, got.Pass)
|
||||||
|
assert.Equal(t, 0, got.Fail)
|
||||||
|
assert.Equal(t, 0, got.Skip)
|
||||||
|
assert.Equal(t, 0, got.Total)
|
||||||
|
assert.Nil(t, got.PassRate, "pass_rate must be null when pass+fail == 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_DefaultsTo7d(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
writeSession(t, dir, "s1", `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`)
|
||||||
|
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd", nil) // no window
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
|
||||||
|
assert.Equal(t, "7d", got.Window)
|
||||||
|
assert.Equal(t, 1, got.Pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_MissingSkill_ReturnsBadRequest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_BadWindow_ReturnsBadRequest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=foo", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRate_MalformedLine_Skipped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
writeSession(t, dir, "s1",
|
||||||
|
`{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
|
||||||
|
`not valid json`,
|
||||||
|
`{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{brainDir: dir}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.PassRate(w, req)
|
||||||
|
|
||||||
|
var got passRateResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
|
||||||
|
assert.Equal(t, 2, got.Pass, "the malformed line is silently skipped")
|
||||||
|
}
|
||||||
84
ingestion/internal/auth/jwt.go
Normal file
84
ingestion/internal/auth/jwt.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
||||||
|
// Audience is optional; leave empty to skip audience validation.
|
||||||
|
type Validator struct {
|
||||||
|
issuer string
|
||||||
|
audience string
|
||||||
|
jwksURI string
|
||||||
|
cache *jwk.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator fetches the OIDC discovery document from issuerURL, extracts
|
||||||
|
// jwks_uri, seeds the JWKS cache, and returns a ready Validator.
|
||||||
|
// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction.
|
||||||
|
func NewValidator(issuerURL, audience string) (*Validator, error) {
|
||||||
|
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
if doc.JWKSURI == "" {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cache := jwk.NewCache(ctx)
|
||||||
|
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
||||||
|
return nil, fmt.Errorf("register jwks cache: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||||
|
return nil, fmt.Errorf("initial jwks fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Validator{
|
||||||
|
issuer: issuerURL,
|
||||||
|
audience: audience,
|
||||||
|
jwksURI: doc.JWKSURI,
|
||||||
|
cache: cache,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parses and validates rawToken. Returns the subject claim on success.
|
||||||
|
func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||||
|
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get jwks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []jwt.ParseOption{
|
||||||
|
jwt.WithKeySet(keySet),
|
||||||
|
jwt.WithValidate(true),
|
||||||
|
jwt.WithIssuer(v.issuer),
|
||||||
|
}
|
||||||
|
if v.audience != "" {
|
||||||
|
opts = append(opts, jwt.WithAudience(v.audience))
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := jwt.ParseString(rawToken, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("validate jwt: %w", err)
|
||||||
|
}
|
||||||
|
return tok.Subject(), nil
|
||||||
|
}
|
||||||
169
ingestion/internal/auth/jwt_test.go
Normal file
169
ingestion/internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testKeys struct {
|
||||||
|
priv jwk.Key
|
||||||
|
pub jwk.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRSAKeys(t *testing.T) testKeys {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
priv, err := jwk.FromRaw(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, priv.Set(jwk.KeyIDKey, "test-kid"))
|
||||||
|
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||||
|
|
||||||
|
pub, err := jwk.PublicKeyOf(priv)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return testKeys{priv: priv, pub: pub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
set := jwk.NewSet()
|
||||||
|
require.NoError(t, set.AddKey(keys.pub))
|
||||||
|
jwksBytes, err := json.Marshal(set)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var srv *httptest.Server
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"jwks_uri": srv.URL + "/jwks",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(jwksBytes)
|
||||||
|
})
|
||||||
|
srv = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
b := jwt.NewBuilder().
|
||||||
|
Issuer(issuer).
|
||||||
|
Subject(subject).
|
||||||
|
Expiration(exp)
|
||||||
|
if audience != "" {
|
||||||
|
b = b.Audience([]string{audience})
|
||||||
|
}
|
||||||
|
tok, err := b.Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidator(t *testing.T) {
|
||||||
|
keys := generateRSAKeys(t)
|
||||||
|
srv := mockOIDCServer(t, keys)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v, err := auth.NewValidator(srv.URL, "brain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantSub string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid jwt",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantSub: "test-user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired jwt",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong issuer",
|
||||||
|
token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong audience",
|
||||||
|
token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered token",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a jwt",
|
||||||
|
token: "not-a-jwt",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
sub, err := v.Validate(ctx, tc.token)
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Empty(t, sub)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantSub, sub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidator_NoAudience(t *testing.T) {
|
||||||
|
keys := generateRSAKeys(t)
|
||||||
|
srv := mockOIDCServer(t, keys)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v, err := auth.NewValidator(srv.URL, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Token without audience passes when audience validation is disabled.
|
||||||
|
tok, err := jwt.NewBuilder().
|
||||||
|
Issuer(srv.URL).
|
||||||
|
Subject("sub").
|
||||||
|
Expiration(time.Now().Add(time.Hour)).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sub, err := v.Validate(ctx, string(signed))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "sub", sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidator_BadDiscoveryURL(t *testing.T) {
|
||||||
|
_, err := auth.NewValidator("http://127.0.0.1:1", "brain")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
23
ingestion/internal/auth/protected_resource.go
Normal file
23
ingestion/internal/auth/protected_resource.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource metadata
|
||||||
|
// handler. Mount at GET /.well-known/oauth-protected-resource (no auth required).
|
||||||
|
func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc {
|
||||||
|
type metadata struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
AuthorizationServers []string `json:"authorization_servers"`
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(metadata{
|
||||||
|
Resource: resourceURL,
|
||||||
|
AuthorizationServers: []string{issuerURL},
|
||||||
|
})
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ingestion/internal/auth/protected_resource_test.go
Normal file
28
ingestion/internal/auth/protected_resource_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProtectedResourceHandler(t *testing.T) {
|
||||||
|
h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"])
|
||||||
|
servers := body["authorization_servers"].([]any)
|
||||||
|
assert.Equal(t, "https://auth.d-ma.be", servers[0])
|
||||||
|
}
|
||||||
161
ingestion/internal/brain/index.go
Normal file
161
ingestion/internal/brain/index.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noteEntry is one row in a Wing _index.md.
|
||||||
|
type noteEntry struct {
|
||||||
|
Hall string
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildWingIndex regenerates brain/wiki/<wing>/_index.md as a Map of
|
||||||
|
// Content listing every note in that wing with its Hall and creation
|
||||||
|
// date. Returns nil if the wing directory does not exist.
|
||||||
|
func BuildWingIndex(brainDir, wing string) error {
|
||||||
|
w := Sanitise(wing)
|
||||||
|
if w == "" {
|
||||||
|
return fmt.Errorf("invalid wing %q", wing)
|
||||||
|
}
|
||||||
|
wingDir := filepath.Join(brainDir, "wiki", w)
|
||||||
|
if _, err := os.Stat(wingDir); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("stat wing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := collectWingEntries(wingDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
if entries[i].Hall != entries[j].Hall {
|
||||||
|
return entries[i].Hall < entries[j].Hall
|
||||||
|
}
|
||||||
|
return entries[i].Slug < entries[j].Slug
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# %s\n\n", w)
|
||||||
|
b.WriteString("| Hall | Note | Created |\n")
|
||||||
|
b.WriteString("|------|------|---------|\n")
|
||||||
|
for _, e := range entries {
|
||||||
|
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(wingDir, "_index.md")
|
||||||
|
return os.WriteFile(dest, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
||||||
|
func BuildAllWingIndexes(brainDir string) error {
|
||||||
|
wikiDir := filepath.Join(brainDir, "wiki")
|
||||||
|
ents, err := os.ReadDir(wikiDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read wiki: %w", err)
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := BuildWingIndex(brainDir, e.Name()); err != nil {
|
||||||
|
return fmt.Errorf("index %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectWingEntries(wingDir string) ([]noteEntry, error) {
|
||||||
|
var out []noteEntry
|
||||||
|
ents, err := os.ReadDir(wingDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read wing: %w", err)
|
||||||
|
}
|
||||||
|
for _, hallEnt := range ents {
|
||||||
|
if !hallEnt.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hall := hallEnt.Name()
|
||||||
|
if !IsValidHall(hall) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hallDir := filepath.Join(wingDir, hall)
|
||||||
|
notes, err := os.ReadDir(hallDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read hall %s: %w", hall, err)
|
||||||
|
}
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.IsDir() || !strings.HasSuffix(n.Name(), ".md") || n.Name() == "_index.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(n.Name(), ".md")
|
||||||
|
full := filepath.Join(hallDir, n.Name())
|
||||||
|
title, created := readTitleAndCreated(full, slug)
|
||||||
|
out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTitleAndCreated reads YAML frontmatter for title + created_at; falls
|
||||||
|
// back to slug and file mtime when absent.
|
||||||
|
func readTitleAndCreated(path, slug string) (string, string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return slug, ""
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
title, created := "", ""
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
inFrontmatter := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !inFrontmatter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "title":
|
||||||
|
title = v
|
||||||
|
case "created_at":
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
created = t.UTC().Format("2006-01-02")
|
||||||
|
} else {
|
||||||
|
created = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = strings.ReplaceAll(slug, "-", " ")
|
||||||
|
}
|
||||||
|
if created == "" {
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
created = info.ModTime().UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title, created
|
||||||
|
}
|
||||||
85
ingestion/internal/brain/index_test.go
Normal file
85
ingestion/internal/brain/index_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildWingIndex(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
{"wiki/jepa-fx/decisions/val-vol.md", "---\ntitle: Val Vol R2\ncreated_at: 2026-05-06T10:00:00Z\n---\nbody\n"},
|
||||||
|
{"wiki/jepa-fx/facts/architecture.md", "---\ntitle: Architecture\ncreated_at: 2026-05-04T10:00:00Z\n---\nbody\n"},
|
||||||
|
{"wiki/jepa-fx/sources/paper.md", "---\n---\nbody\n"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, brain.BuildWingIndex(dir, "jepa-fx"))
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki", "jepa-fx", "_index.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "# jepa-fx")
|
||||||
|
assert.Contains(t, s, "| Hall | Note | Created |")
|
||||||
|
assert.Contains(t, s, "| decisions | [Val Vol R2](decisions/val-vol.md) | 2026-05-06 |")
|
||||||
|
assert.Contains(t, s, "| facts | [Architecture](facts/architecture.md) | 2026-05-04 |")
|
||||||
|
assert.Contains(t, s, "| sources | [paper](sources/paper.md) |")
|
||||||
|
// Halls sorted alphabetically.
|
||||||
|
assert.Less(t, indexOf(s, "decisions"), indexOf(s, "facts"))
|
||||||
|
assert.Less(t, indexOf(s, "facts"), indexOf(s, "sources"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWingIndex_SkipsInvalidHalls(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wingDir := filepath.Join(dir, "wiki", "jepa-fx")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(wingDir, "garbage"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(wingDir, "garbage", "x.md"), []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(wingDir, "facts"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(wingDir, "facts", "y.md"), []byte("y"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, brain.BuildWingIndex(dir, "jepa-fx"))
|
||||||
|
got, err := os.ReadFile(filepath.Join(wingDir, "_index.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "facts")
|
||||||
|
assert.NotContains(t, s, "garbage")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllWingIndexes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
{"wiki/a/facts/x.md", "x"},
|
||||||
|
{"wiki/b/facts/y.md", "y"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, brain.BuildAllWingIndexes(dir))
|
||||||
|
_, err := os.Stat(filepath.Join(dir, "wiki", "a", "_index.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki", "b", "_index.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWingIndex_NoWingDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, brain.BuildWingIndex(dir, "ghost"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
70
ingestion/internal/brain/path.go
Normal file
70
ingestion/internal/brain/path.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Package brain provides the wing/hall path taxonomy used by the brain
|
||||||
|
// wiki layout. A note's canonical location is
|
||||||
|
// brain/wiki/<wing>/<hall>/<slug>.md, where Wing is a free-form topic
|
||||||
|
// domain and Hall is one of a closed vocabulary of memory types.
|
||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidHalls is the closed vocabulary of hall names. A hall captures the
|
||||||
|
// memory type of a note within any wing.
|
||||||
|
var ValidHalls = map[string]bool{
|
||||||
|
"facts": true,
|
||||||
|
"decisions": true,
|
||||||
|
"failures": true,
|
||||||
|
"hypotheses": true,
|
||||||
|
"sources": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidHall reports whether h is in the closed Hall vocabulary.
|
||||||
|
func IsValidHall(h string) bool {
|
||||||
|
return ValidHalls[h]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotePath resolves the canonical filesystem path for a note given a
|
||||||
|
// wing, hall, and slug. Returns an error if hall is not in ValidHalls
|
||||||
|
// or if wing/slug sanitise to empty strings.
|
||||||
|
//
|
||||||
|
// The returned path is brain/wiki/<wing>/<hall>/<slug>.md with all
|
||||||
|
// segments sanitised: lowercased, alphanumerics and hyphens only.
|
||||||
|
func NotePath(brainDir, wing, hall, slug string) (string, error) {
|
||||||
|
if !IsValidHall(hall) {
|
||||||
|
return "", fmt.Errorf("invalid hall %q: must be one of facts/decisions/failures/hypotheses/sources", hall)
|
||||||
|
}
|
||||||
|
w := Sanitise(wing)
|
||||||
|
if w == "" {
|
||||||
|
return "", fmt.Errorf("invalid wing %q: must contain at least one alphanumeric character", wing)
|
||||||
|
}
|
||||||
|
s := Sanitise(strings.TrimSuffix(slug, ".md"))
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("invalid slug %q: must contain at least one alphanumeric character", slug)
|
||||||
|
}
|
||||||
|
return filepath.Join(brainDir, "wiki", w, hall, s+".md"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitise lowercases s and keeps only [a-z0-9-], collapsing any other
|
||||||
|
// character (including path separators) to a hyphen. Leading/trailing
|
||||||
|
// hyphens and runs of hyphens are collapsed.
|
||||||
|
func Sanitise(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
var b strings.Builder
|
||||||
|
prevHyphen := true
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
prevHyphen = false
|
||||||
|
case r == '-' || r == '_' || r == ' ' || r == '/' || r == '\\' || r == '.':
|
||||||
|
if !prevHyphen {
|
||||||
|
b.WriteByte('-')
|
||||||
|
prevHyphen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
return strings.Trim(out, "-")
|
||||||
|
}
|
||||||
73
ingestion/internal/brain/path_test.go
Normal file
73
ingestion/internal/brain/path_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotePath_Valid(t *testing.T) {
|
||||||
|
got, err := brain.NotePath("/b", "jepa-fx", "decisions", "val-vol-r2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join("/b", "wiki", "jepa-fx", "decisions", "val-vol-r2.md"), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotePath_StripsMdSuffix(t *testing.T) {
|
||||||
|
got, err := brain.NotePath("/b", "x", "facts", "note.md")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join("/b", "wiki", "x", "facts", "note.md"), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotePath_SanitisesWingAndSlug(t *testing.T) {
|
||||||
|
got, err := brain.NotePath("/b", "Jepa FX!", "facts", "Val Vol R2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join("/b", "wiki", "jepa-fx", "facts", "val-vol-r2.md"), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotePath_RejectsInvalidHall(t *testing.T) {
|
||||||
|
_, err := brain.NotePath("/b", "x", "garbage", "y")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid hall")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotePath_RejectsEmptyWing(t *testing.T) {
|
||||||
|
_, err := brain.NotePath("/b", "!!!", "facts", "y")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid wing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotePath_RejectsEmptySlug(t *testing.T) {
|
||||||
|
_, err := brain.NotePath("/b", "x", "facts", "!!!")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid slug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitise(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Jepa-FX": "jepa-fx",
|
||||||
|
" foo bar ": "foo-bar",
|
||||||
|
"Val/Vol\\R2.md": "val-vol-r2-md",
|
||||||
|
"!!!": "",
|
||||||
|
"___leading": "leading",
|
||||||
|
"trailing___": "trailing",
|
||||||
|
"multi---hyphen": "multi-hyphen",
|
||||||
|
"UPPER 123 mixed": "upper-123-mixed",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
t.Run(in, func(t *testing.T) {
|
||||||
|
assert.Equal(t, want, brain.Sanitise(in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidHall(t *testing.T) {
|
||||||
|
for _, h := range []string{"facts", "decisions", "failures", "hypotheses", "sources"} {
|
||||||
|
assert.True(t, brain.IsValidHall(h), h)
|
||||||
|
}
|
||||||
|
for _, h := range []string{"", "Facts", "facts ", "rooms", "concepts", "entities"} {
|
||||||
|
assert.False(t, brain.IsValidHall(h), h)
|
||||||
|
}
|
||||||
|
}
|
||||||
286
ingestion/internal/brain/tunnel.go
Normal file
286
ingestion/internal/brain/tunnel.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seeAlsoHeader is the markdown heading used to group cross-wing links.
|
||||||
|
const seeAlsoHeader = "## See also"
|
||||||
|
|
||||||
|
// TunnelCandidate is a cross-wing match surfaced by DetectTunnels. It is
|
||||||
|
// not yet a written link — the caller decides whether confidence is high
|
||||||
|
// enough to commit it via WriteTunnel.
|
||||||
|
type TunnelCandidate struct {
|
||||||
|
// TargetPath is the candidate note's path relative to brainDir
|
||||||
|
// (forward-slashed), e.g. "wiki/hyperguild/decisions/routing.md".
|
||||||
|
TargetPath string
|
||||||
|
// MatchedTerm is the title that matched in the source content.
|
||||||
|
MatchedTerm string
|
||||||
|
// Exact is true when the match was a case-insensitive whole-token
|
||||||
|
// hit on the target's frontmatter title. Fuzzy matches (substring
|
||||||
|
// only) are flagged Exact=false and should not be auto-written.
|
||||||
|
Exact bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectTunnels scans brain/wiki/ for notes whose title appears in
|
||||||
|
// content. Returns one TunnelCandidate per matching note. Exact is true
|
||||||
|
// when content contains the title as a whole-word case-insensitive
|
||||||
|
// token; false when only a substring matched (caller treats these as
|
||||||
|
// fuzzy and should not auto-write them).
|
||||||
|
//
|
||||||
|
// A note's title is read from YAML frontmatter `title:`; failing that,
|
||||||
|
// the filename slug (sans `.md`, hyphens → spaces) is used.
|
||||||
|
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||||
|
wikiDir := filepath.Join(brainDir, "wiki")
|
||||||
|
if _, err := os.Stat(wikiDir); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat wiki: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerContent := strings.ToLower(content)
|
||||||
|
|
||||||
|
var out []TunnelCandidate
|
||||||
|
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
if needle == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idx := strings.Index(lowerContent, needle)
|
||||||
|
if idx == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, TunnelCandidate{
|
||||||
|
TargetPath: filepath.ToSlash(rel),
|
||||||
|
MatchedTerm: title,
|
||||||
|
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
||||||
|
// bounded by non-alphanumeric characters (or string edges).
|
||||||
|
func isWholeWord(s string, idx, n int) bool {
|
||||||
|
left := idx == 0 || !isWordByte(s[idx-1])
|
||||||
|
right := idx+n == len(s) || !isWordByte(s[idx+n])
|
||||||
|
return left && right
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordByte(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') ||
|
||||||
|
(b >= 'A' && b <= 'Z') ||
|
||||||
|
(b >= '0' && b <= '9')
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTunnel runs DetectTunnels against content and, for each
|
||||||
|
// candidate, either writes a bidirectional tunnel (when the match is
|
||||||
|
// exact and in a different wing) or stages it for human review in
|
||||||
|
// brain/raw/tunnel-candidates-<YYYY-MM-DD>.md.
|
||||||
|
//
|
||||||
|
// sourcePath is the note that originated the content — used to skip
|
||||||
|
// self-matches and same-wing tunnels. Errors writing individual
|
||||||
|
// tunnels are recorded into the candidates file but never abort the
|
||||||
|
// rest of the scan; the caller's primary write has already succeeded
|
||||||
|
// and auto-linking is best-effort.
|
||||||
|
func AutoTunnel(brainDir, sourcePath, content string) error {
|
||||||
|
srcWing, err := wingOf(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidates, err := DetectTunnels(brainDir, content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fuzzy []TunnelCandidate
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.TargetPath == sourcePath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tgtWing, err := wingOf(c.TargetPath)
|
||||||
|
if err != nil || tgtWing == srcWing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.Exact {
|
||||||
|
fuzzy = append(fuzzy, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := WriteTunnel(brainDir, sourcePath, c.TargetPath); err != nil {
|
||||||
|
fuzzy = append(fuzzy, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logFuzzyCandidates(brainDir, sourcePath, fuzzy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFuzzyCandidates appends one row per candidate to
|
||||||
|
// brain/raw/tunnel-candidates-<YYYY-MM-DD>.md, creating the file with a
|
||||||
|
// header on first write of the day. No-op when the candidate list is empty.
|
||||||
|
func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error {
|
||||||
|
if len(cs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stamp := time.Now().UTC().Format("2006-01-02")
|
||||||
|
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
|
||||||
|
existed := fileExists(path)
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if !existed {
|
||||||
|
if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
|
||||||
|
if _, err := f.WriteString(line); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTunnel appends a bidirectional wikilink between sourcePath and
|
||||||
|
// targetPath under a `## See also` section in each note. Paths are
|
||||||
|
// relative to brainDir (forward-slashed), e.g. wiki/<wing>/<hall>/<slug>.md.
|
||||||
|
//
|
||||||
|
// Idempotent: re-calling with the same pair does not duplicate links or
|
||||||
|
// section headers. Rejects same-wing pairs (a tunnel is by definition
|
||||||
|
// cross-wing) and missing notes.
|
||||||
|
func WriteTunnel(brainDir, sourcePath, targetPath string) error {
|
||||||
|
srcWing, err := wingOf(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("source: %w", err)
|
||||||
|
}
|
||||||
|
tgtWing, err := wingOf(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("target: %w", err)
|
||||||
|
}
|
||||||
|
if srcWing == tgtWing {
|
||||||
|
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath))
|
||||||
|
tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath))
|
||||||
|
if _, err := os.Stat(srcFull); err != nil {
|
||||||
|
return fmt.Errorf("source note: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(tgtFull); err != nil {
|
||||||
|
return fmt.Errorf("target note: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil {
|
||||||
|
return fmt.Errorf("update source: %w", err)
|
||||||
|
}
|
||||||
|
if err := appendSeeAlso(tgtFull, wikilinkOf(sourcePath)); err != nil {
|
||||||
|
return fmt.Errorf("update target: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wikilinkOf turns "wiki/<wing>/<hall>/<slug>.md" into "<wing>/<hall>/<slug>"
|
||||||
|
// for use inside `[[...]]`.
|
||||||
|
func wikilinkOf(relPath string) string {
|
||||||
|
p := strings.TrimSuffix(relPath, ".md")
|
||||||
|
p = strings.TrimPrefix(p, "wiki/")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// wingOf extracts the wing segment from a relative wiki path
|
||||||
|
// "wiki/<wing>/<hall>/<slug>.md".
|
||||||
|
func wingOf(relPath string) (string, error) {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) < 4 || parts[0] != "wiki" {
|
||||||
|
return "", fmt.Errorf("not a wiki path: %q", relPath)
|
||||||
|
}
|
||||||
|
if parts[1] == "" {
|
||||||
|
return "", fmt.Errorf("empty wing in path: %q", relPath)
|
||||||
|
}
|
||||||
|
return parts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendSeeAlso inserts `- [[link]]` under the file's See also section,
|
||||||
|
// creating the section if absent. No-op when the link is already present.
|
||||||
|
func appendSeeAlso(filePath, link string) error {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wikilink := "[[" + link + "]]"
|
||||||
|
if strings.Contains(string(content), wikilink) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bullet := "- " + wikilink
|
||||||
|
|
||||||
|
if !strings.Contains(string(content), seeAlsoHeader) {
|
||||||
|
// No section yet — append a fresh one. Always emit a trailing
|
||||||
|
// newline so subsequent appends don't merge into the previous line.
|
||||||
|
trimmed := strings.TrimRight(string(content), "\n")
|
||||||
|
out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"
|
||||||
|
return os.WriteFile(filePath, []byte(out), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section exists — splice the bullet in just before the next `## `
|
||||||
|
// heading (or EOF). Reading the file line-by-line keeps this robust
|
||||||
|
// against arbitrary section ordering.
|
||||||
|
var b strings.Builder
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
inSeeAlso, inserted := false, false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !inserted && inSeeAlso && strings.HasPrefix(line, "## ") &&
|
||||||
|
strings.TrimSpace(line) != seeAlsoHeader {
|
||||||
|
b.WriteString(bullet)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteByte('\n')
|
||||||
|
inserted = true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == seeAlsoHeader {
|
||||||
|
inSeeAlso = true
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
// section was the last thing in the file — just append bullet
|
||||||
|
out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"
|
||||||
|
return os.WriteFile(filePath, []byte(out), 0o644)
|
||||||
|
}
|
||||||
|
return os.WriteFile(filePath, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
177
ingestion/internal/brain/tunnel_test.go
Normal file
177
ingestion/internal/brain/tunnel_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedNote writes a minimal markdown note at brainDir/relPath with the given body.
|
||||||
|
func seedNote(t *testing.T, brainDir, relPath, body string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(brainDir, relPath)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTunnel_AppendsBidirectionalLinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol R2\n\nbody.\n")
|
||||||
|
seedNote(t, dir, "wiki/hyperguild/decisions/routing.md",
|
||||||
|
"---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n\nbody.\n")
|
||||||
|
|
||||||
|
err := brain.WriteTunnel(dir,
|
||||||
|
"wiki/jepa-fx/decisions/val-vol.md",
|
||||||
|
"wiki/hyperguild/decisions/routing.md",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
src, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/decisions/val-vol.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(src), "## See also")
|
||||||
|
assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]")
|
||||||
|
|
||||||
|
tgt, err := os.ReadFile(filepath.Join(dir, "wiki/hyperguild/decisions/routing.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(tgt), "## See also")
|
||||||
|
assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTunnel_Idempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/a/facts/x.md", "# X\n\nbody.\n")
|
||||||
|
seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n")
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
require.NoError(t, brain.WriteTunnel(dir,
|
||||||
|
"wiki/a/facts/x.md", "wiki/b/facts/y.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, strings.Count(string(src), "[[b/facts/y]]"),
|
||||||
|
"link should appear exactly once after 3 calls")
|
||||||
|
assert.Equal(t, 1, strings.Count(string(src), "## See also"))
|
||||||
|
|
||||||
|
tgt, err := os.ReadFile(filepath.Join(dir, "wiki/b/facts/y.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, strings.Count(string(tgt), "[[a/facts/x]]"))
|
||||||
|
assert.Equal(t, 1, strings.Count(string(tgt), "## See also"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTunnel_RejectsSameWing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/facts/x.md", "x")
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/facts/y.md", "y")
|
||||||
|
err := brain.WriteTunnel(dir,
|
||||||
|
"wiki/jepa-fx/facts/x.md", "wiki/jepa-fx/facts/y.md")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cross wings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTunnel_RejectsMissingNote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||||
|
err := brain.WriteTunnel(dir,
|
||||||
|
"wiki/a/facts/x.md", "wiki/b/facts/ghost.md")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: decisions\ntitle: Val Vol R2\n---\nbody.\n")
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/facts/lejpa.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ntitle: LeJPA Architecture\n---\nbody.\n")
|
||||||
|
|
||||||
|
candidates, err := brain.DetectTunnels(dir,
|
||||||
|
"We need to revisit Val Vol R2 in light of new tier data.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, candidates, 1)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", candidates[0].TargetPath)
|
||||||
|
assert.Equal(t, "Val Vol R2", candidates[0].MatchedTerm)
|
||||||
|
assert.True(t, candidates[0].Exact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||||
|
"---\ntitle: Routing\n---\nbody.\n")
|
||||||
|
|
||||||
|
// Substring of title appears in content, but not as a whole word.
|
||||||
|
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, candidates, 1)
|
||||||
|
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
||||||
|
|
||||||
|
candidates, err := brain.DetectTunnels(dir,
|
||||||
|
"Documented Widget Flags after the deploy issue.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, candidates, 1)
|
||||||
|
assert.True(t, candidates[0].Exact)
|
||||||
|
assert.Equal(t, "widget flags", candidates[0].MatchedTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Existing note in a different wing whose title is "Routing".
|
||||||
|
seedNote(t, dir, "wiki/other/facts/routing.md",
|
||||||
|
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
||||||
|
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
||||||
|
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
||||||
|
|
||||||
|
require.NoError(t, brain.AutoTunnel(dir,
|
||||||
|
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
||||||
|
|
||||||
|
// Source must not get auto-linked (fuzzy).
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
||||||
|
|
||||||
|
// Candidates file must list the pair.
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, matches, 1)
|
||||||
|
body, err := os.ReadFile(matches[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
||||||
|
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
||||||
|
assert.Contains(t, string(body), "Routing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_EmptyWiki(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cs, err := brain.DetectTunnels(dir, "anything")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/a/facts/x.md",
|
||||||
|
"# X\n\nbody.\n\n## See also\n\n- [[a/facts/old]]\n")
|
||||||
|
seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n")
|
||||||
|
|
||||||
|
require.NoError(t, brain.WriteTunnel(dir,
|
||||||
|
"wiki/a/facts/x.md", "wiki/b/facts/y.md"))
|
||||||
|
|
||||||
|
src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(src)
|
||||||
|
assert.Equal(t, 1, strings.Count(s, "## See also"), "should reuse existing section")
|
||||||
|
assert.Contains(t, s, "[[a/facts/old]]")
|
||||||
|
assert.Contains(t, s, "[[b/facts/y]]")
|
||||||
|
}
|
||||||
76
ingestion/internal/embed/embed.go
Normal file
76
ingestion/internal/embed/embed.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Package embed produces dense vector embeddings for brain content.
|
||||||
|
//
|
||||||
|
// Wire format is Ollama's `/api/embed`, with the canonical request shape
|
||||||
|
// `{"model": "...", "input": "..."}` and a 2-D `embeddings` response.
|
||||||
|
// Default deployment runs `nomic-embed-text` on iguana, which returns
|
||||||
|
// 768-dim vectors compatible with the brain_embeddings table schema.
|
||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts embedding requests to an Ollama-compatible endpoint.
|
||||||
|
type Client struct {
|
||||||
|
URL string
|
||||||
|
Model string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. Returns nil when url is empty so callers can
|
||||||
|
// treat a missing BRAIN_EMBED_URL as "feature disabled" via a single nil
|
||||||
|
// check.
|
||||||
|
func New(url, model string) *Client {
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
URL: strings.TrimRight(url, "/"),
|
||||||
|
Model: model,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed returns the embedding vector for text. Empty text is rejected
|
||||||
|
// up-front to keep upstream errors from masking caller mistakes.
|
||||||
|
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return nil, fmt.Errorf("embed: empty text")
|
||||||
|
}
|
||||||
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
|
"model": c.Model,
|
||||||
|
"input": text,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.URL+"/api/embed", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("embed: status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Embeddings [][]float32 `json:"embeddings"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("embed: decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(out.Embeddings) == 0 || len(out.Embeddings[0]) == 0 {
|
||||||
|
return nil, fmt.Errorf("embed: empty embeddings in response")
|
||||||
|
}
|
||||||
|
return out.Embeddings[0], nil
|
||||||
|
}
|
||||||
74
ingestion/internal/embed/embed_test.go
Normal file
74
ingestion/internal/embed/embed_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package embed_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew_EmptyURLReturnsNil(t *testing.T) {
|
||||||
|
assert.Nil(t, embed.New("", "model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbed_ReturnsVector(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/embed", r.URL.Path)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||||
|
assert.Equal(t, "nomic", req["model"])
|
||||||
|
assert.Equal(t, "hello", req["input"])
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"embeddings": [][]float32{{0.1, 0.2, 0.3}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := embed.New(srv.URL, "nomic")
|
||||||
|
require.NotNil(t, c)
|
||||||
|
v, err := c.Embed(context.Background(), "hello")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []float32{0.1, 0.2, 0.3}, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbed_StripsTrailingSlashFromURL(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/embed", r.URL.Path)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1.0}}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := embed.New(srv.URL+"/", "nomic")
|
||||||
|
_, err := c.Embed(context.Background(), "x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbed_PropagatesUpstreamError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := embed.New(srv.URL, "m")
|
||||||
|
_, err := c.Embed(context.Background(), "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbed_RejectsEmptyEmbeddingsArray(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := embed.New(srv.URL, "m")
|
||||||
|
_, err := c.Embed(context.Background(), "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbed_RejectsEmptyText(t *testing.T) {
|
||||||
|
c := embed.New("http://127.0.0.1:1", "m")
|
||||||
|
_, err := c.Embed(context.Background(), "")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
29
ingestion/internal/llm/router.go
Normal file
29
ingestion/internal/llm/router.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Router calls Primary first; on any error falls back to Fallback.
|
||||||
|
// Fallback may be nil, in which case primary errors are returned directly.
|
||||||
|
type Router struct {
|
||||||
|
Primary *Client
|
||||||
|
Fallback *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||||
|
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
|
out, err := r.Primary.Complete(ctx, system, user)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if r.Fallback == nil {
|
||||||
|
return "", fmt.Errorf("primary llm: %w", err)
|
||||||
|
}
|
||||||
|
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||||
|
if err2 != nil {
|
||||||
|
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
71
ingestion/internal/llm/router_test.go
Normal file
71
ingestion/internal/llm/router_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouter_PrimarySucceeds(t *testing.T) {
|
||||||
|
primary := mockServer(t, "from-primary")
|
||||||
|
defer primary.Close()
|
||||||
|
fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("fallback must not be called when primary succeeds")
|
||||||
|
}))
|
||||||
|
defer fallback.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(primary.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-primary", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_FallsBackOnPrimaryError(t *testing.T) {
|
||||||
|
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer primary.Close()
|
||||||
|
fallback := mockServer(t, "from-fallback")
|
||||||
|
defer fallback.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(primary.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-fallback", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_BothFail(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_NilFallback(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{Primary: New(fail.URL, "", "m", time.Second)}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
65
ingestion/internal/mcp/auth.go
Normal file
65
ingestion/internal/mcp/auth.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BearerAuth gates an HTTP handler behind dual-mode authentication.
|
||||||
|
//
|
||||||
|
// Auth precedence:
|
||||||
|
//
|
||||||
|
// 1. Static Bearer match (constant-time compare against staticToken).
|
||||||
|
// Wins immediately and never emits a WWW-Authenticate header. This is
|
||||||
|
// the path used by internal Tailscale/LAN CLI callers that supply
|
||||||
|
// `Authorization: Bearer $BRAIN_MCP_TOKEN` via `.mcp.json`. Returning
|
||||||
|
// 200 without a WWW-Authenticate prevents the MCP client from
|
||||||
|
// speculatively flipping into OAuth-discovery mode.
|
||||||
|
// 2. Dex JWT validation (when validator is non-nil). Used by claude.ai
|
||||||
|
// custom MCP connectors that finished the OAuth handshake.
|
||||||
|
// 3. Otherwise 401. When resourceMetadataURL is non-empty, a
|
||||||
|
// `WWW-Authenticate: Bearer resource_metadata="…"` header is emitted
|
||||||
|
// per RFC 9728 §6.2 so claude.ai's OAuth discovery flow can find the
|
||||||
|
// server's protected-resource metadata document.
|
||||||
|
//
|
||||||
|
// The order matters: a valid static Bearer must short-circuit BEFORE any
|
||||||
|
// JWT path runs, because a non-empty WWW-Authenticate emitted on the
|
||||||
|
// fall-through 401 confuses static-Bearer-only clients into discarding
|
||||||
|
// their header and starting an OAuth handshake instead.
|
||||||
|
func BearerAuth(staticToken string, validator *auth.Validator, resourceMetadataURL string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok {
|
||||||
|
unauthorized(w, resourceMetadataURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Static Bearer wins first — never emits a challenge.
|
||||||
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(staticToken)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Then Dex JWT, if configured.
|
||||||
|
if validator != nil {
|
||||||
|
if _, err := validator.Validate(r.Context(), rawToken); err == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reject with an OAuth resource-metadata challenge if configured.
|
||||||
|
unauthorized(w, resourceMetadataURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(w http.ResponseWriter, resourceMetadataURL string) {
|
||||||
|
if resourceMetadataURL != "" {
|
||||||
|
w.Header().Set("WWW-Authenticate",
|
||||||
|
`Bearer realm="brain", resource_metadata="`+resourceMetadataURL+`"`)
|
||||||
|
}
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
202
ingestion/internal/mcp/auth_test.go
Normal file
202
ingestion/internal/mcp/auth_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testResourceMetadataURL = "https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"
|
||||||
|
|
||||||
|
func okHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_MissingHeader(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_WrongToken(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_CorrectToken(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
handler := mcp.BearerAuth("secret", nil, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer secret")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_EmptyConfiguredToken(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("", nil, "", okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #9: a valid static Bearer must never emit a WWW-Authenticate header,
|
||||||
|
// even when a resource-metadata URL is configured. The presence of that
|
||||||
|
// header on a 200 response would flip MCP CLI clients into OAuth-discovery
|
||||||
|
// mode and break static-Bearer auth from `.mcp.json` on Tailscale/LAN.
|
||||||
|
func TestBearerAuth_ValidStaticBearer_NoWWWAuthenticate(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer secret")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Empty(t, rr.Header().Get("WWW-Authenticate"), "static-Bearer 200 must not advertise OAuth")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #9: a 401 with resource-metadata configured must emit a
|
||||||
|
// WWW-Authenticate header so claude.ai discovers the protected-resource
|
||||||
|
// metadata document and continues the OAuth dance.
|
||||||
|
func TestBearerAuth_Unauthorized_EmitsResourceMetadataChallenge(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
got := rr.Header().Get("WWW-Authenticate")
|
||||||
|
assert.Contains(t, got, `Bearer realm="brain"`)
|
||||||
|
assert.Contains(t, got, `resource_metadata="`+testResourceMetadataURL+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static-Bearer-only deployment: no resource-metadata URL, no challenge
|
||||||
|
// header on 401 — matches pre-#9 behaviour for tests without Dex wired.
|
||||||
|
func TestBearerAuth_Unauthorized_NoChallengeWhenResourceUnset(t *testing.T) {
|
||||||
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
assert.Empty(t, rr.Header().Get("WWW-Authenticate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT auth tests
|
||||||
|
|
||||||
|
func buildOIDCServer(t *testing.T) (*httptest.Server, jwk.Key) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
priv, err := jwk.FromRaw(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, priv.Set(jwk.KeyIDKey, "k1"))
|
||||||
|
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||||
|
pub, err := jwk.PublicKeyOf(priv)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
set := jwk.NewSet()
|
||||||
|
require.NoError(t, set.AddKey(pub))
|
||||||
|
jwksBytes, err := json.Marshal(set)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
muxSrv := http.NewServeMux()
|
||||||
|
var srv *httptest.Server
|
||||||
|
muxSrv.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"jwks_uri": srv.URL + "/jwks",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
muxSrv.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write(jwksBytes)
|
||||||
|
})
|
||||||
|
srv = httptest.NewServer(muxSrv)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func signJWT(t *testing.T, priv jwk.Key, issuer, audience string, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := jwt.NewBuilder().
|
||||||
|
Issuer(issuer).Audience([]string{audience}).
|
||||||
|
Subject("s").Expiration(exp).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_ValidJWT(t *testing.T) {
|
||||||
|
oidcSrv, priv := buildOIDCServer(t)
|
||||||
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
handler := mcp.BearerAuth("static-secret", v, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(time.Hour))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_InvalidJWT_FallsBackToStaticToken(t *testing.T) {
|
||||||
|
oidcSrv, _ := buildOIDCServer(t)
|
||||||
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer static-secret")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_InvalidJWT_WrongStaticToken(t *testing.T) {
|
||||||
|
oidcSrv, priv := buildOIDCServer(t)
|
||||||
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
||||||
|
// Expired JWT — JWT fails, static token doesn't match either
|
||||||
|
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(-time.Hour))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
_ = context.Background() // satisfies import
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -24,6 +26,10 @@ func (s *Server) tools() []map[string]any {
|
|||||||
int_ := func(desc string) map[string]any {
|
int_ := func(desc string) map[string]any {
|
||||||
return map[string]any{"type": "integer", "description": desc}
|
return map[string]any{"type": "integer", "description": desc}
|
||||||
}
|
}
|
||||||
|
enum := func(desc string, vals ...string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": desc, "enum": vals}
|
||||||
|
}
|
||||||
|
halls := []string{"facts", "decisions", "failures", "hypotheses", "sources"}
|
||||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
schema := func(required []string, props map[string]any) json.RawMessage {
|
||||||
b, _ := json.Marshal(map[string]any{
|
b, _ := json.Marshal(map[string]any{
|
||||||
"type": "object", "required": required, "properties": props,
|
"type": "object", "required": required, "properties": props,
|
||||||
@@ -34,20 +40,39 @@ func (s *Server) tools() []map[string]any {
|
|||||||
return []map[string]any{
|
return []map[string]any{
|
||||||
{
|
{
|
||||||
"name": "brain_query",
|
"name": "brain_query",
|
||||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files.",
|
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
||||||
"inputSchema": schema([]string{"query"}, map[string]any{
|
"inputSchema": schema([]string{"query"}, map[string]any{
|
||||||
"query": str("search terms"),
|
"query": str("search terms"),
|
||||||
"limit": int_("max results, default 5"),
|
"limit": int_("max results, default 5"),
|
||||||
|
"wing": str("optional wing to scope to, e.g. jepa-fx"),
|
||||||
|
"hall": enum("optional hall to scope to (requires wing)", halls...),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_write",
|
"name": "brain_write",
|
||||||
"description": "Write a raw knowledge note to brain/knowledge/.",
|
"description": "Write a markdown note to the brain. With wing+hall set, routes to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter; otherwise writes to brain/knowledge/ (legacy).",
|
||||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||||
"content": str("markdown content"),
|
"content": str("markdown content"),
|
||||||
"filename": str("optional filename"),
|
"filename": str("optional filename or slug"),
|
||||||
"type": str("optional frontmatter type"),
|
"type": str("optional frontmatter type (legacy)"),
|
||||||
"domain": str("optional frontmatter domain"),
|
"domain": str("optional frontmatter domain (legacy)"),
|
||||||
|
"wing": str("optional topic domain, e.g. jepa-fx"),
|
||||||
|
"hall": enum("optional memory type (requires wing)", halls...),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_tunnel",
|
||||||
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
|
"inputSchema": schema([]string{"source", "target"}, map[string]any{
|
||||||
|
"source": str("path of source note relative to brain dir, e.g. wiki/jepa-fx/decisions/val-vol.md"),
|
||||||
|
"target": str("path of target note (must be in a different wing)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_index",
|
||||||
|
"description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.",
|
||||||
|
"inputSchema": schema([]string{}, map[string]any{
|
||||||
|
"wing": str("optional wing to index; if absent, rebuilds every wing"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,6 +94,20 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"dry_run": map[string]any{"type": "boolean"},
|
"dry_run": map[string]any{"type": "boolean"},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_answer",
|
||||||
|
"description": "Retrieve relevant brain content via BM25 and synthesize a coherent answer using an LLM.",
|
||||||
|
"inputSchema": schema([]string{"query"}, map[string]any{
|
||||||
|
"query": str("question to answer"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_classify",
|
||||||
|
"description": "Classify raw text into doc type, title, and tags using an LLM.",
|
||||||
|
"inputSchema": schema([]string{"text"}, map[string]any{
|
||||||
|
"text": str("raw document text to classify (first 3000 chars used)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "session_log",
|
"name": "session_log",
|
||||||
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||||
@@ -77,7 +116,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"skill": str("skill name"),
|
"skill": str("skill name"),
|
||||||
"phase": str("phase within the skill"),
|
"phase": str("phase within the skill"),
|
||||||
"project_root": str("absolute project root"),
|
"project_root": str("absolute project root"),
|
||||||
"final_status": str("ok | error | skipped"),
|
"final_status": str("pass | fail | skip (legacy: ok | error | skipped also accepted)"),
|
||||||
"file_path": str("optional file produced"),
|
"file_path": str("optional file produced"),
|
||||||
"model_used": str("optional model identifier"),
|
"model_used": str("optional model identifier"),
|
||||||
"duration_ms": int_("optional duration in ms"),
|
"duration_ms": int_("optional duration in ms"),
|
||||||
@@ -90,6 +129,8 @@ func (s *Server) tools() []map[string]any {
|
|||||||
type brainQueryArgs struct {
|
type brainQueryArgs struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
@@ -103,7 +144,14 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if a.Limit == 0 {
|
if a.Limit == 0 {
|
||||||
a.Limit = 5
|
a.Limit = 5
|
||||||
}
|
}
|
||||||
results, err := search.Query(s.brainDir, a.Query, a.Limit)
|
results, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
|
||||||
|
Query: a.Query,
|
||||||
|
Limit: a.Limit,
|
||||||
|
Wing: a.Wing,
|
||||||
|
Hall: a.Hall,
|
||||||
|
Vector: s.vector,
|
||||||
|
Embedder: s.embedder,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search: %w", err)
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
}
|
}
|
||||||
@@ -115,6 +163,8 @@ type brainWriteArgs struct {
|
|||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
@@ -122,13 +172,73 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.WriteNote(s.brainDir, a.Content, a.Filename, a.Type, a.Domain)
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
|
Content: a.Content,
|
||||||
|
Filename: a.Filename,
|
||||||
|
Type: a.Type,
|
||||||
|
Domain: a.Domain,
|
||||||
|
Wing: a.Wing,
|
||||||
|
Hall: a.Hall,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Auto-regenerate the wing _index.md when the write landed in the
|
||||||
|
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
||||||
|
// best-effort: the note is already written.
|
||||||
|
if a.Wing != "" && a.Hall != "" {
|
||||||
|
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||||
|
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
||||||
|
}
|
||||||
|
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
||||||
|
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return json.Marshal(map[string]string{"path": relPath})
|
return json.Marshal(map[string]string{"path": relPath})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type brainTunnelArgs struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainTunnel(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainTunnelArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Source == "" || a.Target == "" {
|
||||||
|
return nil, fmt.Errorf("source and target are required")
|
||||||
|
}
|
||||||
|
if err := brain.WriteTunnel(s.brainDir, a.Source, a.Target); err != nil {
|
||||||
|
return nil, fmt.Errorf("tunnel: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainIndexArgs struct {
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainIndex(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainIndexArgs
|
||||||
|
if len(args) > 0 {
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.Wing == "" {
|
||||||
|
if err := brain.BuildAllWingIndexes(s.brainDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("index: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"status": "ok", "scope": "all"})
|
||||||
|
}
|
||||||
|
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||||
|
return nil, fmt.Errorf("index: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"status": "ok", "scope": a.Wing})
|
||||||
|
}
|
||||||
|
|
||||||
type brainIngestRawArgs struct {
|
type brainIngestRawArgs struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Pages []pipeline.RawPage `json:"pages"`
|
Pages []pipeline.RawPage `json:"pages"`
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
|
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"})
|
resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"})
|
||||||
|
|
||||||
require.Nil(t, resp["error"])
|
require.Nil(t, resp["error"])
|
||||||
@@ -53,7 +53,7 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainWriteCreatesFile(t *testing.T) {
|
func TestBrainWriteCreatesFile(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "# Test\n\nbody",
|
"content": "# Test\n\nbody",
|
||||||
@@ -70,9 +70,147 @@ func TestBrainWriteCreatesFile(t *testing.T) {
|
|||||||
assert.Contains(t, string(got), "# Test")
|
assert.Contains(t, string(got), "# Test")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "# Val Vol\n\nbody",
|
||||||
|
"filename": "val-vol-r2",
|
||||||
|
"wing": "jepa-fx",
|
||||||
|
"hall": "decisions",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "jepa-fx", "decisions", "val-vol-r2.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "wing: jepa-fx")
|
||||||
|
assert.Contains(t, string(got), "hall: decisions")
|
||||||
|
assert.Contains(t, string(got), "created_at:")
|
||||||
|
assert.Contains(t, string(got), "# Val Vol")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteRejectsInvalidHall(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
"wing": "jepa-fx",
|
||||||
|
"hall": "garbage",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainQueryWingScope(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
{"wiki/jepa-fx/facts/x.md", "---\nwing: jepa-fx\nhall: facts\n---\nfoo keyword.\n"},
|
||||||
|
{"wiki/other/facts/y.md", "---\nwing: other\nhall: facts\n---\nfoo keyword.\n"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(brainDir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_query", map[string]any{
|
||||||
|
"query": "foo",
|
||||||
|
"wing": "jepa-fx",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, "wiki/jepa-fx/facts/x.md")
|
||||||
|
assert.NotContains(t, text, "wiki/other/facts/y.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteAutoTunnelsOnExactMatch(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
// Seed a pre-existing note in wing "other".
|
||||||
|
existing := filepath.Join(brainDir, "wiki/other/facts/widget.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(existing,
|
||||||
|
[]byte("---\nwing: other\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644))
|
||||||
|
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
// Write a new note in a *different* wing whose content references "Widget".
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "# Notes\n\nThis note discusses the Widget concept.\n",
|
||||||
|
"filename": "notes",
|
||||||
|
"wing": "jepa-fx",
|
||||||
|
"hall": "facts",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md")
|
||||||
|
got, err := os.ReadFile(newNote)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[other/facts/widget]]", "new note should link to existing")
|
||||||
|
|
||||||
|
gotTgt, err := os.ReadFile(existing)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(gotTgt), "[[jepa-fx/facts/notes]]", "existing note should backlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteAutoTunnelSkipsSameWing(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
existing := filepath.Join(brainDir, "wiki/jepa-fx/facts/widget.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(existing,
|
||||||
|
[]byte("---\nwing: jepa-fx\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644))
|
||||||
|
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "Same wing reference to Widget here.\n",
|
||||||
|
"filename": "notes",
|
||||||
|
"wing": "jepa-fx",
|
||||||
|
"hall": "facts",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md")
|
||||||
|
got, err := os.ReadFile(newNote)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(got), "[[jepa-fx/facts/widget]]", "same-wing match must not auto-tunnel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainTunnelLinksTwoNotes(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
{"wiki/jepa-fx/decisions/val-vol.md", "---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol\n"},
|
||||||
|
{"wiki/hyperguild/decisions/routing.md", "---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(brainDir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_tunnel", map[string]any{
|
||||||
|
"source": "wiki/jepa-fx/decisions/val-vol.md",
|
||||||
|
"target": "wiki/hyperguild/decisions/routing.md",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
src, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/decisions/val-vol.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]")
|
||||||
|
tgt, err := os.ReadFile(filepath.Join(brainDir, "wiki/hyperguild/decisions/routing.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainTunnelRejectsMissing(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_tunnel", map[string]any{
|
||||||
|
"source": "wiki/a/facts/ghost.md",
|
||||||
|
"target": "wiki/b/facts/ghost.md",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "x",
|
"content": "x",
|
||||||
@@ -83,7 +221,7 @@ func TestBrainWriteRejectsTraversal(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "x",
|
"content": "x",
|
||||||
@@ -98,7 +236,7 @@ func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
|||||||
func TestBrainIngestRawDryRun(t *testing.T) {
|
func TestBrainIngestRawDryRun(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755))
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{
|
resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{
|
||||||
"source": "test-source",
|
"source": "test-source",
|
||||||
@@ -130,7 +268,7 @@ func TestBrainIngestRawDryRun(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainIngestRejectsBoth(t *testing.T) {
|
func TestBrainIngestRejectsBoth(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
"content": "x",
|
"content": "x",
|
||||||
@@ -142,7 +280,7 @@ func TestBrainIngestRejectsBoth(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainIngestRequiresOne(t *testing.T) {
|
func TestBrainIngestRequiresOne(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_ingest", map[string]any{})
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
@@ -150,7 +288,7 @@ func TestBrainIngestRequiresOne(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
|
func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
"content": "x",
|
"content": "x",
|
||||||
@@ -160,7 +298,7 @@ func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
|
func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil) // nil pipelineCfg → no LLM
|
srv := mcp.NewServer(brainDir, nil, nil, nil) // nil pipelineCfg → no LLM
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
"content": "some content",
|
"content": "some content",
|
||||||
@@ -173,7 +311,7 @@ func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionLogAppends(t *testing.T) {
|
func TestSessionLogAppends(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "session_log", map[string]any{
|
resp := toolCall(t, srv, "session_log", map[string]any{
|
||||||
"session_id": "session-x",
|
"session_id": "session-x",
|
||||||
@@ -190,7 +328,7 @@ func TestSessionLogAppends(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionLogRequiresSessionID(t *testing.T) {
|
func TestSessionLogRequiresSessionID(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMCPMountedHandler(t *testing.T) {
|
func TestMCPMountedHandler(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("POST /mcp", srv)
|
mux.Handle("POST /mcp", srv)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ func TestMCPMountedHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
out, _ := io.ReadAll(resp.Body)
|
out, _ := io.ReadAll(resp.Body)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_ingest, brain_ingest_raw, session_log.
|
// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
|
||||||
|
// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log.
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
type request struct {
|
type request struct {
|
||||||
@@ -35,19 +38,56 @@ type Server struct {
|
|||||||
brainDir string
|
brainDir string
|
||||||
pipeline pipeline.Config
|
pipeline pipeline.Config
|
||||||
llm pipeline.CompleteFunc
|
llm pipeline.CompleteFunc
|
||||||
|
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
||||||
|
reranker *reranker.Client // nil = no rerank, BM25 top-10 → LLM
|
||||||
|
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||||
|
embedder search.Embedder // nil = BM25-only retrieval
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
||||||
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc) *Server {
|
// answerLLM drives brain_answer and brain_classify; nil disables those tools.
|
||||||
|
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc, answerLLM pipeline.CompleteFunc) *Server {
|
||||||
cfg := pipeline.Config{}
|
cfg := pipeline.Config{}
|
||||||
if pipelineCfg != nil {
|
if pipelineCfg != nil {
|
||||||
cfg = *pipelineCfg
|
cfg = *pipelineCfg
|
||||||
}
|
}
|
||||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm}
|
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||||
|
// brain_answer retrieves a wider BM25 candidate set and prunes it to
|
||||||
|
// the relevant ones before LLM synthesis. Returns the server for
|
||||||
|
// fluent chaining.
|
||||||
|
func (s *Server) WithReranker(r *reranker.Client) *Server {
|
||||||
|
s.reranker = r
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHybridRetrieval wires the embedding store and embedder so
|
||||||
|
// brain_query and brain_answer run BM25 + pgvector merged via RRF
|
||||||
|
// instead of BM25 alone. Either nil disables hybrid mode.
|
||||||
|
func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder) *Server {
|
||||||
|
s.vector = v
|
||||||
|
s.embedder = e
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
<-r.Context().Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req request
|
var req request
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, nil, -32700, "parse error")
|
writeError(w, nil, -32700, "parse error")
|
||||||
@@ -120,12 +160,20 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainQuery(ctx, args)
|
return s.brainQuery(ctx, args)
|
||||||
case "brain_write":
|
case "brain_write":
|
||||||
return s.brainWrite(ctx, args)
|
return s.brainWrite(ctx, args)
|
||||||
|
case "brain_index":
|
||||||
|
return s.brainIndex(ctx, args)
|
||||||
|
case "brain_tunnel":
|
||||||
|
return s.brainTunnel(ctx, args)
|
||||||
case "brain_ingest_raw":
|
case "brain_ingest_raw":
|
||||||
return s.brainIngestRaw(ctx, args)
|
return s.brainIngestRaw(ctx, args)
|
||||||
case "brain_ingest":
|
case "brain_ingest":
|
||||||
return s.brainIngest(ctx, args)
|
return s.brainIngest(ctx, args)
|
||||||
case "session_log":
|
case "session_log":
|
||||||
return s.sessionLog(ctx, args)
|
return s.sessionLog(ctx, args)
|
||||||
|
case "brain_answer":
|
||||||
|
return s.brainAnswer(ctx, args)
|
||||||
|
case "brain_classify":
|
||||||
|
return s.brainClassify(ctx, args)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func body(t *testing.T, v any) *bytes.Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServerInitialize(t *testing.T) {
|
func TestServerInitialize(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
@@ -38,7 +38,7 @@ func TestServerInitialize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServerToolsList(t *testing.T) {
|
func TestServerToolsList(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||||
@@ -55,12 +55,14 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
names = append(names, t.(map[string]any)["name"].(string))
|
names = append(names, t.(map[string]any)["name"].(string))
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", "session_log",
|
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
||||||
|
"brain_ingest_raw", "brain_ingest",
|
||||||
|
"brain_answer", "brain_classify", "session_log",
|
||||||
}, names)
|
}, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerNotificationGetsNoBody(t *testing.T) {
|
func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "method": "notifications/initialized",
|
"jsonrpc": "2.0", "method": "notifications/initialized",
|
||||||
@@ -73,7 +75,7 @@ func TestServerNotificationGetsNoBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
||||||
|
|||||||
157
ingestion/internal/mcp/tools_answer.go
Normal file
157
ingestion/internal/mcp/tools_answer.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rerankResults scores each candidate's excerpt against the query and
|
||||||
|
// returns up to top results whose score is positive, preserving the
|
||||||
|
// caller's input order (BM25 rank) within the kept set. The reranker is
|
||||||
|
// a filter: ties are broken by BM25, not by the reranker's binary score.
|
||||||
|
func rerankResults(ctx context.Context, rr *reranker.Client, query string, results []search.Result, top int) ([]search.Result, error) {
|
||||||
|
docs := make([]string, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
docs[i] = r.Excerpt
|
||||||
|
}
|
||||||
|
scores, err := rr.Score(ctx, query, docs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kept := make([]search.Result, 0, top)
|
||||||
|
for i, r := range results {
|
||||||
|
if scores[i] > 0 {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
if len(kept) == top {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
answerSystemPrompt = `You are a knowledge assistant. Answer the question using ONLY the provided sources.
|
||||||
|
Cite source file paths inline when referencing specific content.
|
||||||
|
If the context does not contain enough information to answer, say so clearly.`
|
||||||
|
|
||||||
|
classifySystemPrompt = `Classify the document. Respond with JSON only, no markdown fences.
|
||||||
|
{"type":"...","title":"...","tags":["..."]}
|
||||||
|
Valid types: spec, plan, decision, note, wiki, log, code, unknown.`
|
||||||
|
)
|
||||||
|
|
||||||
|
type brainAnswerArgs struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if s.answerLLM == nil {
|
||||||
|
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
|
||||||
|
}
|
||||||
|
var a brainAnswerArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Query == "" {
|
||||||
|
return nil, fmt.Errorf("query is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With reranker disabled: BM25 top-10 straight to the LLM.
|
||||||
|
// With reranker enabled: BM25 top-20 → cross-encoder filter → top-5.
|
||||||
|
bm25Limit := 10
|
||||||
|
if s.reranker != nil {
|
||||||
|
bm25Limit = 20
|
||||||
|
}
|
||||||
|
results, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
|
||||||
|
Query: a.Query,
|
||||||
|
Limit: bm25Limit,
|
||||||
|
Vector: s.vector,
|
||||||
|
Embedder: s.embedder,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
|
}
|
||||||
|
if s.reranker != nil && len(results) > 0 {
|
||||||
|
results, err = rerankResults(ctx, s.reranker, a.Query, results, 5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rerank: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"answer": "No relevant content found in brain.",
|
||||||
|
"sources": []string{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sources := make([]string, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
fmt.Fprintf(&sb, "<source path=%q>\n%s\n</source>\n\n", r.Path, r.Excerpt)
|
||||||
|
sources = append(sources, r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("llm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"answer": answer,
|
||||||
|
"sources": sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainClassifyArgs struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type classifyResult struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainClassify(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if s.answerLLM == nil {
|
||||||
|
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
|
||||||
|
}
|
||||||
|
var a brainClassifyArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := a.Text
|
||||||
|
if len(text) > 3000 {
|
||||||
|
text = text[:3000]
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.answerLLM(ctx, classifySystemPrompt, text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("llm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip markdown fences if model adds them despite the instruction.
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
var cr classifyResult
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cr); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classify response %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
if cr.Tags == nil {
|
||||||
|
cr.Tags = []string{}
|
||||||
|
}
|
||||||
|
return json.Marshal(cr)
|
||||||
|
}
|
||||||
155
ingestion/internal/mcp/tools_answer_test.go
Normal file
155
ingestion/internal/mcp/tools_answer_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mockAnswerLLM(response string) pipeline.CompleteFunc {
|
||||||
|
return func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func brainDirWithContent(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
wikiDir := filepath.Join(dir, "wiki")
|
||||||
|
require.NoError(t, os.MkdirAll(wikiDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(wikiDir, "test.md"), []byte(
|
||||||
|
"---\ntitle: Pass-rate Logging\ntype: spec\n---\n\nPass-rate logging tracks skill invocations.",
|
||||||
|
), 0o644))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(t *testing.T, ts *httptest.Server, name string, arguments map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req := map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": arguments},
|
||||||
|
}
|
||||||
|
resp, err := http.Post(ts.URL, "application/json", body(t, req))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
var out map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainAnswer_RerankerFiltersBeforeLLM(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
wikiDir := filepath.Join(brainDir, "wiki")
|
||||||
|
require.NoError(t, os.MkdirAll(wikiDir, 0o755))
|
||||||
|
// Two notes — both BM25-match the query, but only one is truly relevant.
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(wikiDir, "good.md"), []byte(
|
||||||
|
"---\ntitle: Pass-rate Logging\n---\nPass-rate logging tracks skill invocations.",
|
||||||
|
), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(wikiDir, "noise.md"), []byte(
|
||||||
|
"---\ntitle: Pass-rate Tangent\n---\nPass-rate appears here too but as a tangent.",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
// Fake Ollama reranker: yes only when prompt contains "tracks skill invocations".
|
||||||
|
rrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
yes := strings.Contains(string(raw), "tracks skill invocations")
|
||||||
|
ans := "no"
|
||||||
|
if yes {
|
||||||
|
ans = "yes"
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"response": ans, "done": true})
|
||||||
|
}))
|
||||||
|
defer rrSrv.Close()
|
||||||
|
|
||||||
|
// LLM mock captures the rendered sources so we can assert what reached it.
|
||||||
|
var sawSources string
|
||||||
|
llm := func(_ context.Context, _, user string) (string, error) {
|
||||||
|
sawSources = user
|
||||||
|
return "answer text", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, llm).
|
||||||
|
WithReranker(reranker.New(rrSrv.URL, "qwen3"))
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "pass-rate logging"})
|
||||||
|
require.Nil(t, rpc["error"])
|
||||||
|
|
||||||
|
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||||
|
sources := result["sources"].([]any)
|
||||||
|
require.Len(t, sources, 1, "reranker should drop noise.md")
|
||||||
|
assert.Equal(t, "wiki/good.md", sources[0])
|
||||||
|
assert.Contains(t, sawSources, "good.md")
|
||||||
|
assert.NotContains(t, sawSources, "noise.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainAnswer_NoLLM(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "test"})
|
||||||
|
assert.NotNil(t, rpc["error"], "expected error when answerLLM is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainAnswer_Synthesizes(t *testing.T) {
|
||||||
|
brainDir := brainDirWithContent(t)
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, mockAnswerLLM("Pass-rate logging is described in spec."))
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "pass-rate logging"})
|
||||||
|
require.Nil(t, rpc["error"])
|
||||||
|
|
||||||
|
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||||
|
assert.Equal(t, "Pass-rate logging is described in spec.", result["answer"])
|
||||||
|
assert.NotEmpty(t, result["sources"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClassify_ReturnsJSON(t *testing.T) {
|
||||||
|
llmResp := `{"type":"spec","title":"My Spec","tags":["go","mcp"]}`
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp))
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "# My Spec\n\nThis is a Go MCP spec."})
|
||||||
|
require.Nil(t, rpc["error"])
|
||||||
|
|
||||||
|
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||||
|
assert.Equal(t, "spec", result["type"])
|
||||||
|
assert.Equal(t, "My Spec", result["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClassify_StripsFences(t *testing.T) {
|
||||||
|
llmResp := "```json\n{\"type\":\"note\",\"title\":\"T\",\"tags\":[]}\n```"
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp))
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "some text"})
|
||||||
|
require.Nil(t, rpc["error"])
|
||||||
|
|
||||||
|
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||||
|
assert.Equal(t, "note", result["type"])
|
||||||
|
}
|
||||||
38
ingestion/internal/oauth/metadata.go
Normal file
38
ingestion/internal/oauth/metadata.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Package oauth implements a minimal OAuth 2.0 client_credentials flow
|
||||||
|
// for the brain MCP server. Designed for claude.ai's custom MCP integration
|
||||||
|
// UI, which only supports OAuth (no static-Bearer field). The flow trades
|
||||||
|
// a registered client_id + client_secret for the existing BRAIN_MCP_TOKEN —
|
||||||
|
// no JWTs, no expiry, no refresh — so the rest of the auth middleware is
|
||||||
|
// unchanged.
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetadataHandler serves RFC 8414 authorization-server metadata at
|
||||||
|
// GET /.well-known/oauth-authorization-server. issuer must be the public
|
||||||
|
// origin of the brain MCP (e.g. https://brain-mcp.d-ma.be); the handler
|
||||||
|
// derives the token endpoint from it.
|
||||||
|
//
|
||||||
|
// Mount with no auth — discovery must be reachable to anonymous callers.
|
||||||
|
func MetadataHandler(issuer string) http.HandlerFunc {
|
||||||
|
issuer = strings.TrimRight(issuer, "/")
|
||||||
|
body, _ := json.Marshal(struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
GrantTypes []string `json:"grant_types_supported"`
|
||||||
|
TokenEndpointAuthMeth []string `json:"token_endpoint_auth_methods_supported"`
|
||||||
|
}{
|
||||||
|
Issuer: issuer,
|
||||||
|
TokenEndpoint: issuer + "/oauth/token",
|
||||||
|
GrantTypes: []string{"client_credentials"},
|
||||||
|
TokenEndpointAuthMeth: []string{"client_secret_post", "client_secret_basic"},
|
||||||
|
})
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ingestion/internal/oauth/metadata_test.go
Normal file
41
ingestion/internal/oauth/metadata_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package oauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetadataHandler_ReturnsJSON(t *testing.T) {
|
||||||
|
h := oauth.MetadataHandler("https://brain-mcp.d-ma.be")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"])
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"])
|
||||||
|
assert.ElementsMatch(t, []any{"client_credentials"}, body["grant_types_supported"])
|
||||||
|
assert.ElementsMatch(t,
|
||||||
|
[]any{"client_secret_post", "client_secret_basic"},
|
||||||
|
body["token_endpoint_auth_methods_supported"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataHandler_StripsTrailingSlashFromIssuer(t *testing.T) {
|
||||||
|
h := oauth.MetadataHandler("https://brain-mcp.d-ma.be/")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"])
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"])
|
||||||
|
}
|
||||||
87
ingestion/internal/oauth/token.go
Normal file
87
ingestion/internal/oauth/token.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenConfig is the static configuration for the token endpoint. All
|
||||||
|
// three fields are required.
|
||||||
|
type TokenConfig struct {
|
||||||
|
// ClientID and ClientSecret are the single accepted credentials.
|
||||||
|
// claude.ai's custom-MCP UI persists these on its side.
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
// AccessToken is the bearer value handed back on a successful
|
||||||
|
// exchange. In this deployment it is BRAIN_MCP_TOKEN — the same
|
||||||
|
// static token the rest of the auth middleware already accepts —
|
||||||
|
// so no JWT machinery is needed downstream.
|
||||||
|
AccessToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenHandler serves POST /oauth/token. Implements the
|
||||||
|
// client_credentials grant only, with client_secret_post and
|
||||||
|
// client_secret_basic auth methods (both advertised by MetadataHandler).
|
||||||
|
// Errors follow RFC 6749 §5.2 — JSON body with an "error" field.
|
||||||
|
//
|
||||||
|
// Mount with no auth — credentials live in the request body / header.
|
||||||
|
func TokenHandler(cfg TokenConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
writeOAuthError(w, http.StatusMethodNotAllowed, "invalid_request", "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "form parse")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.PostForm.Get("grant_type") != "client_credentials" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||||
|
"only client_credentials is supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, clientSecret := extractClientCreds(r)
|
||||||
|
if !constantTimeEqual(clientID, cfg.ClientID) ||
|
||||||
|
!constantTimeEqual(clientSecret, cfg.ClientSecret) {
|
||||||
|
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "bad credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}{cfg.AccessToken, "bearer"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClientCreds returns the client_id and client_secret pair from
|
||||||
|
// either client_secret_basic (HTTP Basic) or client_secret_post (form
|
||||||
|
// fields). When both are present, Basic wins per RFC 6749 §2.3.1.
|
||||||
|
func extractClientCreds(r *http.Request) (string, string) {
|
||||||
|
if id, secret, ok := r.BasicAuth(); ok {
|
||||||
|
return id, secret
|
||||||
|
}
|
||||||
|
return r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqual(a, b string) bool {
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
}{code, desc})
|
||||||
|
}
|
||||||
134
ingestion/internal/oauth/token_test.go
Normal file
134
ingestion/internal/oauth/token_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package oauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTokenServer() *httptest.Server {
|
||||||
|
return httptest.NewServer(oauth.TokenHandler(oauth.TokenConfig{
|
||||||
|
ClientID: "the-client",
|
||||||
|
ClientSecret: "the-secret",
|
||||||
|
AccessToken: "BRAIN_TOKEN_VALUE",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(t *testing.T, srv *httptest.Server, vals url.Values, basic [2]string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", strings.NewReader(vals.Encode()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if basic[0] != "" {
|
||||||
|
req.SetBasicAuth(basic[0], basic[1])
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientSecretPost_Success(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
resp := postForm(t, srv, url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"client_id": {"the-client"},
|
||||||
|
"client_secret": {"the-secret"},
|
||||||
|
}, [2]string{})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
assert.Equal(t, "BRAIN_TOKEN_VALUE", body["access_token"])
|
||||||
|
assert.Equal(t, "bearer", body["token_type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientSecretBasic_Success(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
resp := postForm(t, srv,
|
||||||
|
url.Values{"grant_type": {"client_credentials"}},
|
||||||
|
[2]string{"the-client", "the-secret"},
|
||||||
|
)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_WrongSecret(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
resp := postForm(t, srv, url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"client_id": {"the-client"},
|
||||||
|
"client_secret": {"wrong"},
|
||||||
|
}, [2]string{})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
assert.Equal(t, "invalid_client", body["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_BadGrantType(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
resp := postForm(t, srv, url.Values{
|
||||||
|
"grant_type": {"password"},
|
||||||
|
"client_id": {"the-client"},
|
||||||
|
"client_secret": {"the-secret"},
|
||||||
|
}, [2]string{})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
assert.Equal(t, "unsupported_grant_type", body["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_RejectsGet(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
resp, err := http.Get(srv.URL + "/oauth/token")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_BasicMalformed_FallsThrough(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
// Malformed (non-base64) Authorization header — handler should treat
|
||||||
|
// the request as missing creds, not crash.
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token",
|
||||||
|
strings.NewReader("grant_type=client_credentials"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Authorization", "Basic ###not-base64###")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_BasicNoColon(t *testing.T) {
|
||||||
|
srv := newTokenServer()
|
||||||
|
defer srv.Close()
|
||||||
|
// "client-only" base64 — missing the `:secret` half.
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte("the-client"))
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token",
|
||||||
|
strings.NewReader("grant_type=client_credentials"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Authorization", "Basic "+enc)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
119
ingestion/internal/reranker/reranker.go
Normal file
119
ingestion/internal/reranker/reranker.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Package reranker scores (query, document) pairs against a cross-encoder
|
||||||
|
// served by an Ollama-compatible backend.
|
||||||
|
//
|
||||||
|
// Wire format is Ollama's `/api/generate`. The model is prompted with the
|
||||||
|
// Qwen3-Reranker yes/no template — the canonical interface the model
|
||||||
|
// itself was trained against — and the first token of the response is
|
||||||
|
// treated as a binary relevance vote: "yes" → 1.0, anything else → 0.0.
|
||||||
|
// Ties are expected to be broken by the caller's primary retrieval score
|
||||||
|
// (e.g. BM25), so the binary signal is a filter rather than a ranking
|
||||||
|
// substitute.
|
||||||
|
package reranker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts rerank requests to an Ollama-compatible endpoint.
|
||||||
|
type Client struct {
|
||||||
|
URL string
|
||||||
|
Model string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. Returns nil when url is empty so callers can
|
||||||
|
// treat a missing BRAIN_RERANKER_URL as "feature disabled" with a single
|
||||||
|
// nil check.
|
||||||
|
func New(url, model string) *Client {
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
URL: strings.TrimRight(url, "/"),
|
||||||
|
Model: model,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score returns one [0, 1] relevance score per input document, parallel
|
||||||
|
// to the input order. Each (query, doc) pair is scored independently —
|
||||||
|
// Qwen3-Reranker is a cross-encoder and expects per-pair calls.
|
||||||
|
func (c *Client) Score(ctx context.Context, query string, docs []string) ([]float64, error) {
|
||||||
|
out := make([]float64, len(docs))
|
||||||
|
for i, doc := range docs {
|
||||||
|
s, err := c.scoreOne(ctx, query, doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rerank doc %d: %w", i, err)
|
||||||
|
}
|
||||||
|
out[i] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) scoreOne(ctx context.Context, query, doc string) (float64, error) {
|
||||||
|
prompt := buildPrompt(query, doc)
|
||||||
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
|
"model": c.Model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
"options": map[string]any{
|
||||||
|
"num_predict": 4,
|
||||||
|
"temperature": 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.URL+"/api/generate", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return parseYesNo(out.Response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPrompt assembles the Qwen3-Reranker chat template. Kept verbatim
|
||||||
|
// because the model was trained on this exact wording.
|
||||||
|
func buildPrompt(query, doc string) string {
|
||||||
|
return "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n" +
|
||||||
|
"<|im_start|>user\n<Instruct>: Given a web search query, retrieve relevant passages that answer the query\n" +
|
||||||
|
"<Query>: " + query + "\n" +
|
||||||
|
"<Document>: " + doc + "<|im_end|>\n" +
|
||||||
|
"<|im_start|>assistant\n<think>\n\n</think>\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYesNo extracts the first meaningful token from response and
|
||||||
|
// returns 1.0 when it starts with "yes" (case-insensitive), 0.0 otherwise.
|
||||||
|
// Any leading whitespace, `<think>` block, or punctuation is skipped.
|
||||||
|
func parseYesNo(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
// Strip any `<think>…</think>` block the model may emit even with empty thinking.
|
||||||
|
if idx := strings.Index(s, "</think>"); idx != -1 {
|
||||||
|
s = strings.TrimSpace(s[idx+len("</think>"):])
|
||||||
|
}
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
if strings.HasPrefix(s, "yes") {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
119
ingestion/internal/reranker/reranker_test.go
Normal file
119
ingestion/internal/reranker/reranker_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package reranker_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeOllama responds to /api/generate based on a per-document
|
||||||
|
// {needle → answer} map: if the prompt contains the needle, returns
|
||||||
|
// the mapped answer.
|
||||||
|
type fakeOllama struct {
|
||||||
|
t *testing.T
|
||||||
|
answers map[string]string // needle → "yes" or "no"
|
||||||
|
calls int
|
||||||
|
lastBody map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeOllama) handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(f.t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(f.t, "/api/generate", r.URL.Path)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(f.t, err)
|
||||||
|
var p map[string]any
|
||||||
|
require.NoError(f.t, json.Unmarshal(body, &p))
|
||||||
|
f.calls++
|
||||||
|
f.lastBody = p
|
||||||
|
prompt := p["prompt"].(string)
|
||||||
|
answer := "no"
|
||||||
|
for needle, a := range f.answers {
|
||||||
|
if strings.Contains(prompt, needle) {
|
||||||
|
answer = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"model": p["model"], "response": answer, "done": true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_EmptyURLReturnsNil(t *testing.T) {
|
||||||
|
assert.Nil(t, reranker.New("", "model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_YesAndNoOrdered(t *testing.T) {
|
||||||
|
f := &fakeOllama{t: t, answers: map[string]string{
|
||||||
|
"alpha doc": "yes",
|
||||||
|
"beta doc": "no",
|
||||||
|
"gamma doc": "yes",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
c := reranker.New(srv.URL, "test-model")
|
||||||
|
require.NotNil(t, c)
|
||||||
|
|
||||||
|
scores, err := c.Score(context.Background(), "what is alpha",
|
||||||
|
[]string{"alpha doc body", "beta doc body", "gamma doc body"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, scores, 3)
|
||||||
|
assert.Equal(t, 1.0, scores[0])
|
||||||
|
assert.Equal(t, 0.0, scores[1])
|
||||||
|
assert.Equal(t, 1.0, scores[2])
|
||||||
|
assert.Equal(t, 3, f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_SendsCorrectShape(t *testing.T) {
|
||||||
|
f := &fakeOllama{t: t, answers: map[string]string{"hello": "yes"}}
|
||||||
|
srv := httptest.NewServer(f.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
c := reranker.New(srv.URL, "qwen3-rerank")
|
||||||
|
_, err := c.Score(context.Background(), "greeting", []string{"hello world"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "qwen3-rerank", f.lastBody["model"])
|
||||||
|
prompt := f.lastBody["prompt"].(string)
|
||||||
|
assert.Contains(t, prompt, "greeting")
|
||||||
|
assert.Contains(t, prompt, "hello world")
|
||||||
|
assert.Contains(t, prompt, `"yes" or "no"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_HandlesAmbiguousResponse(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"response": "maybe — unclear", "done": true})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := reranker.New(srv.URL, "m")
|
||||||
|
scores, err := c.Score(context.Background(), "q", []string{"d"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Anything that does not start with "yes" (case-insensitive, after
|
||||||
|
// whitespace/think trim) is treated as "no" = 0.
|
||||||
|
assert.Equal(t, []float64{0}, scores)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_EmptyDocsReturnsEmpty(t *testing.T) {
|
||||||
|
c := reranker.New("http://127.0.0.1:1", "m")
|
||||||
|
scores, err := c.Score(context.Background(), "q", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, scores)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_UpstreamErrorPropagates(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := reranker.New(srv.URL, "m")
|
||||||
|
_, err := c.Score(context.Background(), "q", []string{"d"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -3,38 +3,92 @@ package search
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VectorSearcher returns the top-limit nearest paths by cosine
|
||||||
|
// distance. The vectorstore package implements this against pgvector.
|
||||||
|
type VectorSearcher interface {
|
||||||
|
Search(ctx context.Context, query []float32, limit int) ([]VectorHit, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorHit is a single path + distance pair from a vector search.
|
||||||
|
// Re-declared here (rather than imported) to keep search package
|
||||||
|
// free of vectorstore/embed deps and to make stubbing trivial in tests.
|
||||||
|
type VectorHit struct {
|
||||||
|
Path string
|
||||||
|
Distance float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embedder turns a query string into a dense vector. The embed package
|
||||||
|
// implements this against Ollama's /api/embed.
|
||||||
|
type Embedder interface {
|
||||||
|
Embed(ctx context.Context, text string) ([]float32, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Result is a single search hit from the brain wiki.
|
// Result is a single search hit from the brain wiki.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Excerpt string `json:"excerpt"`
|
Excerpt string `json:"excerpt"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query searches all .md files under brainDir/wiki/ for pages containing
|
// QueryOptions configures a search.
|
||||||
// any of the whitespace-separated terms in query. Returns up to limit results
|
//
|
||||||
// sorted by score descending.
|
// When Wing is set, the walk is restricted to brain/wiki/<wing>/.
|
||||||
func Query(brainDir, query string, limit int) ([]Result, error) {
|
// When Hall is additionally set, the walk is restricted to
|
||||||
if limit <= 0 {
|
// brain/wiki/<wing>/<hall>/. Without either, the legacy walk over
|
||||||
limit = 5
|
// brain/knowledge/ and brain/wiki/ is used.
|
||||||
|
//
|
||||||
|
// When both Vector and Embedder are non-nil, results are computed
|
||||||
|
// hybridly: BM25 and vector candidate lists are merged via Reciprocal
|
||||||
|
// Rank Fusion. With either nil the function falls back to BM25 only,
|
||||||
|
// keeping behaviour unchanged for callers that have not opted in.
|
||||||
|
type QueryOptions struct {
|
||||||
|
Query string
|
||||||
|
Limit int
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Vector VectorSearcher
|
||||||
|
Embedder Embedder
|
||||||
}
|
}
|
||||||
terms := strings.Fields(strings.ToLower(query))
|
|
||||||
|
// Query searches the brain. Returns up to opts.Limit results sorted by
|
||||||
|
// score descending. Empty query returns nil.
|
||||||
|
func Query(brainDir string, opts QueryOptions) ([]Result, error) {
|
||||||
|
return QueryContext(context.Background(), brainDir, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContext is the cancellable variant of Query. Hybrid retrieval
|
||||||
|
// requires a context because both the embedder and the vector store are
|
||||||
|
// network calls.
|
||||||
|
func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Result, error) {
|
||||||
|
if opts.Limit <= 0 {
|
||||||
|
opts.Limit = 5
|
||||||
|
}
|
||||||
|
terms := strings.Fields(strings.ToLower(opts.Query))
|
||||||
if len(terms) == 0 {
|
if len(terms) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []Result
|
roots, err := resolveRoots(brainDir, opts.Wing, opts.Hall)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
for _, subdir := range []string{"knowledge", "wiki"} {
|
var results []Result
|
||||||
dir := filepath.Join(brainDir, subdir)
|
for _, dir := range roots {
|
||||||
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
|
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -46,13 +100,11 @@ func Query(brainDir, query string, limit int) ([]Result, error) {
|
|||||||
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
|
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lower := strings.ToLower(string(content))
|
lower := strings.ToLower(string(content))
|
||||||
score := 0
|
score := 0
|
||||||
for _, term := range terms {
|
for _, term := range terms {
|
||||||
@@ -61,18 +113,19 @@ func Query(brainDir, query string, limit int) ([]Result, error) {
|
|||||||
if score == 0 {
|
if score == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rel, err := filepath.Rel(brainDir, path)
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("rel path: %w", err)
|
return fmt.Errorf("rel path: %w", err)
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
|
wing, hall := extractWingHall(string(content), rel)
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Path: rel,
|
Path: rel,
|
||||||
Title: extractTitle(string(content), d.Name()),
|
Title: extractTitle(string(content), d.Name()),
|
||||||
Excerpt: excerpt(string(content), 300),
|
Excerpt: excerpt(string(content), 300),
|
||||||
Score: score,
|
Score: score,
|
||||||
|
Wing: wing,
|
||||||
|
Hall: hall,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -84,12 +137,177 @@ func Query(brainDir, query string, limit int) ([]Result, error) {
|
|||||||
sort.Slice(results, func(i, j int) bool {
|
sort.Slice(results, func(i, j int) bool {
|
||||||
return results[i].Score > results[j].Score
|
return results[i].Score > results[j].Score
|
||||||
})
|
})
|
||||||
if len(results) > limit {
|
|
||||||
results = results[:limit]
|
// Hybrid scoring kicks in only when both the embedder and the
|
||||||
|
// vector store are wired and BM25 actually returned candidates.
|
||||||
|
if opts.Vector != nil && opts.Embedder != nil && len(results) > 0 {
|
||||||
|
merged, err := hybridMerge(ctx, brainDir, opts, results)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("search: hybrid merge failed, falling back to BM25", "err", err)
|
||||||
|
} else {
|
||||||
|
results = merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > opts.Limit {
|
||||||
|
results = results[:opts.Limit]
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rrfK is the constant in the Reciprocal Rank Fusion formula. 60 is
|
||||||
|
// standard (Cormack et al. 2009) and parameter-free in practice.
|
||||||
|
const rrfK = 60.0
|
||||||
|
|
||||||
|
// hybridMerge embeds the query, runs a vector search, and merges its
|
||||||
|
// candidates with the BM25 list via Reciprocal Rank Fusion. Results
|
||||||
|
// that came only from the vector side are hydrated by reading the
|
||||||
|
// note's frontmatter for title/wing/hall and excerpting the body.
|
||||||
|
//
|
||||||
|
// rrf(d) = sum_r 1 / (k + rank_r(d)) over rankers r ∈ {BM25, vector}.
|
||||||
|
func hybridMerge(ctx context.Context, brainDir string, opts QueryOptions, bm25 []Result) ([]Result, error) {
|
||||||
|
q, err := opts.Embedder.Embed(ctx, opts.Query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("embed query: %w", err)
|
||||||
|
}
|
||||||
|
vectorLimit := opts.Limit * 4
|
||||||
|
if vectorLimit < 20 {
|
||||||
|
vectorLimit = 20
|
||||||
|
}
|
||||||
|
hits, err := opts.Vector.Search(ctx, q, vectorLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vector search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rrf := make(map[string]float64)
|
||||||
|
byPath := make(map[string]Result)
|
||||||
|
for rank, r := range bm25 {
|
||||||
|
rrf[r.Path] += 1.0 / (rrfK + float64(rank+1))
|
||||||
|
byPath[r.Path] = r
|
||||||
|
}
|
||||||
|
for rank, h := range hits {
|
||||||
|
if opts.Wing != "" && !pathInScope(h.Path, opts.Wing, opts.Hall) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rrf[h.Path] += 1.0 / (rrfK + float64(rank+1))
|
||||||
|
if _, seen := byPath[h.Path]; !seen {
|
||||||
|
r, err := hydrate(brainDir, h.Path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("search: hydrate failed for vector hit", "path", h.Path, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byPath[h.Path] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := make([]Result, 0, len(byPath))
|
||||||
|
for p, r := range byPath {
|
||||||
|
r.Score = int(rrf[p] * 1e6) // scale to int for stable JSON; relative order is what matters
|
||||||
|
merged = append(merged, r)
|
||||||
|
}
|
||||||
|
sort.Slice(merged, func(i, j int) bool {
|
||||||
|
return merged[i].Score > merged[j].Score
|
||||||
|
})
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathInScope reports whether a wiki path satisfies the wing/hall filter.
|
||||||
|
func pathInScope(relPath, wing, hall string) bool {
|
||||||
|
prefix := "wiki/" + brain.Sanitise(wing) + "/"
|
||||||
|
if hall != "" {
|
||||||
|
prefix += hall + "/"
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(relPath, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrate reads a single note from disk and returns a Result with title,
|
||||||
|
// excerpt, wing, and hall populated. Used for paths that surface only
|
||||||
|
// via vector search.
|
||||||
|
func hydrate(brainDir, relPath string) (Result, error) {
|
||||||
|
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||||
|
content, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
wing, hall := extractWingHall(string(content), relPath)
|
||||||
|
return Result{
|
||||||
|
Path: relPath,
|
||||||
|
Title: extractTitle(string(content), filepath.Base(relPath)),
|
||||||
|
Excerpt: excerpt(string(content), 300),
|
||||||
|
Wing: wing,
|
||||||
|
Hall: hall,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRoots returns the directories to walk for the given wing/hall
|
||||||
|
// filters. Validates hall against the closed vocabulary when set.
|
||||||
|
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
||||||
|
if hall != "" && !brain.IsValidHall(hall) {
|
||||||
|
return nil, fmt.Errorf("invalid hall %q", hall)
|
||||||
|
}
|
||||||
|
if wing != "" {
|
||||||
|
w := brain.Sanitise(wing)
|
||||||
|
if w == "" {
|
||||||
|
return nil, fmt.Errorf("invalid wing %q", wing)
|
||||||
|
}
|
||||||
|
if hall != "" {
|
||||||
|
return []string{filepath.Join(brainDir, "wiki", w, hall)}, nil
|
||||||
|
}
|
||||||
|
return []string{filepath.Join(brainDir, "wiki", w)}, nil
|
||||||
|
}
|
||||||
|
if hall != "" {
|
||||||
|
return nil, fmt.Errorf("hall filter requires wing")
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
filepath.Join(brainDir, "knowledge"),
|
||||||
|
filepath.Join(brainDir, "wiki"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWingHall reads wing/hall from frontmatter first, falling back to
|
||||||
|
// path segments brain/wiki/<wing>/<hall>/.
|
||||||
|
func extractWingHall(content, relPath string) (wing, hall string) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
inFrontmatter := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !inFrontmatter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "wing":
|
||||||
|
wing = v
|
||||||
|
case "hall":
|
||||||
|
hall = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wing != "" && hall != "" {
|
||||||
|
return wing, hall
|
||||||
|
}
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) >= 4 && parts[0] == "wiki" {
|
||||||
|
if wing == "" {
|
||||||
|
wing = parts[1]
|
||||||
|
}
|
||||||
|
if hall == "" && brain.IsValidHall(parts[2]) {
|
||||||
|
hall = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wing, hall
|
||||||
|
}
|
||||||
|
|
||||||
func extractTitle(content, filename string) string {
|
func extractTitle(content, filename string) string {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
inFrontmatter := false
|
inFrontmatter := false
|
||||||
@@ -113,7 +331,6 @@ func extractTitle(content, filename string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func excerpt(content string, maxLen int) string {
|
func excerpt(content string, maxLen int) string {
|
||||||
// Skip frontmatter, return first maxLen chars of body.
|
|
||||||
parts := strings.SplitN(content, "---", 3)
|
parts := strings.SplitN(content, "---", 3)
|
||||||
body := content
|
body := content
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package search_test
|
package search_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +13,69 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type stubEmbedder struct{ vec []float32 }
|
||||||
|
|
||||||
|
func (s stubEmbedder) Embed(_ context.Context, _ string) ([]float32, error) { return s.vec, nil }
|
||||||
|
|
||||||
|
type stubVector struct{ hits []search.VectorHit }
|
||||||
|
|
||||||
|
func (s stubVector) Search(_ context.Context, _ []float32, _ int) ([]search.VectorHit, error) {
|
||||||
|
return s.hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_HybridRRFPromotesVectorOnlyHit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
// BM25-keyword note (matches "lejpa" once)
|
||||||
|
{"wiki/jepa-fx/facts/foo.md", "---\ntitle: Foo\n---\nlejpa keyword\n"},
|
||||||
|
// Semantically related note that does NOT contain the keyword.
|
||||||
|
{"wiki/jepa-fx/facts/semantic.md", "---\ntitle: Semantic\n---\nNo keyword in body.\n"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
embedder := stubEmbedder{vec: []float32{0.1}}
|
||||||
|
vector := stubVector{hits: []search.VectorHit{
|
||||||
|
{Path: "wiki/jepa-fx/facts/semantic.md", Distance: 0.05}, // best vector match
|
||||||
|
{Path: "wiki/jepa-fx/facts/foo.md", Distance: 0.10},
|
||||||
|
}}
|
||||||
|
|
||||||
|
got, err := search.Query(dir, search.QueryOptions{
|
||||||
|
Query: "lejpa",
|
||||||
|
Limit: 5,
|
||||||
|
Vector: vector,
|
||||||
|
Embedder: embedder,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2, "vector-only hit should be hydrated into results")
|
||||||
|
paths := []string{got[0].Path, got[1].Path}
|
||||||
|
assert.Contains(t, paths, "wiki/jepa-fx/facts/foo.md")
|
||||||
|
assert.Contains(t, paths, "wiki/jepa-fx/facts/semantic.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_HybridFallsBackOnEmbedderError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "x.md"), []byte("keyword foo"), 0o644))
|
||||||
|
|
||||||
|
embedder := errorEmbedder{}
|
||||||
|
vector := stubVector{}
|
||||||
|
got, err := search.Query(dir, search.QueryOptions{
|
||||||
|
Query: "keyword", Limit: 5, Vector: vector, Embedder: embedder,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1, "BM25 result should still come back when embedder fails")
|
||||||
|
assert.Equal(t, "wiki/x.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorEmbedder struct{}
|
||||||
|
|
||||||
|
func (errorEmbedder) Embed(_ context.Context, _ string) ([]float32, error) {
|
||||||
|
return nil, assert.AnError
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
@@ -27,7 +91,7 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
|
|
||||||
results, err := search.Query(dir, "retry transient", 5)
|
results, err := search.Query(dir, search.QueryOptions{Query: "retry transient", Limit: 5})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, results, 1)
|
require.Len(t, results, 1)
|
||||||
assert.Equal(t, "knowledge/retry-logic.md", results[0].Path)
|
assert.Equal(t, "knowledge/retry-logic.md", results[0].Path)
|
||||||
@@ -36,6 +100,49 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
assert.Contains(t, results[0].Excerpt, "Retry")
|
assert.Contains(t, results[0].Excerpt, "Retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearch_WingHallScoping(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
{"wiki/jepa-fx/decisions/val-vol.md", "---\nwing: jepa-fx\nhall: decisions\n---\nval-vol-r2 keyword.\n"},
|
||||||
|
{"wiki/jepa-fx/facts/architecture.md", "---\nwing: jepa-fx\nhall: facts\n---\nval-vol-r2 keyword in facts.\n"},
|
||||||
|
{"wiki/hyperguild/decisions/routing.md", "---\nwing: hyperguild\nhall: decisions\n---\nval-vol-r2 reference.\n"},
|
||||||
|
{"knowledge/loose.md", "---\n---\nval-vol-r2 in knowledge.\n"},
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dir, p.rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filter: walk both knowledge/ and wiki/ — all 4 match.
|
||||||
|
got, err := search.Query(dir, search.QueryOptions{Query: "val-vol-r2", Limit: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, got, 4)
|
||||||
|
|
||||||
|
// Wing scope: 2 jepa-fx hits, no hyperguild, no knowledge.
|
||||||
|
got, err = search.Query(dir, search.QueryOptions{Query: "val-vol-r2", Limit: 10, Wing: "jepa-fx"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
for _, r := range got {
|
||||||
|
assert.Equal(t, "jepa-fx", r.Wing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wing+Hall scope: 1 hit.
|
||||||
|
got, err = search.Query(dir, search.QueryOptions{Query: "val-vol-r2", Limit: 10, Wing: "jepa-fx", Hall: "decisions"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "jepa-fx", got[0].Wing)
|
||||||
|
assert.Equal(t, "decisions", got[0].Hall)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", got[0].Path)
|
||||||
|
|
||||||
|
// Invalid hall rejected.
|
||||||
|
_, err = search.Query(dir, search.QueryOptions{Query: "x", Wing: "jepa-fx", Hall: "garbage"})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Hall without wing rejected.
|
||||||
|
_, err = search.Query(dir, search.QueryOptions{Query: "x", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearch_RespectsLimit(t *testing.T) {
|
func TestSearch_RespectsLimit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
@@ -46,7 +153,7 @@ func TestSearch_RespectsLimit(t *testing.T) {
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
results, err := search.Query(dir, "retry", 3)
|
results, err := search.Query(dir, search.QueryOptions{Query: "retry", Limit: 3})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.LessOrEqual(t, len(results), 3)
|
assert.LessOrEqual(t, len(results), 3)
|
||||||
}
|
}
|
||||||
|
|||||||
155
ingestion/internal/vectorstore/pg.go
Normal file
155
ingestion/internal/vectorstore/pg.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Package vectorstore stores brain note embeddings in pgvector on the
|
||||||
|
// shared postgres18 instance. One row per markdown path, cosine-distance
|
||||||
|
// indexed via HNSW for sub-millisecond top-k retrieval.
|
||||||
|
package vectorstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hit is a single result from a cosine-distance search.
|
||||||
|
type Hit struct {
|
||||||
|
Path string
|
||||||
|
Distance float64 // 0 = identical, 2 = opposite
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGStore is a pgvector-backed embeddings store. Construct with New and
|
||||||
|
// call Init once to create the table + HNSW index. Use Close to release
|
||||||
|
// the underlying pool.
|
||||||
|
type PGStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens a connection pool against dsn (a libpq-style URL). Caller
|
||||||
|
// owns the resulting *PGStore and must invoke Close.
|
||||||
|
func New(ctx context.Context, dsn string) (*PGStore, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
return &PGStore{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying connection pool.
|
||||||
|
func (s *PGStore) Close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init creates the brain_embeddings table and its HNSW index if they
|
||||||
|
// don't already exist. Safe to call on every startup. Assumes the
|
||||||
|
// `vector` extension is already installed (one-time DBA setup; see
|
||||||
|
// scripts/brain-embeddings-init.sql).
|
||||||
|
func (s *PGStore) Init(ctx context.Context) error {
|
||||||
|
const ddl = `
|
||||||
|
CREATE TABLE IF NOT EXISTS brain_embeddings (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
embedding vector(768) NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_embeddings_embedding_idx
|
||||||
|
ON brain_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||||
|
`
|
||||||
|
_, err := s.pool.Exec(ctx, ddl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts or replaces the embedding for path. Embedding must be
|
||||||
|
// 768-dim (nomic-embed-text). Caller is responsible for normalising
|
||||||
|
// paths to forward-slash form.
|
||||||
|
func (s *PGStore) Upsert(ctx context.Context, path string, embedding []float32) error {
|
||||||
|
if len(embedding) != 768 {
|
||||||
|
return fmt.Errorf("expected 768-dim embedding, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO brain_embeddings (path, embedding, updated_at)
|
||||||
|
VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (path) DO UPDATE
|
||||||
|
SET embedding = EXCLUDED.embedding, updated_at = now()
|
||||||
|
`, path, vectorLiteral(embedding))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the row at path. No-op when the row doesn't exist.
|
||||||
|
func (s *PGStore) Delete(ctx context.Context, path string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM brain_embeddings WHERE path = $1`, path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns the top-limit nearest paths by cosine distance.
|
||||||
|
func (s *PGStore) Search(ctx context.Context, query []float32, limit int) ([]Hit, error) {
|
||||||
|
if len(query) != 768 {
|
||||||
|
return nil, fmt.Errorf("expected 768-dim query, got %d", len(query))
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT path, embedding <=> $1 AS distance
|
||||||
|
FROM brain_embeddings
|
||||||
|
ORDER BY embedding <=> $1
|
||||||
|
LIMIT $2
|
||||||
|
`, vectorLiteral(query), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var hits []Hit
|
||||||
|
for rows.Next() {
|
||||||
|
var h Hit
|
||||||
|
if err := rows.Scan(&h.Path, &h.Distance); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
hits = append(hits, h)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KnownPaths returns the path set already present in the store. Used by
|
||||||
|
// the watcher to diff against the wiki/ tree and decide what to upsert.
|
||||||
|
func (s *PGStore) KnownPaths(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT path FROM brain_embeddings`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query paths: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[p] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// vectorLiteral renders a Go float32 slice as the literal representation
|
||||||
|
// pgvector accepts as a parametric input: `[v1,v2,...,vN]`.
|
||||||
|
func vectorLiteral(v []float32) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('[')
|
||||||
|
for i, x := range v {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%g", x)
|
||||||
|
}
|
||||||
|
b.WriteByte(']')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
91
ingestion/internal/vectorstore/pg_test.go
Normal file
91
ingestion/internal/vectorstore/pg_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package vectorstore_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// integration tests run against a real postgres18 + pgvector. Gated by
|
||||||
|
// BRAIN_PG_TEST_DSN so `task check` stays hermetic on hosts without a
|
||||||
|
// reachable database.
|
||||||
|
//
|
||||||
|
// To run:
|
||||||
|
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||||
|
// go test ./internal/vectorstore/... -run Integration
|
||||||
|
func dsn(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
v := os.Getenv("BRAIN_PG_TEST_DSN")
|
||||||
|
if v == "" {
|
||||||
|
t.Skip("BRAIN_PG_TEST_DSN not set; skipping pgvector integration tests")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func freshStore(t *testing.T) (*vectorstore.PGStore, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
s, err := vectorstore.New(ctx, dsn(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
require.NoError(t, s.Init(ctx))
|
||||||
|
// Clean slate per test.
|
||||||
|
_, _ = s.KnownPaths(ctx)
|
||||||
|
require.NoError(t, s.Delete(ctx, "%test-fixture%"))
|
||||||
|
return s, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(dim int, fill float32) []float32 {
|
||||||
|
v := make([]float32, dim)
|
||||||
|
for i := range v {
|
||||||
|
v[i] = fill
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_UpsertAndSearch(t *testing.T) {
|
||||||
|
s, ctx := freshStore(t)
|
||||||
|
|
||||||
|
require.NoError(t, s.Upsert(ctx, "wiki/a.md", vec(768, 1.0)))
|
||||||
|
require.NoError(t, s.Upsert(ctx, "wiki/b.md", vec(768, -1.0)))
|
||||||
|
|
||||||
|
hits, err := s.Search(ctx, vec(768, 1.0), 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(hits), 1)
|
||||||
|
assert.Equal(t, "wiki/a.md", hits[0].Path)
|
||||||
|
assert.InDelta(t, 0.0, hits[0].Distance, 1e-5)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = s.Delete(ctx, "wiki/a.md")
|
||||||
|
_ = s.Delete(ctx, "wiki/b.md")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_KnownPaths(t *testing.T) {
|
||||||
|
s, ctx := freshStore(t)
|
||||||
|
require.NoError(t, s.Upsert(ctx, "wiki/k.md", vec(768, 0.5)))
|
||||||
|
t.Cleanup(func() { _ = s.Delete(ctx, "wiki/k.md") })
|
||||||
|
|
||||||
|
paths, err := s.KnownPaths(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, ok := paths["wiki/k.md"]
|
||||||
|
assert.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsert_RejectsWrongDimension(t *testing.T) {
|
||||||
|
s := &vectorstore.PGStore{}
|
||||||
|
err := s.Upsert(context.Background(), "x", vec(100, 0))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_RejectsWrongDimension(t *testing.T) {
|
||||||
|
s := &vectorstore.PGStore{}
|
||||||
|
_, err := s.Search(context.Background(), vec(100, 0), 5)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
153
ingestion/internal/vectorstore/sync.go
Normal file
153
ingestion/internal/vectorstore/sync.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package vectorstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Embedder produces dense vectors. The embed package's Client satisfies
|
||||||
|
// this; it's declared locally so vectorstore doesn't depend on embed.
|
||||||
|
type Embedder interface {
|
||||||
|
Embed(ctx context.Context, text string) ([]float32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the subset of PGStore that Sync needs. Lets tests stub it.
|
||||||
|
type Store interface {
|
||||||
|
KnownPaths(ctx context.Context) (map[string]struct{}, error)
|
||||||
|
Upsert(ctx context.Context, path string, embedding []float32) error
|
||||||
|
Delete(ctx context.Context, path string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResult tallies what Sync did. Returned for logs / metrics; callers
|
||||||
|
// generally don't act on the fields directly.
|
||||||
|
type SyncResult struct {
|
||||||
|
Added int
|
||||||
|
Updated int
|
||||||
|
Deleted int
|
||||||
|
Errors []error
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDirs is the set of brainDir subdirectories whose .md files are
|
||||||
|
// embedded for vector retrieval. wiki/ holds LLM-extracted entity and
|
||||||
|
// source pages; knowledge/ holds curated hand-written entries.
|
||||||
|
var scanDirs = []string{"wiki", "knowledge"}
|
||||||
|
|
||||||
|
// Sync brings the embedding store in line with brain/{wiki,knowledge}/
|
||||||
|
// on disk:
|
||||||
|
// - new files (in the tree, not in the store) get embedded + upserted
|
||||||
|
// - files whose mtime exceeds the store's updated_at get re-embedded
|
||||||
|
// - files no longer on disk get deleted from the store
|
||||||
|
//
|
||||||
|
// Designed to be called on a ticker. Best-effort: per-file errors are
|
||||||
|
// collected into SyncResult.Errors and do not abort the run.
|
||||||
|
func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder) (SyncResult, error) {
|
||||||
|
var res SyncResult
|
||||||
|
if store == nil || embedder == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
known, err := store.KnownPaths(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("known paths: %w", err)
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, sub := range scanDirs {
|
||||||
|
root := filepath.Join(brainDir, sub)
|
||||||
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
seen[relSlash] = struct{}{}
|
||||||
|
|
||||||
|
if _, ok := known[relSlash]; ok {
|
||||||
|
// Already embedded — TODO: compare mtime once Store exposes
|
||||||
|
// updated_at so we re-embed on edit. For now, skip.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Errorf("read %s: %w", relSlash, readErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
vec, embErr := embedder.Embed(ctx, string(content))
|
||||||
|
if embErr != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Errorf("embed %s: %w", relSlash, embErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if upErr := store.Upsert(ctx, relSlash, vec); upErr != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Errorf("upsert %s: %w", relSlash, upErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res.Added++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("walk %s: %w", sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop rows whose file is gone.
|
||||||
|
for path := range known {
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := store.Delete(ctx, path); err != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Errorf("delete %s: %w", path, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Deleted++
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSync launches Sync on a ticker in a background goroutine. The
|
||||||
|
// goroutine exits when ctx is cancelled. Failures are logged via slog.
|
||||||
|
func StartSync(ctx context.Context, brainDir string, store Store, embedder Embedder, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Minute
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
// Run once immediately so first-boot doesn't wait a full tick.
|
||||||
|
if r, err := Sync(ctx, brainDir, store, embedder); err != nil {
|
||||||
|
slog.Error("embed sync failed", "err", err)
|
||||||
|
} else if r.Added+r.Deleted > 0 || len(r.Errors) > 0 {
|
||||||
|
slog.Info("embed sync", "added", r.Added, "deleted", r.Deleted, "errors", len(r.Errors))
|
||||||
|
for _, e := range r.Errors {
|
||||||
|
slog.Warn("embed sync item failed", "err", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if r, err := Sync(ctx, brainDir, store, embedder); err != nil {
|
||||||
|
slog.Error("embed sync failed", "err", err)
|
||||||
|
} else if r.Added+r.Deleted > 0 || len(r.Errors) > 0 {
|
||||||
|
slog.Info("embed sync", "added", r.Added, "deleted", r.Deleted, "errors", len(r.Errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
151
ingestion/internal/vectorstore/sync_test.go
Normal file
151
ingestion/internal/vectorstore/sync_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package vectorstore_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubStore struct {
|
||||||
|
known map[string]struct{}
|
||||||
|
upserts map[string][]float32
|
||||||
|
deletes []string
|
||||||
|
failNext error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) KnownPaths(_ context.Context) (map[string]struct{}, error) {
|
||||||
|
out := make(map[string]struct{}, len(s.known))
|
||||||
|
for k := range s.known {
|
||||||
|
out[k] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Upsert(_ context.Context, path string, v []float32) error {
|
||||||
|
if s.failNext != nil {
|
||||||
|
err := s.failNext
|
||||||
|
s.failNext = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.upserts == nil {
|
||||||
|
s.upserts = make(map[string][]float32)
|
||||||
|
}
|
||||||
|
s.upserts[path] = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Delete(_ context.Context, path string) error {
|
||||||
|
s.deletes = append(s.deletes, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubEmbedder struct {
|
||||||
|
vec []float32
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e stubEmbedder) Embed(_ context.Context, _ string) ([]float32, error) {
|
||||||
|
return e.vec, e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNote(t *testing.T, dir, rel, body string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(dir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_AddsNewFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/jepa-fx/facts/x.md", "body of x")
|
||||||
|
writeNote(t, dir, "wiki/jepa-fx/facts/y.md", "body of y")
|
||||||
|
|
||||||
|
store := &stubStore{known: map[string]struct{}{}}
|
||||||
|
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, res.Added)
|
||||||
|
assert.Empty(t, res.Deleted)
|
||||||
|
assert.Contains(t, store.upserts, "wiki/jepa-fx/facts/x.md")
|
||||||
|
assert.Contains(t, store.upserts, "wiki/jepa-fx/facts/y.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_SkipsAlreadyKnown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||||
|
|
||||||
|
store := &stubStore{known: map[string]struct{}{"wiki/a/facts/x.md": {}}}
|
||||||
|
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, res.Added)
|
||||||
|
assert.Empty(t, store.upserts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_DeletesDisappearedFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
||||||
|
// store has a path that doesn't exist on disk anymore
|
||||||
|
store := &stubStore{known: map[string]struct{}{"wiki/old/facts/ghost.md": {}}}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, &stubStoreWithDelete{stubStore: store}, stubEmbedder{vec: make([]float32, 768)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, res.Deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubStoreWithDelete is a thin wrapper to capture Delete calls;
|
||||||
|
// stubStore already implements Delete but we need the wrapper to mix
|
||||||
|
// store interfaces with sync-specific expectations.
|
||||||
|
type stubStoreWithDelete struct {
|
||||||
|
*stubStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_SkipsIndexFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/a/_index.md", "moc")
|
||||||
|
writeNote(t, dir, "wiki/a/facts/real.md", "body")
|
||||||
|
|
||||||
|
store := &stubStore{known: map[string]struct{}{}}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, store, stubEmbedder{vec: make([]float32, 768)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, res.Added)
|
||||||
|
assert.NotContains(t, store.upserts, "wiki/a/_index.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_ScansKnowledgeDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||||
|
writeNote(t, dir, "knowledge/2026-05-19-koala-gpu-setup.md", "knowledge body")
|
||||||
|
|
||||||
|
store := &stubStore{known: map[string]struct{}{}}
|
||||||
|
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, res.Added)
|
||||||
|
assert.Contains(t, store.upserts, "wiki/a/facts/x.md")
|
||||||
|
assert.Contains(t, store.upserts, "knowledge/2026-05-19-koala-gpu-setup.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_NoOpWhenComponentsNil(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, res.Added)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSync_CollectsEmbedderErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||||
|
store := &stubStore{known: map[string]struct{}{}}
|
||||||
|
emb := stubEmbedder{err: errors.New("upstream down")}
|
||||||
|
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, res.Added)
|
||||||
|
assert.Len(t, res.Errors, 1)
|
||||||
|
}
|
||||||
84
internal/auth/jwt.go
Normal file
84
internal/auth/jwt.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
||||||
|
// Audience is optional; leave empty to skip audience validation.
|
||||||
|
type Validator struct {
|
||||||
|
issuer string
|
||||||
|
audience string
|
||||||
|
jwksURI string
|
||||||
|
cache *jwk.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator fetches the OIDC discovery document from issuerURL, extracts
|
||||||
|
// jwks_uri, seeds the JWKS cache, and returns a ready Validator.
|
||||||
|
// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction.
|
||||||
|
func NewValidator(issuerURL, audience string) (*Validator, error) {
|
||||||
|
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
if doc.JWKSURI == "" {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cache := jwk.NewCache(ctx)
|
||||||
|
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
||||||
|
return nil, fmt.Errorf("register jwks cache: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||||
|
return nil, fmt.Errorf("initial jwks fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Validator{
|
||||||
|
issuer: issuerURL,
|
||||||
|
audience: audience,
|
||||||
|
jwksURI: doc.JWKSURI,
|
||||||
|
cache: cache,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parses and validates rawToken. Returns the subject claim on success.
|
||||||
|
func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||||
|
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get jwks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []jwt.ParseOption{
|
||||||
|
jwt.WithKeySet(keySet),
|
||||||
|
jwt.WithValidate(true),
|
||||||
|
jwt.WithIssuer(v.issuer),
|
||||||
|
}
|
||||||
|
if v.audience != "" {
|
||||||
|
opts = append(opts, jwt.WithAudience(v.audience))
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := jwt.ParseString(rawToken, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("validate jwt: %w", err)
|
||||||
|
}
|
||||||
|
return tok.Subject(), nil
|
||||||
|
}
|
||||||
169
internal/auth/jwt_test.go
Normal file
169
internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testKeys struct {
|
||||||
|
priv jwk.Key
|
||||||
|
pub jwk.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRSAKeys(t *testing.T) testKeys {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
priv, err := jwk.FromRaw(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, priv.Set(jwk.KeyIDKey, "test-kid"))
|
||||||
|
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||||
|
|
||||||
|
pub, err := jwk.PublicKeyOf(priv)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return testKeys{priv: priv, pub: pub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
set := jwk.NewSet()
|
||||||
|
require.NoError(t, set.AddKey(keys.pub))
|
||||||
|
jwksBytes, err := json.Marshal(set)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var srv *httptest.Server
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"jwks_uri": srv.URL + "/jwks",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(jwksBytes)
|
||||||
|
})
|
||||||
|
srv = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
b := jwt.NewBuilder().
|
||||||
|
Issuer(issuer).
|
||||||
|
Subject(subject).
|
||||||
|
Expiration(exp)
|
||||||
|
if audience != "" {
|
||||||
|
b = b.Audience([]string{audience})
|
||||||
|
}
|
||||||
|
tok, err := b.Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidator(t *testing.T) {
|
||||||
|
keys := generateRSAKeys(t)
|
||||||
|
srv := mockOIDCServer(t, keys)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v, err := auth.NewValidator(srv.URL, "brain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantSub string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid jwt",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantSub: "test-user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired jwt",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong issuer",
|
||||||
|
token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong audience",
|
||||||
|
token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered token",
|
||||||
|
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a jwt",
|
||||||
|
token: "not-a-jwt",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
sub, err := v.Validate(ctx, tc.token)
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Empty(t, sub)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantSub, sub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidator_NoAudience(t *testing.T) {
|
||||||
|
keys := generateRSAKeys(t)
|
||||||
|
srv := mockOIDCServer(t, keys)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v, err := auth.NewValidator(srv.URL, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Token without audience passes when audience validation is disabled.
|
||||||
|
tok, err := jwt.NewBuilder().
|
||||||
|
Issuer(srv.URL).
|
||||||
|
Subject("sub").
|
||||||
|
Expiration(time.Now().Add(time.Hour)).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sub, err := v.Validate(ctx, string(signed))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "sub", sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidator_BadDiscoveryURL(t *testing.T) {
|
||||||
|
_, err := auth.NewValidator("http://127.0.0.1:1", "brain")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
23
internal/auth/protected_resource.go
Normal file
23
internal/auth/protected_resource.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource metadata
|
||||||
|
// handler. Mount at GET /.well-known/oauth-protected-resource (no auth required).
|
||||||
|
func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc {
|
||||||
|
type metadata struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
AuthorizationServers []string `json:"authorization_servers"`
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(metadata{
|
||||||
|
Resource: resourceURL,
|
||||||
|
AuthorizationServers: []string{issuerURL},
|
||||||
|
})
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/auth/protected_resource_test.go
Normal file
28
internal/auth/protected_resource_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProtectedResourceHandler(t *testing.T) {
|
||||||
|
h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||||
|
assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"])
|
||||||
|
servers := body["authorization_servers"].([]any)
|
||||||
|
assert.Equal(t, "https://auth.d-ma.be", servers[0])
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
||||||
SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions
|
SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions
|
||||||
BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain
|
BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain
|
||||||
|
MCPAuthToken string // SUPERVISOR_MCP_TOKEN — optional bearer token for MCP HTTP; empty disables auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -28,6 +29,7 @@ func Load() (Config, error) {
|
|||||||
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
||||||
cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions")
|
cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions")
|
||||||
cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain")
|
cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain")
|
||||||
|
cfg.MCPAuthToken = os.Getenv("SUPERVISOR_MCP_TOKEN")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
t.Setenv("INGEST_BASE_URL", "")
|
t.Setenv("INGEST_BASE_URL", "")
|
||||||
t.Setenv("SUPERVISOR_SESSIONS_DIR", "")
|
t.Setenv("SUPERVISOR_SESSIONS_DIR", "")
|
||||||
t.Setenv("SUPERVISOR_BRAIN_DIR", "")
|
t.Setenv("SUPERVISOR_BRAIN_DIR", "")
|
||||||
|
t.Setenv("SUPERVISOR_MCP_TOKEN", "")
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -25,6 +26,7 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL)
|
assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL)
|
||||||
assert.Equal(t, "./brain/sessions", cfg.SessionsDir)
|
assert.Equal(t, "./brain/sessions", cfg.SessionsDir)
|
||||||
assert.Equal(t, "./brain", cfg.BrainDir)
|
assert.Equal(t, "./brain", cfg.BrainDir)
|
||||||
|
assert.Equal(t, "", cfg.MCPAuthToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
@@ -32,6 +34,7 @@ func TestLoadFromEnv(t *testing.T) {
|
|||||||
t.Setenv("LITELLM_BASE_URL", "http://localhost:4000")
|
t.Setenv("LITELLM_BASE_URL", "http://localhost:4000")
|
||||||
t.Setenv("LITELLM_API_KEY", "test-key")
|
t.Setenv("LITELLM_API_KEY", "test-key")
|
||||||
t.Setenv("SUPERVISOR_CONFIG_DIR", "/etc/supervisor")
|
t.Setenv("SUPERVISOR_CONFIG_DIR", "/etc/supervisor")
|
||||||
|
t.Setenv("SUPERVISOR_MCP_TOKEN", "secret-token")
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -39,4 +42,5 @@ func TestLoadFromEnv(t *testing.T) {
|
|||||||
assert.Equal(t, "http://localhost:4000", cfg.LiteLLMBaseURL)
|
assert.Equal(t, "http://localhost:4000", cfg.LiteLLMBaseURL)
|
||||||
assert.Equal(t, "test-key", cfg.LiteLLMAPIKey)
|
assert.Equal(t, "test-key", cfg.LiteLLMAPIKey)
|
||||||
assert.Equal(t, "/etc/supervisor", cfg.ConfigDir)
|
assert.Equal(t, "/etc/supervisor", cfg.ConfigDir)
|
||||||
|
assert.Equal(t, "secret-token", cfg.MCPAuthToken)
|
||||||
}
|
}
|
||||||
|
|||||||
101
internal/config/routing.go
Normal file
101
internal/config/routing.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoutingConfig holds the runtime configuration for the routing pod.
|
||||||
|
// Separate from Config because the routing pod's surface differs from the supervisor's.
|
||||||
|
type RoutingConfig struct {
|
||||||
|
Port string // ROUTING_PORT, default 3210
|
||||||
|
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
||||||
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://piguard:4000
|
||||||
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
|
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||||
|
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||||
|
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||||
|
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
||||||
|
// floor < ceil mathematical convention: Floor (default 0.90) is the
|
||||||
|
// UPPER boundary — at/above it, always route local; Ceil (default 0.70)
|
||||||
|
// is the LOWER boundary — below it, always route Claude. The band in
|
||||||
|
// between is the 50/50 sample zone. The naming follows the spec's policy
|
||||||
|
// vocabulary; see internal/routing/policy.go for the consumer.
|
||||||
|
RouteLocalFloor float64 // HYPERGUILD_ROUTE_LOCAL_FLOOR, default 0.90
|
||||||
|
RouteLocalCeil float64 // HYPERGUILD_ROUTE_LOCAL_CEIL, default 0.70
|
||||||
|
PassRateTTLSeconds int // HYPERGUILD_PASS_RATE_TTL_SECONDS, default 60
|
||||||
|
|
||||||
|
// project_create configuration. Empty GiteaMCPURL disables the
|
||||||
|
// project_create tool registration so the routing pod still starts
|
||||||
|
// in environments where it's not wired up.
|
||||||
|
GiteaMCPURL string // GITEA_MCP_URL, e.g. http://koala:30340/mcp
|
||||||
|
GiteaMCPToken string // GITEA_MCP_TOKEN, bearer for gitea-mcp
|
||||||
|
GiteaOwner string // GITEA_OWNER, default mathias
|
||||||
|
GitHubOwner string // GITHUB_OWNER, default mathiasb
|
||||||
|
InfraRepo string // INFRA_REPO, default infra
|
||||||
|
GitHubPAT string // GITHUB_PAT, repo scope; never logged
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadRouting() (RoutingConfig, error) {
|
||||||
|
cfg := RoutingConfig{
|
||||||
|
Port: envOr("ROUTING_PORT", "3210"),
|
||||||
|
MCPAuthToken: os.Getenv("ROUTING_MCP_TOKEN"),
|
||||||
|
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "http://piguard:4000"),
|
||||||
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
|
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
||||||
|
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
||||||
|
ThinkingModel: envOr("HYPERGUILD_THINKING_MODEL", "iguana/gemma4-26b"),
|
||||||
|
}
|
||||||
|
|
||||||
|
floor, err := parseFloatEnv("HYPERGUILD_ROUTE_LOCAL_FLOOR", 0.90)
|
||||||
|
if err != nil {
|
||||||
|
return RoutingConfig{}, err
|
||||||
|
}
|
||||||
|
cfg.RouteLocalFloor = floor
|
||||||
|
|
||||||
|
ceil, err := parseFloatEnv("HYPERGUILD_ROUTE_LOCAL_CEIL", 0.70)
|
||||||
|
if err != nil {
|
||||||
|
return RoutingConfig{}, err
|
||||||
|
}
|
||||||
|
cfg.RouteLocalCeil = ceil
|
||||||
|
|
||||||
|
ttl, err := parseIntEnv("HYPERGUILD_PASS_RATE_TTL_SECONDS", 60)
|
||||||
|
if err != nil {
|
||||||
|
return RoutingConfig{}, err
|
||||||
|
}
|
||||||
|
cfg.PassRateTTLSeconds = ttl
|
||||||
|
|
||||||
|
cfg.GiteaMCPURL = os.Getenv("GITEA_MCP_URL")
|
||||||
|
cfg.GiteaMCPToken = os.Getenv("GITEA_MCP_TOKEN")
|
||||||
|
cfg.GiteaOwner = envOr("GITEA_OWNER", "mathias")
|
||||||
|
cfg.GitHubOwner = envOr("GITHUB_OWNER", "mathiasb")
|
||||||
|
cfg.InfraRepo = envOr("INFRA_REPO", "infra")
|
||||||
|
cfg.GitHubPAT = os.Getenv("GITHUB_PAT")
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloatEnv(key string, def float64) (float64, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntEnv(key string, def int) (int, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
73
internal/config/routing_test.go
Normal file
73
internal/config/routing_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRoutingDefaults(t *testing.T) {
|
||||||
|
for _, k := range []string{
|
||||||
|
"ROUTING_PORT", "ROUTING_MCP_TOKEN", "LITELLM_BASE_URL", "LITELLM_API_KEY",
|
||||||
|
"BRAIN_URL", "HYPERGUILD_FAST_MODEL", "HYPERGUILD_THINKING_MODEL",
|
||||||
|
"HYPERGUILD_ROUTE_LOCAL_FLOOR", "HYPERGUILD_ROUTE_LOCAL_CEIL",
|
||||||
|
"HYPERGUILD_PASS_RATE_TTL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(k, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadRouting()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "3210", cfg.Port)
|
||||||
|
assert.Equal(t, "", cfg.MCPAuthToken)
|
||||||
|
assert.Equal(t, "http://piguard:4000", cfg.LiteLLMBaseURL)
|
||||||
|
assert.Equal(t, "http://ingestion.supervisor:3300", cfg.BrainURL)
|
||||||
|
assert.Equal(t, "koala/qwen35-9b-fast", cfg.FastModel)
|
||||||
|
assert.Equal(t, "iguana/gemma4-26b", cfg.ThinkingModel)
|
||||||
|
assert.InDelta(t, 0.90, cfg.RouteLocalFloor, 1e-9)
|
||||||
|
assert.InDelta(t, 0.70, cfg.RouteLocalCeil, 1e-9)
|
||||||
|
assert.Equal(t, 60, cfg.PassRateTTLSeconds)
|
||||||
|
assert.Equal(t, "", cfg.LiteLLMAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRoutingFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("ROUTING_PORT", "3250")
|
||||||
|
t.Setenv("ROUTING_MCP_TOKEN", "tok-xyz")
|
||||||
|
t.Setenv("LITELLM_BASE_URL", "http://localhost:4000")
|
||||||
|
t.Setenv("LITELLM_API_KEY", "lk")
|
||||||
|
t.Setenv("BRAIN_URL", "http://localhost:3300")
|
||||||
|
t.Setenv("HYPERGUILD_FAST_MODEL", "koala/phi4-14b")
|
||||||
|
t.Setenv("HYPERGUILD_THINKING_MODEL", "iguana/qwen3-14b-think")
|
||||||
|
t.Setenv("HYPERGUILD_ROUTE_LOCAL_FLOOR", "0.85")
|
||||||
|
t.Setenv("HYPERGUILD_ROUTE_LOCAL_CEIL", "0.65")
|
||||||
|
t.Setenv("HYPERGUILD_PASS_RATE_TTL_SECONDS", "30")
|
||||||
|
|
||||||
|
cfg, err := config.LoadRouting()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "3250", cfg.Port)
|
||||||
|
assert.Equal(t, "tok-xyz", cfg.MCPAuthToken)
|
||||||
|
assert.Equal(t, "http://localhost:4000", cfg.LiteLLMBaseURL)
|
||||||
|
assert.Equal(t, "lk", cfg.LiteLLMAPIKey)
|
||||||
|
assert.Equal(t, "http://localhost:3300", cfg.BrainURL)
|
||||||
|
assert.Equal(t, "koala/phi4-14b", cfg.FastModel)
|
||||||
|
assert.Equal(t, "iguana/qwen3-14b-think", cfg.ThinkingModel)
|
||||||
|
assert.InDelta(t, 0.85, cfg.RouteLocalFloor, 1e-9)
|
||||||
|
assert.InDelta(t, 0.65, cfg.RouteLocalCeil, 1e-9)
|
||||||
|
assert.Equal(t, 30, cfg.PassRateTTLSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRoutingRejectsBadFloat(t *testing.T) {
|
||||||
|
t.Setenv("HYPERGUILD_ROUTE_LOCAL_FLOOR", "not-a-number")
|
||||||
|
_, err := config.LoadRouting()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "HYPERGUILD_ROUTE_LOCAL_FLOOR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRoutingRejectsBadInt(t *testing.T) {
|
||||||
|
t.Setenv("HYPERGUILD_PASS_RATE_TTL_SECONDS", "not-a-number")
|
||||||
|
_, err := config.LoadRouting()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "HYPERGUILD_PASS_RATE_TTL_SECONDS")
|
||||||
|
}
|
||||||
108
internal/githubclient/client.go
Normal file
108
internal/githubclient/client.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Package githubclient is a minimal GitHub REST API client. The hyperguild
|
||||||
|
// project_create flow is gitea-first; this client exists only to create an
|
||||||
|
// empty repo on GitHub before the gitea→github push-mirror is configured,
|
||||||
|
// since the mirror cannot push to a non-existent remote.
|
||||||
|
package githubclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAPI = "https://api.github.com"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
api string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client with the given personal access token (repo scope).
|
||||||
|
func New(token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
api: defaultAPI,
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBaseURL overrides the API base (test injection).
|
||||||
|
func (c *Client) WithBaseURL(u string) *Client {
|
||||||
|
c.api = u
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo is the subset of GitHub's repo response we surface upstream.
|
||||||
|
type Repo struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRepoArgs struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
AutoInit bool `json:"auto_init"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrAlreadyExists is returned by CreateRepo when GitHub responds 422 with
|
||||||
|
// "name already exists". Callers treat it as idempotent success.
|
||||||
|
var ErrAlreadyExists = fmt.Errorf("github repo already exists")
|
||||||
|
|
||||||
|
// CreateRepo creates a repo under the authenticated user's account.
|
||||||
|
// auto_init is always false — the push-mirror will populate the repo from
|
||||||
|
// gitea, so an auto-generated README would conflict on first push.
|
||||||
|
func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error) {
|
||||||
|
if c.token == "" {
|
||||||
|
return nil, fmt.Errorf("github pat not configured")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(createRepoArgs{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Private: private,
|
||||||
|
AutoInit: false,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.api+"/user/repos", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusCreated:
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
case http.StatusUnprocessableEntity:
|
||||||
|
// 422 covers "name already exists" + a handful of other validation
|
||||||
|
// errors. Treat any 422 that mentions "already exists" as idempotent
|
||||||
|
// success; everything else surfaces verbatim.
|
||||||
|
if bytes.Contains(raw, []byte("already exists")) {
|
||||||
|
return nil, ErrAlreadyExists
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("github 422: %s", string(raw))
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return nil, fmt.Errorf("github auth %d: PAT missing repo scope or invalid", resp.StatusCode)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("github %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/githubclient/client_test.go
Normal file
71
internal/githubclient/client_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package githubclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateRepo_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/user/repos", r.URL.Path)
|
||||||
|
assert.Equal(t, "token ghp_test", r.Header.Get("Authorization"))
|
||||||
|
var args map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &args)
|
||||||
|
assert.Equal(t, "test-repo", args["name"])
|
||||||
|
assert.Equal(t, true, args["private"])
|
||||||
|
assert.Equal(t, false, args["auto_init"])
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"full_name":"mathiasb/test-repo","html_url":"https://github.com/mathiasb/test-repo","clone_url":"https://github.com/mathiasb/test-repo.git","private":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
|
||||||
|
r, err := c.CreateRepo(context.Background(), "test-repo", "desc", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "mathiasb/test-repo", r.FullName)
|
||||||
|
assert.True(t, r.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_AlreadyExists(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
|
||||||
|
_, err := c.CreateRepo(context.Background(), "x", "", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, githubclient.ErrAlreadyExists))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_Unauthorized(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
|
||||||
|
_, err := c.CreateRepo(context.Background(), "x", "", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "PAT missing repo scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_NoToken(t *testing.T) {
|
||||||
|
c := githubclient.New("")
|
||||||
|
_, err := c.CreateRepo(context.Background(), "x", "", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "github pat not configured")
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ package mcp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/auth"
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,13 +34,38 @@ type rpcError struct {
|
|||||||
// Server is an HTTP handler implementing the MCP JSON-RPC protocol.
|
// Server is an HTTP handler implementing the MCP JSON-RPC protocol.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
reg *registry.Registry
|
reg *registry.Registry
|
||||||
|
token string
|
||||||
|
validator *auth.Validator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(reg *registry.Registry) *Server {
|
// NewServer constructs an MCP HTTP handler. token is the static bearer token
|
||||||
return &Server{reg: reg}
|
// (empty disables static auth). validator is optional; when non-nil, a valid
|
||||||
|
// JWT from Dex is accepted in addition to the static token.
|
||||||
|
func NewServer(reg *registry.Registry, token string, validator *auth.Validator) *Server {
|
||||||
|
return &Server{reg: reg, token: token, validator: validator}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.checkAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET opens the SSE stream for server-to-client events (MCP streamable HTTP).
|
||||||
|
// claude.ai probes with GET before sending initialize, so accept without a session.
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
_, _ = w.Write([]byte(": stream open\n\n"))
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
<-r.Context().Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req request
|
var req request
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, nil, -32700, "parse error")
|
writeError(w, nil, -32700, "parse error")
|
||||||
@@ -93,6 +122,44 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAuth verifies the bearer token. Accepts a valid Dex JWT (when validator
|
||||||
|
// is configured) or the static token. Returns true if the request may proceed.
|
||||||
|
// When neither token nor validator is configured, auth is disabled (default).
|
||||||
|
func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if s.token == "" && s.validator == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok {
|
||||||
|
s.rejectAuth(w, r)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.validator != nil {
|
||||||
|
if _, err := s.validator.Validate(r.Context(), rawToken); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.token != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(s.token)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rejectAuth(w, r)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rejectAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slog.Warn("mcp auth rejected", "remote", r.RemoteAddr, "method", r.Method)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: &rpcError{Code: -32001, Message: "unauthorized"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, id any, code int, msg string) {
|
func writeError(w http.ResponseWriter, id any, code int, msg string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(response{
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func jsonBody(t *testing.T, v any) *bytes.Buffer {
|
|||||||
|
|
||||||
func TestMCPInitialize(t *testing.T) {
|
func TestMCPInitialize(t *testing.T) {
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg, "", nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -45,7 +45,7 @@ func TestMCPInitialize(t *testing.T) {
|
|||||||
|
|
||||||
func TestMCPToolsList(t *testing.T) {
|
func TestMCPToolsList(t *testing.T) {
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg, "", nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": map[string]any{},
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": map[string]any{},
|
||||||
@@ -63,7 +63,7 @@ func TestMCPToolsList(t *testing.T) {
|
|||||||
|
|
||||||
func TestMCPUnknownMethod(t *testing.T) {
|
func TestMCPUnknownMethod(t *testing.T) {
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg, "", nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
||||||
"jsonrpc": "2.0", "id": 3, "method": "unknown/method", "params": map[string]any{},
|
"jsonrpc": "2.0", "id": 3, "method": "unknown/method", "params": map[string]any{},
|
||||||
@@ -80,7 +80,7 @@ func TestMCPUnknownMethod(t *testing.T) {
|
|||||||
|
|
||||||
func TestMCPNotificationKnownMethodGetsNoResponseBody(t *testing.T) {
|
func TestMCPNotificationKnownMethodGetsNoResponseBody(t *testing.T) {
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg, "", nil)
|
||||||
|
|
||||||
// JSON-RPC 2.0 notification: "id" field absent. Per spec, server MUST NOT
|
// JSON-RPC 2.0 notification: "id" field absent. Per spec, server MUST NOT
|
||||||
// reply. notifications/initialized is part of the standard MCP handshake.
|
// reply. notifications/initialized is part of the standard MCP handshake.
|
||||||
@@ -97,9 +97,52 @@ func TestMCPNotificationKnownMethodGetsNoResponseBody(t *testing.T) {
|
|||||||
"notifications must not receive a response body")
|
"notifications must not receive a response body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCPAuth(t *testing.T) {
|
||||||
|
const token = "s3cr3t"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"no token configured passes without header", "", "", http.StatusOK},
|
||||||
|
{"correct bearer passes", token, "Bearer " + token, http.StatusOK},
|
||||||
|
{"wrong bearer rejected", token, "Bearer wrong", http.StatusUnauthorized},
|
||||||
|
{"missing header rejected", token, "", http.StatusUnauthorized},
|
||||||
|
{"wrong scheme rejected", token, "Basic " + token, http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
reg := registry.New()
|
||||||
|
srv := mcp.NewServer(reg, tc.token, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{},
|
||||||
|
}))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tc.authHeader)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.wantStatus, rr.Code)
|
||||||
|
if tc.wantStatus == http.StatusUnauthorized {
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
rpcErr, ok := resp["error"].(map[string]any)
|
||||||
|
require.True(t, ok, "expected error object in response")
|
||||||
|
assert.Equal(t, float64(-32001), rpcErr["code"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMCPNotificationUnknownMethodGetsNoResponseBody(t *testing.T) {
|
func TestMCPNotificationUnknownMethodGetsNoResponseBody(t *testing.T) {
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg, "", nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|||||||
150
internal/mcpclient/client.go
Normal file
150
internal/mcpclient/client.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Package mcpclient is a minimal JSON-RPC over HTTP client for talking to
|
||||||
|
// MCP servers from inside hyperguild components. It only implements
|
||||||
|
// `tools/call` because that's all consumer skills need today.
|
||||||
|
package mcpclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
|
||||||
|
type Client struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTokenRequired is returned by New when token is empty. Empty token
|
||||||
|
// causes mcpclient to omit the Authorization header at request time,
|
||||||
|
// which is silently misread as 401 by bearer-auth servers — see
|
||||||
|
// hyperguild #13 and the brain entry on the failure mode.
|
||||||
|
var ErrTokenRequired = errors.New("mcpclient: token required")
|
||||||
|
|
||||||
|
// New returns a Client. Returns ErrTokenRequired when token is empty:
|
||||||
|
// every MCP server we talk to today is bearer-protected, and an empty
|
||||||
|
// token is always a configuration bug (typically a Kubernetes Secret
|
||||||
|
// missing the expected key, see hyperguild #13). Callers that genuinely
|
||||||
|
// need an unauthenticated client should construct &Client{} directly in
|
||||||
|
// tests, not call New.
|
||||||
|
func New(url, token string) (*Client, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, ErrTokenRequired
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
url: url,
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient overrides the underlying HTTP client (test injection).
|
||||||
|
func (c *Client) WithHTTPClient(h *http.Client) *Client {
|
||||||
|
c.http = h
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params map[string]any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is returned when the remote MCP server signals a typed failure.
|
||||||
|
// Code follows JSON-RPC conventions; see gitea-mcp internal/mcp/jsonrpc.go
|
||||||
|
// for the codes the server uses (e.g. -32002 NotFound, -32003 Conflict).
|
||||||
|
type Error struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string { return fmt.Sprintf("mcp error %d: %s", e.Code, e.Message) }
|
||||||
|
|
||||||
|
// CallTool issues `tools/call`. result is JSON-unmarshalled from the
|
||||||
|
// server's content[0].text field; pass nil to discard.
|
||||||
|
func (c *Client) CallTool(ctx context.Context, name string, args any, result any) error {
|
||||||
|
body, err := json.Marshal(rpcRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 1,
|
||||||
|
Method: "tools/call",
|
||||||
|
Params: map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"arguments": args,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("mcp http %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpc rpcResponse
|
||||||
|
if err := json.Unmarshal(raw, &rpc); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w (body=%s)", err, string(raw))
|
||||||
|
}
|
||||||
|
if rpc.Error != nil {
|
||||||
|
return &Error{Code: rpc.Error.Code, Message: rpc.Error.Message}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP success result shape: { content: [{type:"text", text:"<json>"}] }
|
||||||
|
var wrap struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rpc.Result, &wrap); err != nil {
|
||||||
|
return fmt.Errorf("decode wrap: %w (result=%s)", err, string(rpc.Result))
|
||||||
|
}
|
||||||
|
if len(wrap.Content) == 0 {
|
||||||
|
return fmt.Errorf("empty content in tool response")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(wrap.Content[0].Text), result); err != nil {
|
||||||
|
return fmt.Errorf("decode tool result text: %w (text=%s)", err, wrap.Content[0].Text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
92
internal/mcpclient/client_test.go
Normal file
92
internal/mcpclient/client_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package mcpclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew_EmptyTokenFailsFast(t *testing.T) {
|
||||||
|
c, err := mcpclient.New("http://example.invalid", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, c)
|
||||||
|
require.ErrorIs(t, err, mcpclient.ErrTokenRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTool_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "Bearer tok", r.Header.Get("Authorization"))
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
var got map[string]any
|
||||||
|
_ = json.Unmarshal(b, &got)
|
||||||
|
assert.Equal(t, "tools/call", got["method"])
|
||||||
|
params := got["params"].(map[string]any)
|
||||||
|
assert.Equal(t, "x_y", params["name"])
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ok\":true,\"n\":7}"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := mcpclient.New(srv.URL, "tok")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var out struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
N int `json:"n"`
|
||||||
|
}
|
||||||
|
require.NoError(t, c.CallTool(context.Background(), "x_y", map[string]any{"a": 1}, &out))
|
||||||
|
assert.True(t, out.OK)
|
||||||
|
assert.Equal(t, 7, out.N)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTool_RPCError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32003,"message":"already exists"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := mcpclient.New(srv.URL, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = c.CallTool(context.Background(), "x", nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
var me *mcpclient.Error
|
||||||
|
require.True(t, errors.As(err, &me))
|
||||||
|
assert.Equal(t, -32003, me.Code)
|
||||||
|
assert.Contains(t, me.Message, "already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTool_HTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`unauthorized`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := mcpclient.New(srv.URL, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = c.CallTool(context.Background(), "x", nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "401")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTool_NilResult(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{}"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := mcpclient.New(srv.URL, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.CallTool(context.Background(), "x", nil, nil))
|
||||||
|
}
|
||||||
21
internal/routing/hash.go
Normal file
21
internal/routing/hash.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanonicalHash returns a deterministic 64-bit hash of (system, user).
|
||||||
|
// Used to make sample-band routing decisions reproducible: identical input
|
||||||
|
// strings produce the same hash on every call, independent of process state.
|
||||||
|
//
|
||||||
|
// Inputs are joined with a 0x00 byte separator before hashing — distinguishes
|
||||||
|
// (system="ab", user="cd") from (system="abcd", user="").
|
||||||
|
func CanonicalHash(system, user string) uint64 {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(system))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(user))
|
||||||
|
sum := h.Sum(nil)
|
||||||
|
return binary.BigEndian.Uint64(sum[:8])
|
||||||
|
}
|
||||||
46
internal/routing/hash_test.go
Normal file
46
internal/routing/hash_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package routing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanonicalHashDeterministic(t *testing.T) {
|
||||||
|
a := routing.CanonicalHash("system one", "user one")
|
||||||
|
b := routing.CanonicalHash("system one", "user one")
|
||||||
|
assert.Equal(t, a, b, "same inputs must produce same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalHashDistinguishesInputs(t *testing.T) {
|
||||||
|
cases := [][2]string{
|
||||||
|
{"sys", "user"},
|
||||||
|
{"sys", "user2"},
|
||||||
|
{"sys2", "user"},
|
||||||
|
{"", "system\x00user"}, // separator collision attempt
|
||||||
|
{"system\x00user", ""},
|
||||||
|
}
|
||||||
|
seen := make(map[uint64]bool)
|
||||||
|
for _, c := range cases {
|
||||||
|
h := routing.CanonicalHash(c[0], c[1])
|
||||||
|
assert.False(t, seen[h], "collision on %v", c)
|
||||||
|
seen[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalHashLowBitDistribution(t *testing.T) {
|
||||||
|
// Sanity check: across 1000 distinct inputs, low-bit split is roughly even.
|
||||||
|
zeros, ones := 0, 0
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
h := routing.CanonicalHash("sys", string(rune('a'+(i%26)))+string(rune(i)))
|
||||||
|
if h&1 == 0 {
|
||||||
|
zeros++
|
||||||
|
} else {
|
||||||
|
ones++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Allow ±15% deviation from 500/500. Tighter would be flaky on real data.
|
||||||
|
assert.InDelta(t, 500, zeros, 150)
|
||||||
|
assert.InDelta(t, 500, ones, 150)
|
||||||
|
}
|
||||||
79
internal/routing/log.go
Normal file
79
internal/routing/log.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry describes a single routing decision to log via the brain MCP.
|
||||||
|
type LogEntry struct {
|
||||||
|
SessionID string
|
||||||
|
Skill string // the original skill the call routed (e.g., "review")
|
||||||
|
Decision string // "local" or "thinking" or "thinking_fallback"
|
||||||
|
Message string // free-form, e.g. "model=qwen35, pass_rate=0.94"
|
||||||
|
ProjectRoot string
|
||||||
|
DurationMs int64
|
||||||
|
Failed bool // true → final_status: "fail"; false → "skip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger posts session_log entries to a brain MCP at BrainURL + /mcp.
|
||||||
|
type Logger struct {
|
||||||
|
BrainURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a Logger with a 2-second HTTP timeout.
|
||||||
|
func NewLogger(brainURL string) *Logger {
|
||||||
|
return &Logger{
|
||||||
|
BrainURL: brainURL,
|
||||||
|
HTTP: &http.Client{Timeout: 2 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDecision posts a session_log MCP call. Errors are returned but the caller
|
||||||
|
// MUST NOT block real work on them — logging is best-effort.
|
||||||
|
func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
||||||
|
status := "skip"
|
||||||
|
if e.Failed {
|
||||||
|
status = "fail"
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{
|
||||||
|
"name": "session_log",
|
||||||
|
"arguments": map[string]any{
|
||||||
|
"session_id": e.SessionID,
|
||||||
|
"skill": "_routing",
|
||||||
|
"phase": "decide",
|
||||||
|
"final_status": status,
|
||||||
|
"message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message),
|
||||||
|
"duration_ms": e.DurationMs,
|
||||||
|
"project_root": e.ProjectRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("log: marshal: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.BrainURL+"/mcp", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("log: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := l.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("log: request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("log: server returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
81
internal/routing/log_test.go
Normal file
81
internal/routing/log_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package routing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoggerLogDecision(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/mcp", r.URL.Path)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, json.Unmarshal(body, &captured))
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := routing.NewLogger(srv.URL)
|
||||||
|
err := l.LogDecision(context.Background(), routing.LogEntry{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
Skill: "review",
|
||||||
|
Decision: "local",
|
||||||
|
Message: "model=qwen35, pass_rate=0.94",
|
||||||
|
ProjectRoot: "/home/x/proj",
|
||||||
|
DurationMs: 1234,
|
||||||
|
Failed: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
params := captured["params"].(map[string]any)
|
||||||
|
assert.Equal(t, "tools/call", captured["method"])
|
||||||
|
assert.Equal(t, "session_log", params["name"])
|
||||||
|
|
||||||
|
args := params["arguments"].(map[string]any)
|
||||||
|
assert.Equal(t, "_routing", args["skill"])
|
||||||
|
assert.Equal(t, "decide", args["phase"])
|
||||||
|
assert.Equal(t, "skip", args["final_status"])
|
||||||
|
assert.Contains(t, args["message"].(string), "review: local")
|
||||||
|
assert.Equal(t, "sess-1", args["session_id"])
|
||||||
|
assert.Equal(t, "/home/x/proj", args["project_root"])
|
||||||
|
assert.Equal(t, float64(1234), args["duration_ms"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerLogFailure(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &captured)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := routing.NewLogger(srv.URL)
|
||||||
|
err := l.LogDecision(context.Background(), routing.LogEntry{
|
||||||
|
SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
args := captured["params"].(map[string]any)["arguments"].(map[string]any)
|
||||||
|
assert.Equal(t, "fail", args["final_status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerSurfacesUpstreamError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "down", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := routing.NewLogger(srv.URL)
|
||||||
|
err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
85
internal/routing/passrate.go
Normal file
85
internal/routing/passrate.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher reads /pass-rate from the brain pod with a per-skill TTL cache.
|
||||||
|
type Fetcher struct {
|
||||||
|
BaseURL string
|
||||||
|
Window string
|
||||||
|
TTL time.Duration
|
||||||
|
HTTP *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]cachedRate
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedRate struct {
|
||||||
|
value *float64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type passRateResponse struct {
|
||||||
|
PassRate *float64 `json:"pass_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFetcher returns a Fetcher that calls baseURL + /pass-rate with the
|
||||||
|
// given window string. If ttl is zero, defaults to 60 seconds. The HTTP
|
||||||
|
// client uses a 1-second total timeout.
|
||||||
|
func NewFetcher(baseURL, window string, ttl time.Duration) *Fetcher {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = 60 * time.Second
|
||||||
|
}
|
||||||
|
return &Fetcher{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Window: window,
|
||||||
|
TTL: ttl,
|
||||||
|
HTTP: &http.Client{Timeout: time.Second},
|
||||||
|
cache: make(map[string]cachedRate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the pass rate for the named skill, or nil if no data exists,
|
||||||
|
// or an error if the brain is unreachable. Caches successful results.
|
||||||
|
func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if c, ok := f.cache[skill]; ok && time.Since(c.at) < f.TTL {
|
||||||
|
v := c.value
|
||||||
|
f.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
u := fmt.Sprintf("%s/pass-rate?skill=%s&window=%s",
|
||||||
|
f.BaseURL, url.QueryEscape(skill), url.QueryEscape(f.Window))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("passrate: build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := f.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("passrate: request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("passrate: server returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body passRateResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return nil, fmt.Errorf("passrate: decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
f.cache[skill] = cachedRate{value: body.PassRate, at: time.Now()}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
return body.PassRate, nil
|
||||||
|
}
|
||||||
94
internal/routing/passrate_test.go
Normal file
94
internal/routing/passrate_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package routing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetcherGetReturnsPassRate(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/pass-rate", r.URL.Path)
|
||||||
|
assert.Equal(t, "tdd", r.URL.Query().Get("skill"))
|
||||||
|
assert.Equal(t, "7d", r.URL.Query().Get("window"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"skill": "tdd", "pass_rate": 0.94})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
||||||
|
pr, err := f.Get(context.Background(), "tdd")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, pr)
|
||||||
|
assert.InDelta(t, 0.94, *pr, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherGetReturnsNilWhenNoData(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"skill": "novel", "pass_rate": nil})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
||||||
|
pr, err := f.Get(context.Background(), "novel")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherCachesWithinTTL(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, err := f.Get(context.Background(), "tdd")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "should hit upstream once and serve four times from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherFetchesAgainAfterTTLExpires(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Tight TTL so the test stays fast.
|
||||||
|
f := routing.NewFetcher(srv.URL, "7d", 5*time.Millisecond)
|
||||||
|
_, err := f.Get(context.Background(), "tdd")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
|
||||||
|
|
||||||
|
// Sleep past TTL, then a second Get should hit upstream again.
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
_, err = f.Get(context.Background(), "tdd")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expected fresh upstream call after TTL expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherSurfacesUpstreamError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
||||||
|
pr, err := f.Get(context.Background(), "tdd")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, pr)
|
||||||
|
}
|
||||||
47
internal/routing/policy.go
Normal file
47
internal/routing/policy.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
// Decision is the route picked for a single skill call.
|
||||||
|
type Decision int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DecideLocal Decision = iota
|
||||||
|
DecideClaude
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d Decision) String() string {
|
||||||
|
if d == DecideLocal {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
return "claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policy holds the floor/ceil thresholds for routing decisions.
|
||||||
|
//
|
||||||
|
// Rules (in order):
|
||||||
|
//
|
||||||
|
// 1. passRate == nil → DecideLocal (default-to-local for cost-routable skills)
|
||||||
|
// 2. *passRate >= Floor → DecideLocal (trust local)
|
||||||
|
// 3. *passRate < Ceil → DecideClaude (don't trust local)
|
||||||
|
// 4. otherwise (sample band) → requestHash low bit picks: 0=local, 1=claude
|
||||||
|
type Policy struct {
|
||||||
|
Floor float64
|
||||||
|
Ceil float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide returns the routing decision for a single call.
|
||||||
|
// requestHash is consulted only when passRate is in the sample band [Ceil, Floor).
|
||||||
|
func (p Policy) Decide(passRate *float64, requestHash uint64) Decision {
|
||||||
|
if passRate == nil {
|
||||||
|
return DecideLocal
|
||||||
|
}
|
||||||
|
if *passRate >= p.Floor {
|
||||||
|
return DecideLocal
|
||||||
|
}
|
||||||
|
if *passRate < p.Ceil {
|
||||||
|
return DecideClaude
|
||||||
|
}
|
||||||
|
if requestHash&1 == 0 {
|
||||||
|
return DecideLocal
|
||||||
|
}
|
||||||
|
return DecideClaude
|
||||||
|
}
|
||||||
36
internal/routing/policy_test.go
Normal file
36
internal/routing/policy_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package routing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptr(f float64) *float64 { return &f }
|
||||||
|
|
||||||
|
func TestPolicyDecide(t *testing.T) {
|
||||||
|
p := routing.Policy{Floor: 0.9, Ceil: 0.7}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
passRate *float64
|
||||||
|
hash uint64
|
||||||
|
want routing.Decision
|
||||||
|
}{
|
||||||
|
{"null pass rate → local", nil, 0, routing.DecideLocal},
|
||||||
|
{"null pass rate, hash irrelevant → local", nil, 0xDEADBEEF, routing.DecideLocal},
|
||||||
|
{"at floor → local", ptr(0.9), 0, routing.DecideLocal},
|
||||||
|
{"above floor → local", ptr(0.95), 0, routing.DecideLocal},
|
||||||
|
{"below ceil → claude", ptr(0.5), 0, routing.DecideClaude},
|
||||||
|
{"at ceil → sample-band even-hash → local", ptr(0.7), 0, routing.DecideLocal},
|
||||||
|
{"sample band, even hash → local", ptr(0.8), 2, routing.DecideLocal},
|
||||||
|
{"sample band, odd hash → claude", ptr(0.8), 3, routing.DecideClaude},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, p.Decide(tc.passRate, tc.hash))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/routing/router.go
Normal file
84
internal/routing/router.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompleteFunc matches the signature used by every skill package's Config.
|
||||||
|
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
||||||
|
|
||||||
|
// RunInput captures the per-call inputs the dispatch wrapper needs.
|
||||||
|
type RunInput struct {
|
||||||
|
Skill string
|
||||||
|
System string
|
||||||
|
User string
|
||||||
|
SessionID string
|
||||||
|
ProjectRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router composes a pass-rate fetcher, a decision policy, a session logger,
|
||||||
|
// and a LiteLLM client. Skill packages receive Router.Run as their CompleteFunc.
|
||||||
|
type Router struct {
|
||||||
|
Fetcher *Fetcher
|
||||||
|
Logger *Logger
|
||||||
|
Policy Policy
|
||||||
|
FastModel string
|
||||||
|
ThinkingModel string
|
||||||
|
Complete CompleteFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes one skill call: decides local vs claude, calls LiteLLM, logs the
|
||||||
|
// decision. On local-side error, falls open by retrying once on the Claude model.
|
||||||
|
func (r *Router) Run(ctx context.Context, in RunInput) (string, int64, error) {
|
||||||
|
pr, ferr := r.Fetcher.Get(ctx, in.Skill)
|
||||||
|
if ferr != nil {
|
||||||
|
slog.Warn("router: pass-rate unreachable, defaulting to local", "skill", in.Skill, "err", ferr)
|
||||||
|
pr = nil
|
||||||
|
}
|
||||||
|
hash := CanonicalHash(in.System, in.User)
|
||||||
|
decision := r.Policy.Decide(pr, hash)
|
||||||
|
|
||||||
|
model := r.ThinkingModel
|
||||||
|
if decision == DecideLocal {
|
||||||
|
model = r.FastModel
|
||||||
|
}
|
||||||
|
|
||||||
|
out, ms, err := r.Complete(ctx, model, in.System, in.User)
|
||||||
|
if lerr := r.Logger.LogDecision(ctx, LogEntry{
|
||||||
|
SessionID: in.SessionID,
|
||||||
|
Skill: in.Skill,
|
||||||
|
Decision: decision.String(),
|
||||||
|
Message: fmt.Sprintf("model=%s, pass_rate=%s", model, formatPassRate(pr)),
|
||||||
|
ProjectRoot: in.ProjectRoot,
|
||||||
|
DurationMs: ms,
|
||||||
|
Failed: err != nil,
|
||||||
|
}); lerr != nil {
|
||||||
|
slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && decision == DecideLocal {
|
||||||
|
slog.Warn("router: fast failed, falling open to thinking model", "skill", in.Skill, "err", err)
|
||||||
|
out, ms, err = r.Complete(ctx, r.ThinkingModel, in.System, in.User)
|
||||||
|
if lerr := r.Logger.LogDecision(ctx, LogEntry{
|
||||||
|
SessionID: in.SessionID,
|
||||||
|
Skill: in.Skill,
|
||||||
|
Decision: "thinking_fallback",
|
||||||
|
Message: fmt.Sprintf("model=%s, after-fast-error", r.ThinkingModel),
|
||||||
|
ProjectRoot: in.ProjectRoot,
|
||||||
|
DurationMs: ms,
|
||||||
|
Failed: err != nil,
|
||||||
|
}); lerr != nil {
|
||||||
|
slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, ms, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPassRate(pr *float64) string {
|
||||||
|
if pr == nil {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f", *pr)
|
||||||
|
}
|
||||||
136
internal/routing/router_test.go
Normal file
136
internal/routing/router_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package routing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/routing"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeLLM struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []struct{ Model, System, User string }
|
||||||
|
resp string
|
||||||
|
err error
|
||||||
|
errOn string // if non-empty, only the named model errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLLM) Complete(_ context.Context, model, system, user string) (string, int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, struct{ Model, System, User string }{model, system, user})
|
||||||
|
if f.errOn == model {
|
||||||
|
return "", 0, f.err
|
||||||
|
}
|
||||||
|
if f.err != nil && f.errOn == "" {
|
||||||
|
return "", 0, f.err
|
||||||
|
}
|
||||||
|
return f.resp, 100, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRouter(t *testing.T, llm *fakeLLM, passRate float64) (*routing.Router, *httptest.Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/pass-rate":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": passRate})
|
||||||
|
case "/mcp":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(brain.Close)
|
||||||
|
|
||||||
|
r := &routing.Router{
|
||||||
|
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
||||||
|
Logger: routing.NewLogger(brain.URL),
|
||||||
|
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
||||||
|
FastModel: "koala/qwen35-9b-fast",
|
||||||
|
ThinkingModel: "iguana/gemma4-26b",
|
||||||
|
Complete: llm.Complete,
|
||||||
|
}
|
||||||
|
return r, brain, brain
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterRoutesLocalAtHighPassRate(t *testing.T) {
|
||||||
|
llm := &fakeLLM{resp: "ok"}
|
||||||
|
r, _, _ := newRouter(t, llm, 0.95)
|
||||||
|
|
||||||
|
out, _, err := r.Run(context.Background(), routing.RunInput{
|
||||||
|
Skill: "review", System: "sys", User: "user", SessionID: "s1", ProjectRoot: "/p",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok", out)
|
||||||
|
|
||||||
|
llm.mu.Lock()
|
||||||
|
defer llm.mu.Unlock()
|
||||||
|
require.Len(t, llm.calls, 1)
|
||||||
|
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterRoutesThinkingAtLowPassRate(t *testing.T) {
|
||||||
|
llm := &fakeLLM{resp: "ok"}
|
||||||
|
r, _, _ := newRouter(t, llm, 0.3)
|
||||||
|
|
||||||
|
_, _, err := r.Run(context.Background(), routing.RunInput{
|
||||||
|
Skill: "review", System: "sys", User: "user", SessionID: "s2",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
llm.mu.Lock()
|
||||||
|
defer llm.mu.Unlock()
|
||||||
|
require.Len(t, llm.calls, 1)
|
||||||
|
assert.Equal(t, "iguana/gemma4-26b", llm.calls[0].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterFailsOpenFastErrorToThinking(t *testing.T) {
|
||||||
|
llm := &fakeLLM{resp: "ok-after-fallback", err: errors.New("fast boom"), errOn: "koala/qwen35-9b-fast"}
|
||||||
|
r, _, _ := newRouter(t, llm, 0.95) // would route fast
|
||||||
|
|
||||||
|
out, _, err := r.Run(context.Background(), routing.RunInput{
|
||||||
|
Skill: "review", System: "sys", User: "user", SessionID: "s3",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok-after-fallback", out)
|
||||||
|
|
||||||
|
llm.mu.Lock()
|
||||||
|
defer llm.mu.Unlock()
|
||||||
|
require.Len(t, llm.calls, 2)
|
||||||
|
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
||||||
|
assert.Equal(t, "iguana/gemma4-26b", llm.calls[1].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterDefaultsToFastWhenBrainUnreachable(t *testing.T) {
|
||||||
|
// Brain returns 500 → fetcher errors → router treats pass rate as nil → fast.
|
||||||
|
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "down", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer brain.Close()
|
||||||
|
|
||||||
|
llm := &fakeLLM{resp: "ok"}
|
||||||
|
r := &routing.Router{
|
||||||
|
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
||||||
|
Logger: routing.NewLogger(brain.URL),
|
||||||
|
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
||||||
|
FastModel: "koala/qwen35-9b-fast",
|
||||||
|
ThinkingModel: "iguana/gemma4-26b",
|
||||||
|
Complete: llm.Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := r.Run(context.Background(), routing.RunInput{
|
||||||
|
Skill: "review", System: "sys", User: "user", SessionID: "s4",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
llm.mu.Lock()
|
||||||
|
defer llm.mu.Unlock()
|
||||||
|
require.Len(t, llm.calls, 1)
|
||||||
|
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user