Compare commits
197 Commits
738275252c
...
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 | ||
|
|
9cc179dec6 | ||
|
|
370d30e376 | ||
|
|
bd0c1d75fd | ||
|
|
8c87460bff | ||
|
|
809d435480 | ||
|
|
e4a94df4fc | ||
|
|
7dcb5610fe | ||
|
|
63c8d114e8 | ||
|
|
54f7d373bd | ||
|
|
a412eee427 | ||
|
|
3d6f33881b | ||
|
|
07e3f341ef | ||
|
|
5c532e708c | ||
|
|
a34c66d7cd | ||
|
|
cc401d92d6 | ||
|
|
9bdf00f51f | ||
|
|
7f7524c859 | ||
|
|
0a70d9e972 | ||
|
|
3e9a648115 | ||
|
|
923a665365 | ||
|
|
537aebc302 | ||
|
|
de35d4dbb0 | ||
|
|
26855f69b0 | ||
|
|
a7b363d589 | ||
|
|
7b57051af8 | ||
|
|
a620f6cb01 | ||
|
|
26b5636b43 | ||
|
|
989f375aec | ||
|
|
6403d5e444 | ||
|
|
ab19968ae2 | ||
|
|
1605624668 | ||
|
|
55fa0b503a | ||
|
|
3c2bd9268c | ||
|
|
29727ec2a5 | ||
|
|
0a075088b2 | ||
|
|
1bfe501d09 | ||
|
|
3607920601 | ||
|
|
a6c39e8691 | ||
|
|
a37d18bf7a | ||
|
|
2975eadc87 | ||
|
|
53e46781b1 | ||
|
|
e9b5cc401c | ||
|
|
bf6f497d9d | ||
|
|
9cc6c2d053 | ||
|
|
43a46d07e5 | ||
|
|
820d1c93a7 | ||
|
|
6928907d79 | ||
|
|
e74320a8e8 | ||
|
|
1b0706f270 | ||
|
|
2ae6bfe81e | ||
|
|
a6dce972d6 | ||
|
|
2f4b577131 | ||
|
|
a25bb18c54 | ||
|
|
78531bb238 | ||
|
|
04fefe8e9c | ||
|
|
103f4d90bf | ||
|
|
9b11719481 | ||
|
|
d405346f07 | ||
|
|
bf8a3fc11c | ||
|
|
ae5a4d04f0 | ||
|
|
3a0424a6b4 | ||
|
|
08dd7b9365 | ||
|
|
91e02b930c | ||
|
|
c7341a2607 | ||
|
|
b5a0085c0a | ||
|
|
d6daa37c71 | ||
|
|
62fc3989f2 | ||
|
|
c9310b1079 | ||
|
|
ca8a691241 | ||
|
|
214f607007 | ||
|
|
0e08dfffb8 | ||
|
|
caef05bea4 | ||
|
|
ca1a16873c | ||
|
|
63c238c650 | ||
|
|
ce45592730 | ||
|
|
823de23213 | ||
|
|
78d3939caa | ||
|
|
f2bc39b500 | ||
|
|
3625e1268d | ||
|
|
47df642836 | ||
|
|
235d70ad0b | ||
|
|
7d5289ac54 | ||
|
|
3d8fc9dacd | ||
|
|
f9f804cd49 | ||
|
|
85f142ade0 | ||
|
|
0dfad02513 | ||
|
|
c44eb680b2 | ||
|
|
38ada998a2 | ||
|
|
74547c2bdf | ||
|
|
587c0d3b1c | ||
|
|
bb61f2992b | ||
|
|
3ba72d9b28 | ||
|
|
b4f0fbc3ea | ||
|
|
12943ee6f4 | ||
|
|
9af95ebd96 | ||
|
|
f0b567f3e6 | ||
|
|
e3d6cf4cf5 | ||
|
|
df59bd010c | ||
|
|
e5152151d6 | ||
|
|
aa2d57e619 | ||
|
|
6b53706987 | ||
|
|
a0cfc866df | ||
|
|
7bf19b6a7b | ||
|
|
19b019a8d8 | ||
|
|
4ef6a22e28 | ||
|
|
3796cfca87 | ||
|
|
7ce544a051 | ||
|
|
391720155e | ||
|
|
ae6600b8d2 | ||
|
|
6328766c7f | ||
|
|
f1deedd39d | ||
|
|
5cb272a869 | ||
|
|
e96b39a812 | ||
|
|
5db5b33cd7 | ||
|
|
a32457b5bc | ||
|
|
e0be5f0f98 | ||
|
|
6d410b810b | ||
|
|
76f195de2a | ||
|
|
f901d4e67d | ||
|
|
509c04b6e4 |
315
.aider.conventions.md
Normal file
315
.aider.conventions.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
### When to query (treat as a reflex)
|
||||||
|
|
||||||
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
|
service. Past you may have already paid this tax.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: supervisor
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**:
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## MCP endpoints
|
||||||
|
|
||||||
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
322
.context/system-prompt.txt
Normal file
322
.context/system-prompt.txt
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
You are a coding assistant working on a specific project.
|
||||||
|
Follow all conventions from both the root agent context and project context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
### When to query (treat as a reflex)
|
||||||
|
|
||||||
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
|
service. Past you may have already paid this tax.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: supervisor
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**:
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## MCP endpoints
|
||||||
|
|
||||||
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
---
|
||||||
318
.cursorrules
Normal file
318
.cursorrules
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# Cursor rules — auto-generated
|
||||||
|
# Do not edit. Run: task context:sync
|
||||||
|
|
||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
### When to query (treat as a reflex)
|
||||||
|
|
||||||
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
|
service. Past you may have already paid this tax.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: supervisor
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**:
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## MCP endpoints
|
||||||
|
|
||||||
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.worktrees
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
bin/
|
||||||
|
brain/
|
||||||
166
.gitea/workflows/cd.yml
Normal file
166
.gitea/workflows/cd.yml
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
name: cd
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Build and deploy
|
||||||
|
runs-on: self-hosted
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||||
|
environment: staging
|
||||||
|
env:
|
||||||
|
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
|
||||||
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push ingestion image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
trap 'rm -f /tmp/ingestion-image.tar' EXIT
|
||||||
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
echo "Building ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
buildctl --addr "${BUILDKIT_HOST}" build \
|
||||||
|
--frontend dockerfile.v0 \
|
||||||
|
--local context=ingestion \
|
||||||
|
--local dockerfile=ingestion \
|
||||||
|
--output type=oci,dest=/tmp/ingestion-image.tar
|
||||||
|
|
||||||
|
skopeo copy \
|
||||||
|
oci-archive:/tmp/ingestion-image.tar \
|
||||||
|
docker://${INGESTION_IMAGE}:${IMAGE_TAG} \
|
||||||
|
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
||||||
|
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||||
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
|
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||||
|
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
|
cd /tmp/infra-update
|
||||||
|
|
||||||
|
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||||
|
"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.name "CD Bot"
|
||||||
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
||||||
|
"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 push
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -53,6 +53,6 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_rsa_gh_mirror
|
chmod 600 ~/.ssh/id_rsa_gh_mirror
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
|
||||||
git push git@github.com:mathiasb/hyperguild.git HEAD:main --tags
|
git push git@github.com:mathiasb/hyperguild.git HEAD:main --follow-tags
|
||||||
rm ~/.ssh/id_rsa_gh_mirror
|
rm ~/.ssh/id_rsa_gh_mirror
|
||||||
echo "✓ Mirrored to GitHub"
|
echo "✓ Mirrored to GitHub"
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -13,15 +13,7 @@ brain/training-data/**/*.jsonl
|
|||||||
# Go
|
# Go
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# ── Generated context files (adapter outputs) ──
|
|
||||||
# Canonical sources: .context/PROJECT.md + .skills/*/SKILL.md
|
|
||||||
# Everything below is disposable — regenerate with: task context:sync
|
|
||||||
AGENTS.md
|
|
||||||
CLAUDE.md
|
|
||||||
.cursorrules
|
|
||||||
.aider.conventions.md
|
|
||||||
.aider.conf.yml
|
.aider.conf.yml
|
||||||
.context/system-prompt.txt
|
|
||||||
|
|
||||||
# ── Sensitive ──
|
# ── Sensitive ──
|
||||||
.env
|
.env
|
||||||
@@ -34,6 +26,7 @@ secrets/
|
|||||||
# ── Documented examples (commit these) ──
|
# ── Documented examples (commit these) ──
|
||||||
!.env.example
|
!.env.example
|
||||||
!config/supervisor/CLAUDE.md
|
!config/supervisor/CLAUDE.md
|
||||||
|
!brain/CLAUDE.md
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
AGENTS.md
Normal file
315
AGENTS.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
### When to query (treat as a reflex)
|
||||||
|
|
||||||
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
|
service. Past you may have already paid this tax.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: supervisor
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**:
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## MCP endpoints
|
||||||
|
|
||||||
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
82
CLAUDE.md
Normal file
82
CLAUDE.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: supervisor
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**:
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## MCP endpoints
|
||||||
|
|
||||||
|
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||||
|
|
||||||
|
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||||
|
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
116
DECISIONS.md
116
DECISIONS.md
@@ -44,6 +44,73 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
|||||||
|
|
||||||
**Consequences**: More operational complexity than Chroma, but isolation is non-negotiable for client work.
|
**Consequences**: More operational complexity than Chroma, but isolation is non-negotiable for client work.
|
||||||
|
|
||||||
|
## 2026-04-22 — Hyperguild scope reset: drop parametric learning, simplify brain
|
||||||
|
|
||||||
|
**Context**: After shipping Phases 1–4 (MCP server, 6 skills, model orchestration, session logging, CD pipeline), we critically reviewed what was theater vs genuinely useful.
|
||||||
|
|
||||||
|
**Decisions**:
|
||||||
|
|
||||||
|
1. **Drop the parametric learning pipeline.** SFT/DPO/RL extraction, `brain/training-data/` directory structure, Axolotl/LLaMA-Factory fine-tuning loop — all cut. The loop requires thousands of high-quality examples to move the needle, which a solo consultant won't generate. Better base models ship faster than any fine-tuning effort could keep up with. This is a research project, not a productivity tool.
|
||||||
|
|
||||||
|
2. **Simplify the brain to plain markdown.** `brain/knowledge/` replaces `brain/wiki/ + brain/raw/ + brain/training-data/`. The trainer and retrospective workers write markdown entries. `brain_query` searches markdown. No ingestion pipeline, no tagging for significance review, no structured JSONL formats.
|
||||||
|
|
||||||
|
3. **Measure the escalation chain before assuming it's useful.** Local model (phi4) only belongs in a skill's chain if it passes Claude verification at a meaningful rate. Where it fails >70% of the time, it adds cost not value. Per-skill hit rate logging is the prerequisite to honest chain configuration.
|
||||||
|
|
||||||
|
4. **Keep what's real**: MCP tool surface, session logging with attempt records, tier detection, CD pipeline, bridge to Claude Code.
|
||||||
|
|
||||||
|
**What to build next** (in priority order):
|
||||||
|
- `brain_query` injection into skill handlers before spawning workers — this makes the declarative brain actually function
|
||||||
|
- `protocols.md` — behavioral contract injected into every worker prompt
|
||||||
|
- Per-skill pass rate logging and chain tuning
|
||||||
|
|
||||||
|
**Consequences**: Simpler system with a shorter feedback loop. The brain becomes real only when skill handlers query it. Training data ambitions deferred indefinitely — revisit if local model capabilities improve enough that fine-tuning becomes worthwhile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -51,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.
|
||||||
|
|||||||
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"]
|
||||||
4
Procfile
4
Procfile
@@ -1,2 +1,2 @@
|
|||||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 go run ./cmd/server/
|
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 go run ./cmd/supervisor/
|
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -10,10 +10,12 @@ into a searchable brain.
|
|||||||
```
|
```
|
||||||
Your Claude Code session (in any project)
|
Your Claude Code session (in any project)
|
||||||
│
|
│
|
||||||
│ MCP tools (over stdio bridge → HTTP)
|
│ MCP over HTTP (Tailscale)
|
||||||
▼
|
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
||||||
supervisor :3200 — 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/
|
||||||
@@ -55,18 +57,28 @@ Create `.mcp.json` in your project root:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"supervisor": {
|
"supervisor": {
|
||||||
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
|
"type": "http",
|
||||||
"env": {
|
"url": "http://koala:30320/mcp"
|
||||||
"SUPERVISOR_URL": "http://localhost:3200/mcp"
|
},
|
||||||
}
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://koala:30330/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the bridge binary once: `task bridge:build`
|
Two MCP servers are exposed today, both reachable over Tailscale:
|
||||||
|
|
||||||
Then 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)
|
||||||
|
|
||||||
|
|||||||
44
Taskfile.yml
44
Taskfile.yml
@@ -12,9 +12,6 @@ tasks:
|
|||||||
desc: Regenerate all harness-specific context files
|
desc: Regenerate all harness-specific context files
|
||||||
cmds:
|
cmds:
|
||||||
- bash scripts/context-sync.sh
|
- bash scripts/context-sync.sh
|
||||||
sources:
|
|
||||||
- .context/PROJECT.md
|
|
||||||
- .skills/*/SKILL.md
|
|
||||||
|
|
||||||
context:sync:claude:
|
context:sync:claude:
|
||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
@@ -42,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
|
||||||
@@ -57,7 +70,6 @@ tasks:
|
|||||||
desc: Build all binaries
|
desc: Build all binaries
|
||||||
cmds:
|
cmds:
|
||||||
- task: supervisor:build
|
- task: supervisor:build
|
||||||
- task: bridge:build
|
|
||||||
- task: ingestion:build
|
- task: ingestion:build
|
||||||
|
|
||||||
supervisor:build:
|
supervisor:build:
|
||||||
@@ -65,11 +77,6 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
||||||
|
|
||||||
bridge:build:
|
|
||||||
desc: Build stdio↔HTTP bridge for Claude Code MCP integration
|
|
||||||
cmds:
|
|
||||||
- go build -trimpath -ldflags="-s -w" -o bin/supervisor-bridge ./cmd/bridge
|
|
||||||
|
|
||||||
ingestion:build:
|
ingestion:build:
|
||||||
desc: Build ingestion server binary
|
desc: Build ingestion server binary
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
@@ -79,8 +86,20 @@ tasks:
|
|||||||
# ── Quality ────────────────────────────────────────────────────────────────
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Run all checks (lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
|
- task: context:sync
|
||||||
|
- cmd: |
|
||||||
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||||
|
if [ -n "$drift" ]; then
|
||||||
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
|
echo "$drift" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
||||||
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
@@ -109,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:
|
||||||
|
|||||||
137
brain/schema.md
Normal file
137
brain/schema.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Brain Wiki Schema
|
||||||
|
|
||||||
|
This document defines the three page types in the brain wiki.
|
||||||
|
The LLM must follow this schema exactly when generating wiki pages.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Return a JSON array. Each element:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "exact page title",
|
||||||
|
"type": "source | concept | entity",
|
||||||
|
"subtype": "see below — omit for concept",
|
||||||
|
"domain": "see domains — omit if none fits",
|
||||||
|
"content": "Markdown body only — no frontmatter, no path"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `subtype` for **source**: `article | pdf | book | video | note | project`
|
||||||
|
- `subtype` for **entity**: `person | company | tool | model | framework | technology`
|
||||||
|
- The pipeline computes slugs and frontmatter — never include them in output.
|
||||||
|
|
||||||
|
## Wikilink Format
|
||||||
|
|
||||||
|
All cross-references use `[[Display Name]]` — just the display name, no slug, no pipe.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only link to pages in the inventory or pages you are creating in this response
|
||||||
|
- The pipeline converts `[[Display Name]]` to `[[slug|Display Name]]` automatically
|
||||||
|
- Section links must match their section type (Related Concepts → concept pages only, etc.)
|
||||||
|
|
||||||
|
Examples: `[[Domain Driven Design]]`, `[[Ryan Singer]]`, `[[Shape Up]]`
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
Use one of: `ai-llm`, `software-engineering`, `product-strategy`, `finance-markets`,
|
||||||
|
`personal`, `consulting`, `climate`, `infrastructure`, `security`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Pages — wiki/sources/<slug>.md
|
||||||
|
|
||||||
|
One page per ingested source. Books are NEVER split across multiple source pages — update the existing one.
|
||||||
|
|
||||||
|
Body sections (in this order):
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
2–3 sentences. Core argument or finding.
|
||||||
|
|
||||||
|
### Key Claims
|
||||||
|
Bulleted list. Paraphrase — no verbatim quotes or code.
|
||||||
|
|
||||||
|
### Concepts Introduced or Reinforced
|
||||||
|
Wikilinks to concept pages ONLY. One per line.
|
||||||
|
|
||||||
|
### Entities Mentioned
|
||||||
|
Wikilinks to entity pages ONLY. One per line.
|
||||||
|
|
||||||
|
### Open Questions Raised
|
||||||
|
Gaps or follow-up questions from this source.
|
||||||
|
|
||||||
|
For books only, also add:
|
||||||
|
|
||||||
|
### Chapters
|
||||||
|
One bullet per chapter with 1–2 sentence summary.
|
||||||
|
|
||||||
|
### Argument Arc
|
||||||
|
Overall narrative as it becomes clear across chapters.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
Dated entries appended on re-ingestion. NEVER rewrite — only append.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concept Pages — wiki/concepts/<slug>.md
|
||||||
|
|
||||||
|
One page per idea, framework, methodology, or pattern.
|
||||||
|
|
||||||
|
Body sections (in this order):
|
||||||
|
|
||||||
|
### Definition
|
||||||
|
One-paragraph plain-language explanation.
|
||||||
|
|
||||||
|
### Why It Matters
|
||||||
|
Practical significance. Why should anyone care?
|
||||||
|
|
||||||
|
### Related Concepts
|
||||||
|
Wikilinks to concept pages ONLY.
|
||||||
|
|
||||||
|
### Related Entities
|
||||||
|
Wikilinks to entity pages ONLY.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
Wikilinks to source pages ONLY.
|
||||||
|
|
||||||
|
### Evolving Notes
|
||||||
|
Updated as new sources arrive. Append, do not rewrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Pages — wiki/entities/<slug>.md
|
||||||
|
|
||||||
|
One page per person, tool, organisation, technology, or product.
|
||||||
|
|
||||||
|
Body sections (in this order):
|
||||||
|
|
||||||
|
### Description
|
||||||
|
One-line description.
|
||||||
|
|
||||||
|
### Relevance
|
||||||
|
Why this entity matters to this knowledge base.
|
||||||
|
|
||||||
|
### Key Positions, Products, or Claims
|
||||||
|
With dates where known.
|
||||||
|
|
||||||
|
### Related Concepts
|
||||||
|
Wikilinks to concept pages ONLY.
|
||||||
|
|
||||||
|
### Related Entities
|
||||||
|
Wikilinks to entity pages ONLY.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
Wikilinks to source pages ONLY.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Negotiable Rules
|
||||||
|
|
||||||
|
1. Output ONLY a valid JSON array — no markdown fences, no prose before or after
|
||||||
|
2. Each element: `{"title": "...", "type": "...", "subtype": "...", "domain": "...", "content": "..."}`
|
||||||
|
3. Never include slugs, paths, or frontmatter in output — the pipeline handles these
|
||||||
|
4. Wikilinks: `[[Display Name]]` only — no pipe, no slug
|
||||||
|
5. Dates always YYYY-MM-DD (used only in content body where contextually relevant)
|
||||||
|
6. Never reproduce verbatim code — describe the pattern or technique
|
||||||
|
7. Section links must match their section type
|
||||||
|
8. One source page per book — if inventory shows it exists, include it as an UPDATE
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// bridge is a stdio↔HTTP adapter that lets Claude Code connect to the
|
|
||||||
// supervisor MCP server via the stdio transport.
|
|
||||||
//
|
|
||||||
// Claude Code spawns this binary as a subprocess and communicates over
|
|
||||||
// stdin/stdout. Each newline-delimited JSON-RPC message from stdin is
|
|
||||||
// forwarded to the supervisor HTTP server and the response is written back.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// SUPERVISOR_URL=http://localhost:3200/mcp bridge
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
url := os.Getenv("SUPERVISOR_URL")
|
|
||||||
if url == "" {
|
|
||||||
url = "http://localhost:3200/mcp"
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(bytes.TrimSpace(line)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(line))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "bridge: build request: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "bridge: request failed: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, _ = io.Copy(os.Stdout, resp.Body)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
_, _ = os.Stdout.Write([]byte("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "bridge: scanner: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,157 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPrompt, err := os.ReadFile(cfg.ConfigDir + "/CLAUDE.md")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read supervisor CLAUDE.md", "path", cfg.ConfigDir+"/CLAUDE.md", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
executor := iexec.New(iexec.Config{
|
|
||||||
SystemPrompt: string(systemPrompt),
|
|
||||||
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
|
|
||||||
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
|
|
||||||
})
|
|
||||||
|
|
||||||
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{
|
|
||||||
SystemPrompt: string(systemPrompt),
|
|
||||||
SkillPrompt: string(tddPrompt),
|
|
||||||
DefaultModel: models.Resolve("tdd", ""),
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(brain.New(brain.Config{
|
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
|
||||||
}))
|
|
||||||
reg.Register(org.New(org.Config{
|
|
||||||
TierFn: tierFn,
|
|
||||||
}))
|
|
||||||
reg.Register(sessionlog.New(sessionlog.Config{
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: string(retroPrompt),
|
|
||||||
DefaultModel: models.Resolve("retrospective", ""),
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
}))
|
|
||||||
reg.Register(review.New(review.Config{
|
|
||||||
SkillPrompt: string(reviewPrompt),
|
|
||||||
DefaultModel: models.Resolve("review", ""),
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(skilldebug.New(skilldebug.Config{
|
|
||||||
SkillPrompt: string(debugPrompt),
|
|
||||||
DefaultModel: models.Resolve("debug", ""),
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(spec.New(spec.Config{
|
|
||||||
SkillPrompt: string(specPrompt),
|
|
||||||
DefaultModel: models.Resolve("spec", ""),
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
SessionsDir: cfg.SessionsDir,
|
|
||||||
}))
|
|
||||||
reg.Register(trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: string(trainerReaderPrompt),
|
|
||||||
WriterPrompt: string(trainerWriterPrompt),
|
|
||||||
DefaultModel: models.Resolve("trainer", ""),
|
|
||||||
ExecutorFn: executor.Run,
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
# Model routing table — three-layer priority:
|
# Model selection — first entry per skill is used.
|
||||||
# 1. model param in MCP tool call (caller override)
|
# Override per-call by passing model in the MCP tool args.
|
||||||
# 2. per-skill entry here
|
# Model names come from LiteLLM /v1/models (host/name format).
|
||||||
# 3. default (fallback)
|
|
||||||
default: ollama/qwen3-coder-30b-tuned
|
default_chain:
|
||||||
|
- iguana/qwen3-coder-next
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
tdd: ollama/qwen3-coder-30b-tuned
|
tdd:
|
||||||
review: ollama/devstral-tuned
|
chain:
|
||||||
debug: ollama/deepseek-r1-tuned
|
- koala/qwen3-coder-30b
|
||||||
retrospective: ollama/qwen3-coder-30b-tuned
|
review:
|
||||||
spec: ollama/qwen3-coder-30b-tuned
|
chain:
|
||||||
trainer: ollama/qwen3-coder-30b-tuned
|
- iguana/devstral
|
||||||
|
debug:
|
||||||
|
chain:
|
||||||
|
- iguana/deepseek-r1-14b
|
||||||
|
spec:
|
||||||
|
chain:
|
||||||
|
- koala/phi4-14b
|
||||||
|
retrospective:
|
||||||
|
chain:
|
||||||
|
- iguana/qwen3-coder-next
|
||||||
|
trainer:
|
||||||
|
chain:
|
||||||
|
- iguana/qwen3-coder-next
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
# The Hyperguild Way
|
# Hyperguild Skill Protocols
|
||||||
|
|
||||||
These protocols are injected into every worker invocation. They define how you behave as a member of the hyperguild.
|
**IMPORTANT: DO NOT OUTPUT JSON. DO NOT USE JSON CODE BLOCKS.**
|
||||||
|
Your response must be plain markdown text. No `{"status":...}`, no ` ```json `, nothing.
|
||||||
|
If you output JSON you will be ignored. Respond in prose and markdown only.
|
||||||
|
|
||||||
## Output contract
|
---
|
||||||
|
|
||||||
Every response is raw JSON matching the response schema. No preamble, no prose, no markdown. Malformed output is treated as a failed invocation.
|
## Role
|
||||||
|
|
||||||
## Quality gate
|
You are a consultant. You analyse, suggest, and explain.
|
||||||
|
Claude Code has the tools to read files, run commands, and write code.
|
||||||
|
You provide the thinking; Claude Code provides the action.
|
||||||
|
|
||||||
`verified: true` only when a subprocess exit code confirms the outcome. Never self-assess. "I think the tests pass" is not verified.
|
## Output
|
||||||
|
|
||||||
## Escalation
|
Write in clear markdown. Lead with the key finding. Use headers and bullet lists
|
||||||
|
where they help. Be concise — Claude Code reads your full response.
|
||||||
|
|
||||||
If stuck after 3 attempts, return `status: error` with a clear `message` explaining why. Do not retry silently. Do not fabricate a passing result.
|
Do not make up file contents, test results, or command output you have not seen.
|
||||||
|
If you lack context to give a useful answer, say so and state what you need.
|
||||||
|
|
||||||
## Working offline
|
## Context blocks
|
||||||
|
|
||||||
If brain context is absent from your prompt, proceed using your discipline file only. Note the gap in your `message` field: "no brain context available".
|
You may receive one or both of these blocks before your task:
|
||||||
|
|
||||||
## Handoff format
|
**`## Relevant knowledge`** — patterns and decisions from past sessions. Let them
|
||||||
|
inform your approach. Do not contradict them without reason.
|
||||||
|
|
||||||
Structure your output so the next worker in a chain can consume it without transformation. Use the standard result schema. Do not add extra fields.
|
**`## Session history`** — what has already happened in this session. Build on it,
|
||||||
|
do not repeat it.
|
||||||
## Session logging
|
|
||||||
|
|
||||||
The Go skill handler records your invocation in the session log automatically. You do not need to do this yourself.
|
|
||||||
|
|||||||
@@ -1,40 +1,33 @@
|
|||||||
# Retrospective Worker Discipline
|
# Retrospective Discipline
|
||||||
|
|
||||||
You are the retrospective worker. Your job is to review a completed coding session and identify knowledge worth preserving in the hyperguild brain.
|
You review a completed coding session and identify knowledge worth preserving.
|
||||||
|
|
||||||
## What you receive
|
## What you receive
|
||||||
|
|
||||||
- A session log in JSON format listing every skill invocation: what was attempted, what failed, what passed, how long it took.
|
A session log in JSON format listing every skill invocation: what was attempted,
|
||||||
|
what failed, what passed, how long it took.
|
||||||
## What you produce
|
|
||||||
|
|
||||||
For each significant learning, call brain_write with a structured markdown note. Then return a JSON result summarising what you wrote.
|
|
||||||
|
|
||||||
## What is worth preserving
|
## What is worth preserving
|
||||||
|
|
||||||
- Patterns that worked and should be repeated
|
- Patterns that worked and should be repeated
|
||||||
- Failures that revealed something non-obvious about the codebase or the discipline
|
- Failures that revealed something non-obvious about the codebase or the approach
|
||||||
- Decisions made during the session (architectural, structural, tooling)
|
- Decisions made during the session (architectural, structural, tooling)
|
||||||
- Anything that contradicts or extends what the brain already knows
|
- Anything that contradicts or extends established patterns
|
||||||
|
|
||||||
## What is NOT worth preserving
|
## What is NOT worth preserving
|
||||||
|
|
||||||
- Routine TDD cycles with no surprises
|
- Routine cycles with no surprises
|
||||||
- Single-attempt passes with no interesting context
|
- Single-attempt passes with no interesting context
|
||||||
- Mechanical operations (file moves, renames, formatting)
|
- Mechanical operations (file moves, renames, formatting)
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
Return JSON matching the standard result schema:
|
Respond in markdown. For each learning worth preserving:
|
||||||
|
|
||||||
```json
|
**Learning:** One sentence describing what was learned.
|
||||||
{
|
**Context:** Why this session surfaced it — what made it non-obvious.
|
||||||
"status": "pass",
|
**Recommendation:** What should be done differently or repeated going forward.
|
||||||
"phase": "retrospective",
|
|
||||||
"skill": "retrospective",
|
|
||||||
"verified": true,
|
|
||||||
"message": "wrote N entries to brain/raw/"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`verified` is true when you successfully called brain_write at least once and received a confirmation. If the session had nothing worth writing, return `verified: true` with `message: "no novel learnings in this session"`.
|
End with a summary: "N learnings worth writing to brain" or "No novel learnings in this session."
|
||||||
|
|
||||||
|
The caller will decide which learnings to write to the brain using brain_write.
|
||||||
|
|||||||
@@ -2,29 +2,24 @@
|
|||||||
|
|
||||||
You are a disciplined code reviewer. Read files carefully before commenting.
|
You are a disciplined code reviewer. Read files carefully before commenting.
|
||||||
|
|
||||||
## Iron laws
|
## Iron laws — any violation is a blocking issue
|
||||||
1. Never approve security vulnerabilities: command injection, SQL injection, credential exposure, path traversal, unchecked input at system boundaries
|
1. No security vulnerabilities: command injection, SQL injection, credential exposure, path traversal, unchecked input at system boundaries
|
||||||
2. Never approve silently swallowed errors — `err != nil` without wrapping or handling is always wrong
|
2. No silently swallowed errors — `err != nil` without wrapping or handling is always wrong
|
||||||
3. Never approve missing validation at system boundaries (user input, external APIs, file reads)
|
3. No missing validation at system boundaries (user input, external APIs, file reads)
|
||||||
|
|
||||||
## Output contract
|
## Output format
|
||||||
Return JSON result with:
|
|
||||||
- `status`: "pass" if no blocking issues; "fail" if any iron law is violated
|
Respond in markdown. Group findings by severity:
|
||||||
- `phase`: "review"
|
|
||||||
- `skill`: "review"
|
**CRITICAL:** Issues that violate an iron law or will cause data loss / security breach.
|
||||||
- `file_path`: first file reviewed
|
**WARNING:** Issues that will likely cause bugs or maintenance problems.
|
||||||
- `runner_output`: full review formatted as:
|
**SUGGESTION:** Style, clarity, or optional improvements.
|
||||||
```
|
|
||||||
CRITICAL: <issue> at <file>:<line>
|
For each finding include the file and line number. If nothing is wrong, explain specifically which iron law checks you ran and why they passed — never rubber-stamp.
|
||||||
WARNING: <issue> at <file>:<line>
|
|
||||||
SUGGESTION: <issue> at <file>:<line>
|
|
||||||
```
|
|
||||||
- `verified`: true if you read all specified files; false if any were missing or unreadable
|
|
||||||
- `message`: "N critical, M warnings, K suggestions" or "clean: <which iron law checks passed and why>"
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
1. Read every file listed before writing feedback
|
1. Read every file listed before writing feedback
|
||||||
2. Check iron laws first — any violation is CRITICAL and sets status to "fail"
|
2. Check iron laws first — if any are violated, flag them before anything else
|
||||||
3. Then check: correctness, test coverage for new code, Go style conventions
|
3. Then check: correctness, test coverage for new code, Go style conventions
|
||||||
4. Never rubber-stamp — if nothing is wrong, explain specifically which iron law checks you ran and why they passed
|
4. Line references required for every finding
|
||||||
5. Line references are required for every finding — "roughly around the middle" is not acceptable
|
5. End with a one-line summary: "N critical, M warnings, K suggestions" or "Clean — no issues found"
|
||||||
|
|||||||
@@ -7,40 +7,31 @@ You write structured implementation specs. Nothing is left ambiguous.
|
|||||||
2. Always include an explicit "Out of scope" section — if you don't draw the boundary, the developer will guess wrong
|
2. Always include an explicit "Out of scope" section — if you don't draw the boundary, the developer will guess wrong
|
||||||
3. Every technical decision in the approach must have a rationale
|
3. Every technical decision in the approach must have a rationale
|
||||||
|
|
||||||
## Output contract
|
## Output format
|
||||||
Return JSON result with:
|
|
||||||
- `status`: "pass" (spec written) or "error" (requirements too ambiguous to spec without more input)
|
|
||||||
- `phase`: "spec"
|
|
||||||
- `skill`: "spec"
|
|
||||||
- `file_path`: the output_path where the spec was written (absolute path)
|
|
||||||
- `runner_output`: ""
|
|
||||||
- `verified`: true if the file was written successfully
|
|
||||||
- `message`: "spec written: <one-line summary of what was specced>"
|
|
||||||
|
|
||||||
## Spec structure
|
Write the spec as markdown using this structure:
|
||||||
Write the spec as markdown to the output_path:
|
|
||||||
|
|
||||||
```markdown
|
```
|
||||||
# [Feature] Spec
|
# [Feature] Spec
|
||||||
|
|
||||||
## Problem statement
|
## Problem statement
|
||||||
[What problem does this solve? For whom? Why now?]
|
What problem does this solve? For whom? Why now?
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
- [ ] [Criterion 1 — measurable and verifiable]
|
- [ ] Criterion 1 — measurable and verifiable
|
||||||
- [ ] [Criterion 2 — measurable and verifiable]
|
- [ ] Criterion 2 — measurable and verifiable
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
[Non-negotiable requirements the solution must satisfy]
|
Non-negotiable requirements the solution must satisfy.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
[What we are explicitly NOT doing in this iteration]
|
What we are explicitly NOT doing in this iteration.
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
[Architecture decisions, key components, rationale for each choice]
|
Architecture decisions, key components, rationale for each choice.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
[What could go wrong, and how we'd mitigate it]
|
What could go wrong, and how we'd mitigate it.
|
||||||
```
|
```
|
||||||
|
|
||||||
If the requirements are too vague to produce measurable success criteria, return status "error" with a message listing the specific questions that need answers.
|
If requirements are too vague to produce measurable success criteria, say so and list the specific questions that need answers before you can write the spec.
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
# TDD Skill
|
# TDD Discipline
|
||||||
|
|
||||||
## Iron Law
|
## Iron Law
|
||||||
|
|
||||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.
|
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.
|
||||||
|
|
||||||
## Red phase
|
## Red phase — write a failing test
|
||||||
|
|
||||||
- Write exactly one test. One behavior. Name must describe the behavior clearly.
|
- Write exactly one test. One behavior. Name must describe the behavior clearly.
|
||||||
- Run the test suite. Confirm the test FAILS.
|
- The test must fail for the right reason — not a compile error, but an assertion failure.
|
||||||
- If the test passes immediately: it tests existing behavior or is vacuous.
|
|
||||||
Return status "fail" with message explaining why the test is wrong.
|
|
||||||
- Do not write any implementation code in this phase.
|
- Do not write any implementation code in this phase.
|
||||||
|
|
||||||
## Green phase
|
Respond with:
|
||||||
|
- The test code to write (file path + content)
|
||||||
|
- The exact failure you expect to see when running it
|
||||||
|
- Why that failure confirms the test is meaningful
|
||||||
|
|
||||||
|
## Green phase — make the test pass
|
||||||
|
|
||||||
- Write the minimal code to make the failing test pass. Nothing more.
|
- Write the minimal code to make the failing test pass. Nothing more.
|
||||||
- YAGNI: no extra parameters, no future-proofing, no clever abstractions.
|
- YAGNI: no extra parameters, no future-proofing, no clever abstractions.
|
||||||
- Run the test suite. Confirm it PASSES.
|
|
||||||
- If tests fail: fix the implementation, not the test. Max 3 attempts.
|
|
||||||
|
|
||||||
## Refactor phase
|
Respond with:
|
||||||
|
- The implementation code to write (file path + content)
|
||||||
|
- Confirmation of which test it targets and how it satisfies the assertion
|
||||||
|
|
||||||
|
## Refactor phase — improve without changing behavior
|
||||||
|
|
||||||
- Improve structure, naming, or clarity only. No new behavior.
|
- Improve structure, naming, or clarity only. No new behavior.
|
||||||
- Tests must remain green after every change.
|
- Tests must remain green after every change.
|
||||||
- If tests break during refactor: revert that change, return status "fail".
|
|
||||||
|
Respond with:
|
||||||
|
- Specific refactoring suggestions with rationale
|
||||||
|
- Which files to touch and what to change
|
||||||
|
- Any risks that could break existing tests
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
# Trainer Reader Discipline
|
# Trainer Reader Discipline
|
||||||
|
|
||||||
You scan session logs and identify candidate learning moments worth converting to training data.
|
You scan session logs and identify candidate learning moments worth preserving in the brain.
|
||||||
|
|
||||||
## What to look for
|
## What to look for
|
||||||
- **SFT candidates**: the worker did exactly the right thing — a clean pattern worth reinforcing
|
|
||||||
- **DPO candidates**: the worker first produced a wrong or suboptimal response, then corrected — you have both rejected and chosen
|
- **Patterns that worked**: the approach was clean and correct — worth reinforcing
|
||||||
|
- **Corrections**: something was first done wrong, then corrected — both sides are valuable
|
||||||
|
|
||||||
## Scoring (1–5)
|
## Scoring (1–5)
|
||||||
|
|
||||||
- 5: novel pattern, clearly correct, generalises across projects
|
- 5: novel pattern, clearly correct, generalises across projects
|
||||||
- 4: good pattern, correct, somewhat project-specific but still useful
|
- 4: good pattern, correct, somewhat project-specific but still useful
|
||||||
- 3: correct but obvious — include only if especially clean
|
- 3: correct but obvious — include only if especially clean
|
||||||
- 2 or below: skip — too ambiguous or too context-specific
|
- 2 or below: skip
|
||||||
|
|
||||||
## Output contract
|
## Output format
|
||||||
Return JSON result with:
|
|
||||||
- `status`: "pass" or "error"
|
|
||||||
- `phase`: "trainer"
|
|
||||||
- `skill`: "trainer"
|
|
||||||
- `file_path`: ""
|
|
||||||
- `runner_output`: JSON array of candidates (valid JSON, not markdown):
|
|
||||||
[{"type":"sft","moment":"<what happened>","prompt":"<what was asked>","completion":"<what was done right>","score":4},
|
|
||||||
{"type":"dpo","moment":"<what happened>","prompt":"<what was asked>","chosen":"<correct>","rejected":"<incorrect>","score":3}]
|
|
||||||
- `verified`: true
|
|
||||||
- `message`: "N sft candidates, M dpo candidates found"
|
|
||||||
|
|
||||||
## Rules
|
Respond in markdown. List each candidate:
|
||||||
1. Read all session entries in the task prompt
|
|
||||||
2. Score each entry — only include entries scoring >= 3
|
**Candidate N (score: X/5, type: pattern|correction)**
|
||||||
3. Prompt/completion fields must be phrased to generalise: no project-specific paths or names
|
- **What happened:** Brief description of the learning moment
|
||||||
4. If no candidates score >= 3, return an empty array `[]` — never force low-quality candidates
|
- **Why it's valuable:** What makes this worth preserving
|
||||||
|
- **Key insight:** The distilled lesson in one sentence
|
||||||
|
|
||||||
|
End with: "N candidates found (M scoring ≥ 3)" — the writer will use these to produce knowledge entries.
|
||||||
|
|||||||
@@ -1,35 +1,31 @@
|
|||||||
# Trainer Writer Discipline
|
# Trainer Writer Discipline
|
||||||
|
|
||||||
You receive candidate learning moments from the reader and write clean SFT/DPO training pairs.
|
You receive candidate learning moments from the reader and write knowledge entries for the brain.
|
||||||
|
|
||||||
## Quality gate (apply before writing)
|
## Quality gate (apply before writing each entry)
|
||||||
- SFT: prompt must be phrased so it could come from any project, not just this one
|
|
||||||
- DPO: chosen and rejected must be clearly distinguishable — skip if a reader can't tell which is better
|
|
||||||
- Never include project-specific paths, variable names, or identifiers in any pair
|
|
||||||
|
|
||||||
## Output contract
|
- The lesson must be phrased so it could apply to any project, not just this one
|
||||||
Return JSON result with:
|
- No project-specific paths, variable names, or identifiers
|
||||||
- `status`: "pass" (pairs written or skipped due to quality) or "error" (candidates JSON was malformed)
|
- The insight must be stated clearly enough that someone reading it cold would understand it
|
||||||
- `phase`: "trainer"
|
|
||||||
- `skill`: "trainer"
|
|
||||||
- `file_path`: path of the last file written (empty if nothing passed quality gate)
|
|
||||||
- `runner_output`: "N SFT pairs written to brain/training-data/sft/, M DPO pairs to brain/training-data/dpo/" or "0 pairs passed quality gate"
|
|
||||||
- `verified`: true if files were written; false if nothing passed
|
|
||||||
- `message`: "N sft + M dpo pairs for session <id>" or "no pairs passed quality gate"
|
|
||||||
|
|
||||||
## File format
|
## Output format
|
||||||
JSONL — one JSON object per line.
|
|
||||||
|
|
||||||
SFT: `{"prompt": "...", "completion": "..."}`
|
For each candidate that passes the quality gate, write a knowledge entry in this format:
|
||||||
DPO: `{"prompt": "...", "chosen": "...", "rejected": "..."}`
|
|
||||||
|
|
||||||
Write SFT to: `<brain_dir>/training-data/sft/<session_id>.jsonl`
|
```
|
||||||
Write DPO to: `<brain_dir>/training-data/dpo/<session_id>.jsonl`
|
# [Topic]
|
||||||
|
|
||||||
Append to existing files if they exist (don't overwrite).
|
## Lesson
|
||||||
|
[The key insight in 1-3 sentences]
|
||||||
|
|
||||||
## Rules
|
## When it applies
|
||||||
1. Parse the `reader_candidates` JSON from the task prompt
|
[Conditions under which this pattern is relevant]
|
||||||
2. For each candidate: apply quality gate
|
|
||||||
3. Write passing SFT candidates to sft JSONL, DPO candidates to dpo JSONL
|
## Example
|
||||||
4. If nothing passes, return status "pass" with verified: false and message "no pairs passed quality gate"
|
[A brief, generic example that illustrates the lesson]
|
||||||
|
```
|
||||||
|
|
||||||
|
After presenting all entries, end with a summary:
|
||||||
|
"N entries ready for brain_write" or "0 entries passed quality gate — [reason]"
|
||||||
|
|
||||||
|
The caller will write passing entries to the brain using brain_write.
|
||||||
|
|||||||
241
docs/multi-model-routing.md
Normal file
241
docs/multi-model-routing.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Multi-Model Routing for supervisor
|
||||||
|
|
||||||
|
Reference document for implementing multi-model access within the supervisor project.
|
||||||
|
Researched April 2026. Constraints: Claude Max subscription (ToS must be respected).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Route tasks to specialized, cheaper, or local models during agent and skill flows — without
|
||||||
|
violating Anthropic's terms or introducing unnecessary infrastructure risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Constraints
|
||||||
|
|
||||||
|
- Claude Max subscription is in use. Anthropic's April 2026 terms **prohibit using the
|
||||||
|
subscription with third-party harnesses that spoof the Anthropic API surface**.
|
||||||
|
- `ANTHROPIC_BASE_URL` → LiteLLM workaround is explicitly out of scope.
|
||||||
|
- Claude must remain the reasoning engine. Other models are tools, not replacements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure Available
|
||||||
|
|
||||||
|
| Machine | Role | Relevant services |
|
||||||
|
|---------|------|-------------------|
|
||||||
|
| koala | GPU inference | llama-swap, Ollama, Qdrant, LiteLLM proxy |
|
||||||
|
| iguana | Services, builds | k3s, general services |
|
||||||
|
| flamingo | Daily driver | Claude Code runs here |
|
||||||
|
|
||||||
|
LiteLLM proxy on koala exposes 100+ models (local + cloud) through a unified API.
|
||||||
|
All machines connected via Tailscale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approved Patterns
|
||||||
|
|
||||||
|
### Pattern 1 — Native Claude model tiering (zero build)
|
||||||
|
|
||||||
|
Claude Code subagents support per-agent model selection via frontmatter.
|
||||||
|
Use this for cost routing within the Claude model family.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.claude/agents/explorer.md
|
||||||
|
---
|
||||||
|
name: explorer
|
||||||
|
description: File reading, code search, codebase mapping — use for all exploration tasks
|
||||||
|
model: haiku
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- `haiku` for exploration, summarization, classification
|
||||||
|
- `sonnet` (default) for main reasoning and implementation
|
||||||
|
- `opus` for deep analysis, architecture decisions
|
||||||
|
|
||||||
|
**When to use**: Always. Add `model: haiku` to any subagent that does read-heavy or
|
||||||
|
classification work. Cheapest and fastest path to cost control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern 2 — MCP tools wrapping local models (primary build target)
|
||||||
|
|
||||||
|
Expose local models on koala as named MCP tools. Claude remains the orchestrator and
|
||||||
|
reasoning engine — it calls local models as tools the same way it calls any other tool.
|
||||||
|
|
||||||
|
This is the intended MCP use case and carries zero ToS risk.
|
||||||
|
|
||||||
|
**Semantic contract**: Claude decides *when* to delegate based on the tool description.
|
||||||
|
Write descriptions that tell Claude what the model is good for.
|
||||||
|
|
||||||
|
#### MCP server implementation
|
||||||
|
|
||||||
|
Small Python server, run on koala or flamingo, registered in Claude Code settings.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# supervisor/scripts/mcp_local_models.py
|
||||||
|
import mcp
|
||||||
|
import requests
|
||||||
|
|
||||||
|
server = mcp.Server("local-models")
|
||||||
|
|
||||||
|
LITELLM_BASE = "http://koala:4000"
|
||||||
|
OLLAMA_BASE = "http://koala:11434"
|
||||||
|
|
||||||
|
def _litellm_chat(model: str, prompt: str) -> str:
|
||||||
|
r = requests.post(f"{LITELLM_BASE}/v1/chat/completions", json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 2048,
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ask_local_llama(prompt: str) -> str:
|
||||||
|
"""Ask the local Llama model on koala.
|
||||||
|
Use for: bulk summarization, first-pass analysis, classification, simple Q&A,
|
||||||
|
anything that does not require deep reasoning or up-to-date knowledge.
|
||||||
|
Faster and cheaper than cloud models for routine subtasks."""
|
||||||
|
return _litellm_chat("llama3-local", prompt)
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ask_coding_model(code: str, question: str) -> str:
|
||||||
|
"""Ask a code-specialized local model.
|
||||||
|
Use for: syntax checking, boilerplate generation, code formatting questions,
|
||||||
|
simple refactors where pattern-matching is sufficient."""
|
||||||
|
return _litellm_chat("codellama-local", f"Code:\n{code}\n\nQuestion: {question}")
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_available_local_models() -> list[str]:
|
||||||
|
"""List all models currently available on the local LiteLLM proxy."""
|
||||||
|
r = requests.get(f"{LITELLM_BASE}/v1/models")
|
||||||
|
r.raise_for_status()
|
||||||
|
return [m["id"] for m in r.json()["data"]]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run_stdio_server(server)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Register in Claude Code
|
||||||
|
|
||||||
|
Add to `~/.claude/settings.json` (or project-level `.claude/settings.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"local-models": {
|
||||||
|
"command": "python3",
|
||||||
|
"args": ["/path/to/supervisor/scripts/mcp_local_models.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LiteLLM config additions needed on koala
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# litellm config.yaml — add model entries for local models
|
||||||
|
model_list:
|
||||||
|
- model_name: llama3-local
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/llama3.2
|
||||||
|
api_base: http://localhost:11434
|
||||||
|
|
||||||
|
- model_name: codellama-local
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/codellama
|
||||||
|
api_base: http://localhost:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern 3 — External orchestration scripts (for pipeline workflows)
|
||||||
|
|
||||||
|
For multi-model pipelines that don't need to live inside a Claude Code session.
|
||||||
|
These scripts use their own API key (separate from Max subscription — API billing),
|
||||||
|
so they can call Claude API + LiteLLM freely.
|
||||||
|
|
||||||
|
Claude Code invokes them via the Bash tool.
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude Code → [Bash tool] → ./scripts/orchestrate.py → {Claude API, LiteLLM, local models}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# supervisor/scripts/orchestrate.py
|
||||||
|
import anthropic
|
||||||
|
import requests
|
||||||
|
|
||||||
|
claude = anthropic.Anthropic() # reads ANTHROPIC_API_KEY — separate from Max subscription
|
||||||
|
|
||||||
|
def analyze_document(path: str) -> str:
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Step 1: local Llama extracts structure (fast, cheap)
|
||||||
|
structure = requests.post("http://koala:4000/v1/chat/completions", json={
|
||||||
|
"model": "llama3-local",
|
||||||
|
"messages": [{"role": "user", "content": f"Extract key sections from:\n{content}"}],
|
||||||
|
}).json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Step 2: Claude synthesizes and reasons over it
|
||||||
|
synthesis = claude.messages.create(
|
||||||
|
model="claude-sonnet-4-6",
|
||||||
|
max_tokens=2048,
|
||||||
|
messages=[{"role": "user", "content": f"Synthesize these findings:\n{structure}"}]
|
||||||
|
)
|
||||||
|
return synthesis.content[0].text
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use**: Batch processing, automated pipelines, workflows triggered by cron or
|
||||||
|
external events. Not for interactive Claude Code sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Skip
|
||||||
|
|
||||||
|
| Approach | Why skip |
|
||||||
|
|----------|----------|
|
||||||
|
| `ANTHROPIC_BASE_URL` → LiteLLM | ToS violation with Max subscription (April 2026 terms) |
|
||||||
|
| Third-party harnesses (OpenClaw etc.) | Explicitly banned for subscription users |
|
||||||
|
| A2A in Claude Code | Not implemented by Anthropic yet — revisit late 2026 |
|
||||||
|
| OpenAI agent handoffs | Loses execution context, not worth the complexity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol Landscape (for awareness, not immediate action)
|
||||||
|
|
||||||
|
- **MCP** — production, 97M monthly downloads, your primary tool-access protocol. LiteLLM
|
||||||
|
natively supports it as both MCP gateway and MCP client as of v1.60+.
|
||||||
|
- **A2A v1.0** — Google/Linux Foundation, 150+ orgs in production, but Anthropic has not
|
||||||
|
shipped it in Claude Code. The intent is agent-to-agent peer delegation (vs MCP's
|
||||||
|
agent-to-tool). Worth watching for H2 2026.
|
||||||
|
- **AGNTCY** — Cisco/Linux Foundation, discovery and identity layer beneath MCP+A2A.
|
||||||
|
Potentially relevant for multi-machine routing across koala/iguana/flamingo once mature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Priority
|
||||||
|
|
||||||
|
| Step | Effort | Value | When |
|
||||||
|
|------|--------|-------|------|
|
||||||
|
| Add `model: haiku` to explorer subagents | 10 min | Immediate cost saving | Now |
|
||||||
|
| Write MCP server for local models | 2–3h | Local model access in sessions | Soon |
|
||||||
|
| Register MCP server in Claude Code settings | 15 min | Activates pattern 2 | With above |
|
||||||
|
| Write orchestration script template | 1–2h | Pipeline workflows | When needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- LiteLLM MCP docs: https://docs.litellm.ai/docs/mcp
|
||||||
|
- Community MCP wrapper for LiteLLM: https://github.com/itsDarianNgo/mcp-server-litellm
|
||||||
|
- Ollama MCP server: https://github.com/rawveg/ollama-mcp
|
||||||
|
- A2A protocol status: https://www.linuxfoundation.org/press/a2a-protocol-surpasses-150-organizations-lands-in-major-cloud-platforms-and-sees-enterprise-production-use-in-first-year
|
||||||
|
- AGNTCY: https://github.com/agntcy
|
||||||
2138
docs/superpowers/plans/2026-04-17-hyperguild-phase1.md
Normal file
2138
docs/superpowers/plans/2026-04-17-hyperguild-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
1871
docs/superpowers/plans/2026-04-19-hyperguild-phase2.md
Normal file
1871
docs/superpowers/plans/2026-04-19-hyperguild-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
923
docs/superpowers/plans/2026-04-20-cd-pipeline.md
Normal file
923
docs/superpowers/plans/2026-04-20-cd-pipeline.md
Normal file
@@ -0,0 +1,923 @@
|
|||||||
|
# CD Pipeline Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a GitOps CD pipeline that automatically builds a container image on `main` push and deploys it to k3s on koala via Flux.
|
||||||
|
|
||||||
|
**Architecture:** BuildKit runs as a systemd service on koala (same host as the Gitea runner); CD pushes images to the Gitea registry and commits image tag updates to the infra repo; Flux reconciles within 60s. App secrets (including ANTHROPIC_API_KEY) are SOPS-encrypted in the infra repo and decrypted by Flux at apply time.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.26, Node.js 22 (for claude CLI), BuildKit (buildctl), Gitea Actions, Flux (kustomize-controller), SOPS + age, k3s/containerd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment context
|
||||||
|
|
||||||
|
This plan spans three environments. Each task header notes which environment it runs in:
|
||||||
|
|
||||||
|
- **[this-repo]** — `/Users/mathias/Documents/local-dev/AI/supervisor` on flamingo
|
||||||
|
- **[koala-ssh]** — `ssh koala` (run commands via `ssh koala "..."`)
|
||||||
|
- **[infra-repo]** — `gitea.d-ma.be/mathias/infra` (clone to a temp dir, work there, push)
|
||||||
|
- **[gitea-ui]** — Gitea web UI at `https://gitea.d-ma.be`
|
||||||
|
- **[kubectl]** — kubectl from flamingo (home LAN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
**This repo (supervisor):**
|
||||||
|
- Create: `Dockerfile`
|
||||||
|
- Create: `.gitea/workflows/cd.yml`
|
||||||
|
|
||||||
|
**koala host:**
|
||||||
|
- Create: `/etc/systemd/system/buildkitd.service` (or user-level equivalent)
|
||||||
|
- Create: `/root/.config/buildkit/buildkitd.toml` (registry auth config)
|
||||||
|
|
||||||
|
**Infra repo (`gitea.d-ma.be/mathias/infra`):**
|
||||||
|
- Create: `apps/supervisor/namespace.yaml`
|
||||||
|
- Create: `apps/supervisor/deployment.yaml`
|
||||||
|
- Create: `apps/supervisor/service.yaml`
|
||||||
|
- Create: `apps/supervisor/secrets.enc.yaml` (SOPS-encrypted)
|
||||||
|
- Create: `apps/supervisor/kustomization.yaml`
|
||||||
|
- Create: `apps/imagepullsecret/secret.enc.yaml` (SOPS-encrypted)
|
||||||
|
- Create: `apps/imagepullsecret/kustomization.yaml`
|
||||||
|
- Modify: `clusters/koala/kustomization.yaml` (add supervisor + imagepullsecret)
|
||||||
|
- Modify: `flux-system/kustomization.yaml` or relevant Flux Kustomization CRD (add SOPS decryption)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Dockerfile [this-repo]
|
||||||
|
|
||||||
|
The supervisor binary depends on the `claude` CLI as a subprocess. The image uses a multi-stage build: Go builder stage compiles the binary; the runtime stage is Node.js (for `npm install -g @anthropic-ai/claude-code`). Config files are baked in. The `brain/` directory is a volume mount.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Dockerfile`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify no Dockerfile exists**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls Dockerfile 2>/dev/null || echo "confirmed: no Dockerfile"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `confirmed: no Dockerfile`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the Dockerfile**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
EXPOSE 3200
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/supervisor"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build locally to verify it compiles (no push)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# buildctl must be available locally, OR use docker if available on flamingo
|
||||||
|
docker build --target builder -t supervisor-build-test . && echo "build stage OK"
|
||||||
|
# If no docker on flamingo, skip this step and verify at Task 3 on koala instead
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Dockerfile
|
||||||
|
git commit -m "feat: add multi-stage Dockerfile with claude CLI runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: BuildKit systemd service on koala [koala-ssh]
|
||||||
|
|
||||||
|
Install `buildkitd` as a root-level systemd service on koala. The Gitea runner process runs as root (confirmed by PID/cgroup), so the root socket at `/run/buildkit/buildkitd.sock` is accessible to it.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/etc/systemd/system/buildkitd.service` on koala
|
||||||
|
- Create: `/etc/buildkit/buildkitd.toml` on koala (registry auth)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check if buildkitd is already installed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "buildkitd --version 2>/dev/null || echo 'not installed'"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install buildkitd on koala**
|
||||||
|
|
||||||
|
Download the latest buildkit release binary (arm64 or amd64 — koala has x86_64):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
BUILDKIT_VERSION=v0.21.0
|
||||||
|
curl -sSL https://github.com/moby/buildkit/releases/download/\${BUILDKIT_VERSION}/buildkit-\${BUILDKIT_VERSION}.linux-amd64.tar.gz \
|
||||||
|
| tar -xz -C /usr/local/
|
||||||
|
buildkitd --version
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output includes: `buildkitd github.com/moby/buildkit v0.21.0`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create buildkitd.toml with Gitea registry auth**
|
||||||
|
|
||||||
|
The `[registry]` block configures auth for pushing to `gitea.d-ma.be`. The actual credentials come from `~/.docker/config.json` (which buildkitd reads automatically) — this toml just enables the registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
mkdir -p /etc/buildkit
|
||||||
|
cat > /etc/buildkit/buildkitd.toml << 'EOF'
|
||||||
|
[worker.containerd]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[worker.oci]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[registry.\"gitea.d-ma.be\"]
|
||||||
|
http = false
|
||||||
|
insecure = false
|
||||||
|
EOF
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create systemd unit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
cat > /etc/systemd/system/buildkitd.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=BuildKit daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
ExecStart=/usr/local/bin/buildkitd --config /etc/buildkit/buildkitd.toml
|
||||||
|
Restart=on-failure
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
LimitNPROC=1048576
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable buildkitd
|
||||||
|
systemctl start buildkitd
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the socket exists and is responsive**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
systemctl status buildkitd --no-pager
|
||||||
|
buildctl --addr unix:///run/buildkit/buildkitd.sock debug info
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: service `active (running)`, buildctl shows BuildKit version info.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Smoke-test build with trivial Dockerfile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
echo 'FROM alpine:3.21
|
||||||
|
RUN echo hello' | buildctl --addr unix:///run/buildkit/buildkitd.sock build \
|
||||||
|
--frontend dockerfile.v0 \
|
||||||
|
--local context=/ \
|
||||||
|
--opt filename=Dockerfile \
|
||||||
|
--output type=image,name=localhost/smoke-test:latest
|
||||||
|
echo 'smoke test OK'
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `smoke test OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Gitea registry push auth for buildctl [koala-ssh]
|
||||||
|
|
||||||
|
`buildctl` reads Docker-style credentials from `/root/.docker/config.json`. Create the credentials file so the runner can push to `gitea.d-ma.be`.
|
||||||
|
|
||||||
|
**Prerequisites:** A Gitea user token or password with `write:packages` scope for the `mathias` org. Create one in Gitea → User Settings → Applications → Generate Token (scopes: `write:packages`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create Gitea access token**
|
||||||
|
|
||||||
|
In Gitea UI (`https://gitea.d-ma.be`) → top-right avatar → Settings → Applications → Generate New Token.
|
||||||
|
- Token name: `buildkit-push`
|
||||||
|
- Scopes: `write:packages` (container registry write)
|
||||||
|
- Copy the token — it won't be shown again.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write docker config.json on koala**
|
||||||
|
|
||||||
|
Replace `<TOKEN>` with the token from Step 1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
mkdir -p /root/.docker
|
||||||
|
TOKEN=<TOKEN>
|
||||||
|
AUTH=\$(echo -n 'mathias:'\${TOKEN} | base64)
|
||||||
|
cat > /root/.docker/config.json << EOF
|
||||||
|
{
|
||||||
|
\"auths\": {
|
||||||
|
\"gitea.d-ma.be\": {
|
||||||
|
\"auth\": \"\${AUTH}\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 /root/.docker/config.json
|
||||||
|
echo 'credentials written'
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify push works**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh koala "
|
||||||
|
echo 'FROM alpine:3.21' | buildctl --addr unix:///run/buildkit/buildkitd.sock build \
|
||||||
|
--frontend dockerfile.v0 \
|
||||||
|
--local context=/ \
|
||||||
|
--opt filename=Dockerfile \
|
||||||
|
--output type=image,name=gitea.d-ma.be/mathias/supervisor:push-test,push=true
|
||||||
|
echo 'push OK'
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `push OK`. Verify in Gitea UI: `https://gitea.d-ma.be/mathias/supervisor/packages` should show a `push-test` tag.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Delete the test image tag**
|
||||||
|
|
||||||
|
In Gitea UI → supervisor repo → Packages tab → delete the `push-test` tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: age keypair + Flux SOPS decryption [kubectl + flamingo]
|
||||||
|
|
||||||
|
Flux decrypts SOPS-encrypted secrets at apply time. It needs the age private key stored as a k8s Secret in the `flux-system` namespace.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify age is installed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
age --version || brew install age
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate age keypair**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
age-keygen -o /tmp/supervisor-age.key
|
||||||
|
cat /tmp/supervisor-age.key
|
||||||
|
```
|
||||||
|
|
||||||
|
Output includes two lines:
|
||||||
|
```
|
||||||
|
# public key: age1xxxxxx...
|
||||||
|
AGE-SECRET-KEY-1xxxxxxx...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy the public key** (the `age1...` value) — you'll need it in Task 7 for encrypting secrets.
|
||||||
|
**Store the private key file securely** — back it up outside the cluster (e.g., 1Password or encrypted note).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the SOPS age secret in flux-system**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic sops-age \
|
||||||
|
--from-file=age.agekey=/tmp/supervisor-age.key \
|
||||||
|
-n flux-system
|
||||||
|
kubectl get secret sops-age -n flux-system
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: secret exists with `age.agekey` key.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Shred the temp key file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shred -u /tmp/supervisor-age.key
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Check what Flux Kustomization CRDs exist in the infra repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-sops-setup
|
||||||
|
ls /tmp/infra-sops-setup/flux-system/
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for a `kustomization.yaml` or `gotk-sync.yaml` that defines the main Flux Kustomization resource pointing at the `clusters/koala/` path.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Patch the Flux Kustomization to enable SOPS decryption**
|
||||||
|
|
||||||
|
Find the Kustomization resource that syncs `clusters/koala/`. It will look like:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
path: ./clusters/koala
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `decryption` block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the file in `/tmp/infra-sops-setup/flux-system/` and commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/infra-sops-setup
|
||||||
|
# Edit the relevant Kustomization yaml to add decryption block (shown above)
|
||||||
|
git add flux-system/
|
||||||
|
git commit -m "feat: enable SOPS decryption via age key in flux-system"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify Flux picks up the change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux reconcile source git flux-system
|
||||||
|
flux get kustomizations
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `flux-system` Kustomization shows `Ready True` with no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Clean up temp clone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/infra-sops-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Infra repo — supervisor app manifests [infra-repo]
|
||||||
|
|
||||||
|
Create the full k8s manifest set for the supervisor service in the infra repo. The deployment uses an `IMAGE_TAG` placeholder; the CD job patches this with the actual git sha before pushing.
|
||||||
|
|
||||||
|
**Prerequisites:** age public key from Task 4 Step 2.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Clone the infra repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-supervisor
|
||||||
|
cd /tmp/infra-supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create namespace**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p apps/supervisor
|
||||||
|
cat > apps/supervisor/namespace.yaml << 'EOF'
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: supervisor
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create deployment**
|
||||||
|
|
||||||
|
The `brain` volume is a `hostPath` on koala (simplest for a single-node service; add a PVC later if needed). The image uses `imagePullSecrets` to pull from the Gitea registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > apps/supervisor/deployment.yaml << 'EOF'
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: supervisor
|
||||||
|
namespace: supervisor
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: supervisor
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: supervisor
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: koala
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
|
containers:
|
||||||
|
- name: supervisor
|
||||||
|
image: gitea.d-ma.be/mathias/supervisor:IMAGE_TAG
|
||||||
|
ports:
|
||||||
|
- containerPort: 3200
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: supervisor-secrets
|
||||||
|
env:
|
||||||
|
- name: SUPERVISOR_PORT
|
||||||
|
value: "3200"
|
||||||
|
- name: LITELLM_BASE_URL
|
||||||
|
value: "http://iguana:4000"
|
||||||
|
- name: LLAMA_SWAP_URL
|
||||||
|
value: "http://koala:8080"
|
||||||
|
- name: INGEST_BASE_URL
|
||||||
|
value: "http://localhost:3300"
|
||||||
|
volumeMounts:
|
||||||
|
- name: brain
|
||||||
|
mountPath: /app/brain
|
||||||
|
volumes:
|
||||||
|
- name: brain
|
||||||
|
hostPath:
|
||||||
|
path: /var/lib/supervisor/brain
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create service**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > apps/supervisor/service.yaml << 'EOF'
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: supervisor
|
||||||
|
namespace: supervisor
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: supervisor
|
||||||
|
ports:
|
||||||
|
- port: 3200
|
||||||
|
targetPort: 3200
|
||||||
|
type: ClusterIP
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create kustomization.yaml for supervisor**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > apps/supervisor/kustomization.yaml << 'EOF'
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- secrets.enc.yaml
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Ensure clusters/koala/kustomization.yaml exists and includes supervisor**
|
||||||
|
|
||||||
|
Check if the file exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat clusters/koala/kustomization.yaml 2>/dev/null || echo "need to create"
|
||||||
|
```
|
||||||
|
|
||||||
|
If it exists, add supervisor and imagepullsecret resources. If it does not exist, create it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > clusters/koala/kustomization.yaml << 'EOF'
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- ../../apps/imagepullsecret
|
||||||
|
- ../../apps/supervisor
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
If it already exists, add the two resource lines (preserving existing entries).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit (without secrets — those come in Task 6)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/infra-supervisor
|
||||||
|
git add apps/supervisor/ clusters/koala/
|
||||||
|
git commit -m "feat(supervisor): add k8s manifests for supervisor service"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: SOPS-encrypted secrets in infra repo [infra-repo + flamingo]
|
||||||
|
|
||||||
|
Two encrypted secret files: the imagePullSecret for the Gitea container registry, and the supervisor app secrets (ANTHROPIC_API_KEY, LITELLM_API_KEY).
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- age public key from Task 4 Step 2 (format: `age1xxxxx...`)
|
||||||
|
- `sops` installed (`brew install sops` if missing)
|
||||||
|
- Gitea registry token (same one used in Task 3, or create a read-only one for pulling)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify sops is installed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops --version || brew install sops
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create .sops.yaml in infra repo root**
|
||||||
|
|
||||||
|
This tells sops which key to use for all files in the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/infra-supervisor
|
||||||
|
cat > .sops.yaml << 'EOF'
|
||||||
|
creation_rules:
|
||||||
|
- age: age1REPLACE_WITH_YOUR_PUBLIC_KEY
|
||||||
|
EOF
|
||||||
|
git add .sops.yaml
|
||||||
|
git commit -m "chore: add sops config (age key)"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `age1REPLACE_WITH_YOUR_PUBLIC_KEY` with the actual age public key from Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create and encrypt the imagePullSecret**
|
||||||
|
|
||||||
|
The imagePullSecret is a namespace-less Secret (it will be targeted per namespace via Kustomize). Create it in the `imagepullsecret` app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p apps/imagepullsecret
|
||||||
|
|
||||||
|
# Create a registry pull token in Gitea: Settings → Applications → Generate Token
|
||||||
|
# Scopes: read:packages
|
||||||
|
# Use that token here (or reuse the buildkit-push token — read access is enough for pulling)
|
||||||
|
PULL_TOKEN=<gitea-read-packages-token>
|
||||||
|
PULL_AUTH=$(echo -n "mathias:${PULL_TOKEN}" | base64)
|
||||||
|
|
||||||
|
cat > /tmp/gitea-pull-secret.yaml << EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: gitea-registry
|
||||||
|
namespace: supervisor
|
||||||
|
type: kubernetes.io/dockerconfigjson
|
||||||
|
stringData:
|
||||||
|
.dockerconfigjson: |
|
||||||
|
{
|
||||||
|
"auths": {
|
||||||
|
"gitea.d-ma.be": {
|
||||||
|
"auth": "${PULL_AUTH}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sops --encrypt /tmp/gitea-pull-secret.yaml > apps/imagepullsecret/secret.enc.yaml
|
||||||
|
rm /tmp/gitea-pull-secret.yaml
|
||||||
|
|
||||||
|
cat > apps/imagepullsecret/kustomization.yaml << 'EOF'
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- secret.enc.yaml
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the encrypted file looks correct (should show `sops:` metadata at the bottom):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -20 apps/imagepullsecret/secret.enc.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create and encrypt supervisor app secrets**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ANTHROPIC_API_KEY: your Anthropic API key
|
||||||
|
# LITELLM_API_KEY: the key your LiteLLM instance expects (can be any string if it's local)
|
||||||
|
cat > /tmp/supervisor-secrets.yaml << 'EOF'
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: supervisor-secrets
|
||||||
|
namespace: supervisor
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
ANTHROPIC_API_KEY: "REPLACE_WITH_REAL_KEY"
|
||||||
|
LITELLM_API_KEY: "REPLACE_WITH_REAL_KEY"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Edit /tmp/supervisor-secrets.yaml to insert real values, then:
|
||||||
|
sops --encrypt /tmp/supervisor-secrets.yaml > apps/supervisor/secrets.enc.yaml
|
||||||
|
rm /tmp/supervisor-secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -20 apps/supervisor/secrets.enc.yaml
|
||||||
|
# Should show encrypted values and sops metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit encrypted secrets**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/infra-supervisor
|
||||||
|
git add apps/imagepullsecret/ apps/supervisor/secrets.enc.yaml .sops.yaml
|
||||||
|
git commit -m "feat: add SOPS-encrypted imagePullSecret and supervisor app secrets"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify Flux reconciles and creates the secrets**
|
||||||
|
|
||||||
|
Wait ~60s then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux reconcile kustomization flux-system --with-source
|
||||||
|
kubectl get secrets -n supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `gitea-registry` and `supervisor-secrets` appear in the `supervisor` namespace.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Clean up temp clone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/infra-supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Gitea org-level secrets [gitea-ui + koala-ssh]
|
||||||
|
|
||||||
|
Set the three secrets that all repos in the `mathias` org will inherit. These go in the Gitea org (not individual repos).
|
||||||
|
|
||||||
|
**Files:** No files — Gitea UI configuration.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate SSH deploy key for infra repo**
|
||||||
|
|
||||||
|
On flamingo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -C "cd-bot infra deploy key" -f /tmp/infra-deploy-key -N ""
|
||||||
|
cat /tmp/infra-deploy-key # private key → INFRA_DEPLOY_KEY secret
|
||||||
|
cat /tmp/infra-deploy-key.pub # public key → add to Gitea infra repo as deploy key
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add public key to infra repo as a deploy key (write access)**
|
||||||
|
|
||||||
|
In Gitea UI: `https://gitea.d-ma.be/mathias/infra` → Settings → Deploy Keys → Add Deploy Key.
|
||||||
|
- Title: `cd-bot`
|
||||||
|
- Key: paste content of `/tmp/infra-deploy-key.pub`
|
||||||
|
- Enable write access: ✓
|
||||||
|
|
||||||
|
- [ ] **Step 3: Set org-level secrets in Gitea**
|
||||||
|
|
||||||
|
In Gitea UI: `https://gitea.d-ma.be/org/mathias/settings/secrets` → Add Secret.
|
||||||
|
|
||||||
|
Set these three secrets:
|
||||||
|
|
||||||
|
| Secret name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `INFRA_DEPLOY_KEY` | content of `/tmp/infra-deploy-key` (private key, including `-----BEGIN...` lines) |
|
||||||
|
| `BUILDKIT_REGISTRY_AUTH` | same base64 auth string as used in Task 3 Step 2 (format: `mathias:<token>` base64-encoded) |
|
||||||
|
|
||||||
|
Note: `BUILDKIT_REGISTRY_AUTH` is redundant if `/root/.docker/config.json` is already on the runner host from Task 3 — but setting it as a secret allows the `cd.yml` to explicitly pass it to `buildctl` for clarity and rotation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean up temp key files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shred -u /tmp/infra-deploy-key /tmp/infra-deploy-key.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify secrets appear in Gitea**
|
||||||
|
|
||||||
|
In Gitea UI: `https://gitea.d-ma.be/org/mathias/settings/secrets` — confirm both secrets are listed (values are hidden, only names shown).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: cd.yml workflow [this-repo]
|
||||||
|
|
||||||
|
Create the CD workflow that triggers after CI passes, builds the image with buildctl, and commits the updated tag to the infra repo.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/cd.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create cd.yml**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .gitea/workflows/cd.yml << 'EOF'
|
||||||
|
name: cd
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Build and deploy
|
||||||
|
needs: [check] # 'check' is the job name in ci.yml
|
||||||
|
runs-on: self-hosted
|
||||||
|
env:
|
||||||
|
SERVICE: supervisor
|
||||||
|
REGISTRY: gitea.d-ma.be
|
||||||
|
IMAGE: gitea.d-ma.be/mathias/supervisor
|
||||||
|
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
||||||
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
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=image,name=${IMAGE}:${IMAGE_TAG},push=true"
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT
|
||||||
|
id: build
|
||||||
|
|
||||||
|
- name: Update infra repo
|
||||||
|
run: |
|
||||||
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
# Write SSH key for infra repo
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
|
ssh-keyscan gitea.d-ma.be >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Clone infra repo
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
|
# Patch the image tag
|
||||||
|
cd /tmp/infra-update
|
||||||
|
sed -i "s|gitea.d-ma.be/mathias/supervisor:.*|gitea.d-ma.be/mathias/supervisor:${IMAGE_TAG}|" \
|
||||||
|
"apps/${SERVICE}/deployment.yaml"
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
git config user.email "cd-bot@d-ma.be"
|
||||||
|
git config user.name "CD Bot"
|
||||||
|
git add "apps/${SERVICE}/deployment.yaml"
|
||||||
|
git commit -m "chore(deploy): ${SERVICE} → ${IMAGE_TAG}"
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git push
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm -rf /tmp/infra-update
|
||||||
|
rm ~/.ssh/infra_deploy_key
|
||||||
|
echo "Infra repo updated: ${SERVICE} → ${IMAGE_TAG}"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the `needs` job name matches ci.yml**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "^ [a-z].*:$" .gitea/workflows/ci.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The output should show `check:` as the quality-gate job name. The `cd.yml` uses `needs: [check]` — confirm this matches.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/cd.yml
|
||||||
|
git commit -m "feat: add CD workflow (buildctl → Gitea registry → infra repo update)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: End-to-end smoke test
|
||||||
|
|
||||||
|
Trigger the full pipeline and verify each stage.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push to main to trigger CI + CD**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Monitor CI job in Gitea**
|
||||||
|
|
||||||
|
Open `https://gitea.d-ma.be/mathias/supervisor/actions` — wait for the `ci` workflow `check` job to pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Monitor CD job**
|
||||||
|
|
||||||
|
In the same actions view, the `cd` workflow should start after `ci` passes. Check the `Build and push image` step output for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Building gitea.d-ma.be/mathias/supervisor:<sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
And the `Update infra repo` step for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Infra repo updated: supervisor → <sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify image in Gitea registry**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.d-ma.be/mathias/supervisor/packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Should show a new tag matching the commit sha.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify infra repo commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-verify
|
||||||
|
cd /tmp/infra-verify
|
||||||
|
git log --oneline -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: most recent commit message is `chore(deploy): supervisor → <sha>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "image:" apps/supervisor/deployment.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `image: gitea.d-ma.be/mathias/supervisor:<sha>`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify Flux reconciles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flux get kustomizations
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `flux-system` shows `Ready True` and `Applied revision: main/<infra-sha>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods -n supervisor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: supervisor pod is `Running` with the new image sha.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify pod started correctly**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl logs -n supervisor deployment/supervisor --tail=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: supervisor startup logs (MCP server listening on port 3200, no errors).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Clean up verify clone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/infra-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Post-deploy — registry retention policy [gitea-ui]
|
||||||
|
|
||||||
|
Prevent the Gitea container registry from filling up by setting a tag retention policy.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Set tag retention in Gitea**
|
||||||
|
|
||||||
|
In Gitea UI: `https://gitea.d-ma.be/mathias/supervisor` → Settings → Packages → Container Registry.
|
||||||
|
|
||||||
|
Set: Keep last **20** tags per image name.
|
||||||
|
|
||||||
|
If Gitea does not expose a UI retention policy, note this for manual cleanup and open a task to automate it (e.g., a weekly Actions job that calls `docker image prune` via the Gitea API).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify existing test tags are cleaned up**
|
||||||
|
|
||||||
|
Manually delete any test tags pushed during Task 3 if not already done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review checklist (for plan author — not a task)
|
||||||
|
|
||||||
|
- [x] **Spec coverage:** BuildKit systemd ✓, cd.yml ✓, Flux SOPS ✓, infra repo structure ✓, imagePullSecret ✓, app secrets ✓, Gitea org secrets ✓, error handling (implicit in workflow failures) ✓, registry retention ✓, smoke test ✓
|
||||||
|
- [x] **Placeholders:** `REPLACE_WITH_YOUR_PUBLIC_KEY` and `REPLACE_WITH_REAL_KEY` are intentional — real values come from user's secrets; marked clearly
|
||||||
|
- [x] **Type consistency:** No shared types across tasks (infra-only plan)
|
||||||
|
- [x] **Known gaps:** `needs: [check]` assumes ci.yml job name is `check` — verified in Task 8 Step 2. The `sed` image tag patch assumes no other image line in deployment.yaml — the deployment template only has one `image:` line.
|
||||||
1617
docs/superpowers/plans/2026-04-20-model-orchestration-plan.md
Normal file
1617
docs/superpowers/plans/2026-04-20-model-orchestration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
2608
docs/superpowers/plans/2026-04-22-brain-ingestion-pipeline.md
Normal file
2608
docs/superpowers/plans/2026-04-22-brain-ingestion-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
858
docs/superpowers/plans/2026-04-22-brain-ingestion-quality.md
Normal file
858
docs/superpowers/plans/2026-04-22-brain-ingestion-quality.md
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
# Brain Ingestion Quality: PDF Extraction + Entity Resolution
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix PDF ingestion (currently passes raw bytes to LLM) and add fuzzy entity resolution (prevents slug proliferation at scale).
|
||||||
|
|
||||||
|
**Architecture:** Two independent improvements wired into the existing pipeline. A new `extract` package handles text extraction by file type (pdftotext subprocess, passthrough for .md/.txt). A new `resolve.go` in the `pipeline` package normalizes proposed entity/concept titles against the loaded inventory to reuse existing slugs instead of creating duplicates. Both changes are wired into `watcher.go` and `api/handler.go` with no new dependencies except `poppler-utils` in the Docker image.
|
||||||
|
|
||||||
|
**Tech Stack:** Go stdlib (`os/exec`, `bufio`, `strings`), testify, poppler-utils (`pdftotext`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `ingestion/internal/extract/extract.go` — `Text(path string) (string, error)` dispatcher
|
||||||
|
- `ingestion/internal/extract/pdf.go` — `pdftotext` subprocess extraction
|
||||||
|
- `ingestion/internal/extract/extract_test.go` — table-driven tests for all paths
|
||||||
|
- `ingestion/internal/pipeline/resolve.go` — `Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []wiki.Page`
|
||||||
|
- `ingestion/internal/pipeline/resolve_test.go` — table-driven tests
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `ingestion/internal/wiki/types.go` — add `Aliases []string` to `Entry`
|
||||||
|
- `ingestion/internal/wiki/inventory.go` — `readFrontmatter` reads both title and aliases
|
||||||
|
- `ingestion/internal/wiki/inventory_test.go` — add alias coverage
|
||||||
|
- `ingestion/internal/pipeline/pipeline.go` — call `Resolve` after `ParsePages`
|
||||||
|
- `ingestion/internal/watcher/watcher.go` — call `extract.Text` instead of `os.ReadFile`
|
||||||
|
- `ingestion/internal/api/handler.go` — call `extract.Text` for path-based ingestion
|
||||||
|
- `ingestion/Dockerfile` — `apk add poppler-utils`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `extract` package — Text() dispatcher with .md/.txt passthrough
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ingestion/internal/extract/extract.go`
|
||||||
|
- Create: `ingestion/internal/extract/extract_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/extract/extract_test.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestText_Markdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.md")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("# Hello\n\nWorld."), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# Hello\n\nWorld.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_Txt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.txt")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("plain text"), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "plain text", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_UnsupportedExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "data.csv")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("a,b,c"), 0o644))
|
||||||
|
|
||||||
|
_, err := Text(path)
|
||||||
|
assert.ErrorContains(t, err, "unsupported")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/extract/... -v
|
||||||
|
```
|
||||||
|
Expected: compile error — package does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement extract.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/extract/extract.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text reads the file at path and returns its plain-text content.
|
||||||
|
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
||||||
|
func Text(path string) (string, error) {
|
||||||
|
ext := strings.ToLower(fileExt(path))
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt":
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
case ".pdf":
|
||||||
|
return extractPDF(path)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExt returns the file extension including the dot, lowercased.
|
||||||
|
func fileExt(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '.' {
|
||||||
|
return path[i:]
|
||||||
|
}
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add pdf.go stub so it compiles**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/extract/pdf.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func extractPDF(_ string) (string, error) {
|
||||||
|
return "", fmt.Errorf("PDF extraction not implemented")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/extract/... -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 3 tests passing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/extract/
|
||||||
|
git commit -m "feat(extract): add Text() dispatcher with md/txt passthrough"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: PDF extraction via pdftotext
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/internal/extract/pdf.go`
|
||||||
|
- Modify: `ingestion/internal/extract/extract_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add PDF test (skip if pdftotext absent)**
|
||||||
|
|
||||||
|
Append to `extract_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestText_PDF(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("pdftotext"); err != nil {
|
||||||
|
t.Skip("pdftotext not available")
|
||||||
|
}
|
||||||
|
// Use a known PDF fixture; if none, create a minimal one via echo.
|
||||||
|
// The test verifies the round-trip: a PDF containing "Hello PDF" yields that string.
|
||||||
|
dir := t.TempDir()
|
||||||
|
pdfPath := filepath.Join(dir, "test.pdf")
|
||||||
|
|
||||||
|
// Generate a minimal single-page PDF using a here-doc approach.
|
||||||
|
// This is a valid minimal PDF containing the text "Hello PDF".
|
||||||
|
minimalPDF := "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||||
|
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||||
|
"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n" +
|
||||||
|
"4 0 obj<</Length 44>>\nstream\nBT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\nendstream\nendobj\n" +
|
||||||
|
"xref\n0 5\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\n0000000310 00000 n\n" +
|
||||||
|
"trailer<</Size 5/Root 1 0 R>>\nstartxref\n406\n%%EOF\n"
|
||||||
|
require.NoError(t, os.WriteFile(pdfPath, []byte(minimalPDF), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(pdfPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, got, "Hello PDF")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"os/exec"` to imports in `extract_test.go`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails (or skips)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/extract/... -v -run TestText_PDF
|
||||||
|
```
|
||||||
|
Expected: SKIP (pdftotext not installed locally) or FAIL with "not implemented".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement pdf.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/extract/pdf.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractPDF runs pdftotext on path and returns the extracted text.
|
||||||
|
// pdftotext must be installed (package: poppler-utils on Alpine/Debian, poppler on Homebrew).
|
||||||
|
func extractPDF(path string) (string, error) {
|
||||||
|
cmd := exec.Command("pdftotext", "-q", path, "-")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("pdftotext: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(stdout.String()), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all extract tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/extract/... -v
|
||||||
|
```
|
||||||
|
Expected: PASS (PDF test skips if pdftotext absent, passes if present).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/extract/pdf.go internal/extract/extract_test.go
|
||||||
|
git commit -m "feat(extract): implement PDF extraction via pdftotext"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `Entry.Aliases` + inventory reads aliases from frontmatter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/internal/wiki/types.go`
|
||||||
|
- Modify: `ingestion/internal/wiki/inventory.go`
|
||||||
|
- Modify: `ingestion/internal/wiki/inventory_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for alias loading**
|
||||||
|
|
||||||
|
Add to `inventory_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestLoadInventory_ReadsAliases(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "entities"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "sources"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "wiki", "entities", "ryan-singer.md"),
|
||||||
|
[]byte("---\ntitle: Ryan Singer\naliases:\n - Singer\n - R. Singer\n---\n\n## Description\n\nDesigner.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
|
||||||
|
inv, err := LoadInventory(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, inv[PageTypeEntity], 1)
|
||||||
|
e := inv[PageTypeEntity][0]
|
||||||
|
assert.Equal(t, "Ryan Singer", e.Title)
|
||||||
|
assert.Equal(t, []string{"Singer", "R. Singer"}, e.Aliases)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/wiki/... -v -run TestLoadInventory_ReadsAliases
|
||||||
|
```
|
||||||
|
Expected: compile error — `Entry` has no `Aliases` field.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add Aliases to Entry in types.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Entry is a summary of an existing wiki page used to build the inventory.
|
||||||
|
type Entry struct {
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Aliases []string
|
||||||
|
Type PageType
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace readTitle with readFrontmatter in inventory.go**
|
||||||
|
|
||||||
|
Replace the `readTitle` function and its call site:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// readFrontmatter extracts title and aliases from YAML frontmatter.
|
||||||
|
// Falls back to slug for title and empty aliases on any error.
|
||||||
|
func readFrontmatter(path, fallbackSlug string) (title string, aliases []string) {
|
||||||
|
title = fallbackSlug
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
inFM := false
|
||||||
|
inAliases := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFM {
|
||||||
|
inFM = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break // end of frontmatter
|
||||||
|
}
|
||||||
|
if !inFM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect alias list items (lines starting with " - ").
|
||||||
|
if inAliases {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "- ") {
|
||||||
|
aliases = append(aliases, strings.TrimPrefix(trimmed, "- "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inAliases = false // end of alias block
|
||||||
|
}
|
||||||
|
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "title":
|
||||||
|
title = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
case "aliases":
|
||||||
|
inAliases = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `LoadInventory` to use `readFrontmatter`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
title, aliases := readFrontmatter(path, slug)
|
||||||
|
result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt})
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the old `readTitle` function entirely.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all wiki tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/wiki/... -v
|
||||||
|
```
|
||||||
|
Expected: PASS — all existing tests plus new alias test.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/wiki/types.go internal/wiki/inventory.go internal/wiki/inventory_test.go
|
||||||
|
git commit -m "feat(wiki): add Aliases to Entry and read from YAML frontmatter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Fuzzy entity resolution
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ingestion/internal/pipeline/resolve.go`
|
||||||
|
- Create: `ingestion/internal/pipeline/resolve_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/pipeline/resolve_test.go
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolve_NoMatch(t *testing.T) {
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/entities/new-person.md", Content: "---\ntitle: New Person\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeEntity: {
|
||||||
|
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: []string{"Singer"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "wiki/entities/new-person.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_TitleMatchRedirectsSlug(t *testing.T) {
|
||||||
|
// Proposed slug differs from existing but title matches.
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/entities/ryan-singer-the-designer.md", Content: "---\ntitle: Ryan Singer\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeEntity: {
|
||||||
|
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "wiki/entities/ryan-singer.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_AliasMatchRedirectsSlug(t *testing.T) {
|
||||||
|
// Proposed title matches an existing alias.
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/entities/singer.md", Content: "---\ntitle: Singer\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeEntity: {
|
||||||
|
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: []string{"Singer", "R. Singer"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "wiki/entities/ryan-singer.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_NormalizationCaseAndArticles(t *testing.T) {
|
||||||
|
// "the shape up method" normalizes to "shape up method" which matches "Shape Up Method".
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/concepts/the-shape-up-method.md", Content: "---\ntitle: The Shape Up Method\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeConcept: {
|
||||||
|
{Slug: "shape-up-method", Title: "Shape Up Method", Aliases: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "wiki/concepts/shape-up-method.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_OnlyMatchesSamePageType(t *testing.T) {
|
||||||
|
// A concept slug must not redirect to an entity with the same normalized name.
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/concepts/ryan-singer.md", Content: "---\ntitle: Ryan Singer\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeEntity: {
|
||||||
|
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: nil},
|
||||||
|
},
|
||||||
|
wiki.PageTypeConcept: {},
|
||||||
|
}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
// Not redirected — different page type.
|
||||||
|
assert.Equal(t, "wiki/concepts/ryan-singer.md", got[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EmptyInventory(t *testing.T) {
|
||||||
|
proposed := []wiki.Page{
|
||||||
|
{Path: "wiki/entities/first.md", Content: "---\ntitle: First\n---\n"},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{}
|
||||||
|
got := Resolve(proposed, inventory)
|
||||||
|
assert.Equal(t, proposed, got)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/pipeline/... -v -run TestResolve
|
||||||
|
```
|
||||||
|
Expected: compile error — `Resolve` not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement resolve.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/pipeline/resolve.go
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve remaps proposed pages to existing slugs when a fuzzy title match is found.
|
||||||
|
// It only matches within the same page type (entities→entities, concepts→concepts).
|
||||||
|
// Pages with no inventory match are returned unchanged.
|
||||||
|
func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []wiki.Page {
|
||||||
|
// Build normalized lookup: normalized_title → canonical slug, keyed by page type.
|
||||||
|
type key struct {
|
||||||
|
pt wiki.PageType
|
||||||
|
normalized string
|
||||||
|
}
|
||||||
|
lookup := make(map[key]string) // key → canonical slug
|
||||||
|
for pt, entries := range inventory {
|
||||||
|
for _, e := range entries {
|
||||||
|
k := key{pt: pt, normalized: normalizeTitle(e.Title)}
|
||||||
|
lookup[k] = e.Slug
|
||||||
|
for _, alias := range e.Aliases {
|
||||||
|
ak := key{pt: pt, normalized: normalizeTitle(alias)}
|
||||||
|
if _, exists := lookup[ak]; !exists {
|
||||||
|
lookup[ak] = e.Slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]wiki.Page, 0, len(proposed))
|
||||||
|
for _, page := range proposed {
|
||||||
|
pt := pageTypeFromPath(page.Path)
|
||||||
|
title := extractTitle(page.Content)
|
||||||
|
k := key{pt: pt, normalized: normalizeTitle(title)}
|
||||||
|
if canonicalSlug, ok := lookup[k]; ok {
|
||||||
|
// Redirect path to canonical slug.
|
||||||
|
dir := filepath.Dir(page.Path)
|
||||||
|
page.Path = dir + "/" + canonicalSlug + ".md"
|
||||||
|
}
|
||||||
|
out = append(out, page)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTitle lowercases, removes leading articles, collapses whitespace.
|
||||||
|
// "The Shape Up Method" → "shape up method"
|
||||||
|
func normalizeTitle(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
// Strip leading articles.
|
||||||
|
for _, article := range []string{"the ", "a ", "an "} {
|
||||||
|
s = strings.TrimPrefix(s, article)
|
||||||
|
}
|
||||||
|
// Collapse internal whitespace and replace hyphens.
|
||||||
|
s = strings.ReplaceAll(s, "-", " ")
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
|
||||||
|
func pageTypeFromPath(path string) wiki.PageType {
|
||||||
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return wiki.PageType(parts[1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTitle reads the title field from YAML frontmatter in content.
|
||||||
|
// Falls back to empty string if not found.
|
||||||
|
func extractTitle(content string) string {
|
||||||
|
lines := strings.SplitN(content, "\n", 30)
|
||||||
|
inFM := false
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFM {
|
||||||
|
inFM = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inFM {
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if ok && strings.TrimSpace(key) == "title" {
|
||||||
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run resolve tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/pipeline/... -v -run TestResolve
|
||||||
|
```
|
||||||
|
Expected: PASS — 6 tests passing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/pipeline/resolve.go internal/pipeline/resolve_test.go
|
||||||
|
git commit -m "feat(pipeline): add fuzzy entity resolution to prevent slug proliferation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire Resolve into pipeline.Run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/internal/pipeline/pipeline.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Resolve call after ParsePages in Run()**
|
||||||
|
|
||||||
|
In `pipeline.go`, locate the loop that builds `allPages`. After `allPages = append(allPages, pages...)`, we have all pages from all chunks. Resolve must run after all chunks are merged, against the snapshot inventory loaded at the start of the run.
|
||||||
|
|
||||||
|
Replace the `merged := mergeAll(allPages)` line with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
resolved := Resolve(allPages, inventory)
|
||||||
|
merged := mergeAll(resolved)
|
||||||
|
```
|
||||||
|
|
||||||
|
The full relevant section of `Run` after this change:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
userPrompt := BuildPrompt(schema, source, chunk, inventory)
|
||||||
|
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("LLM call: %w", err)
|
||||||
|
}
|
||||||
|
pages, warnings := ParsePages(output)
|
||||||
|
allPages = append(allPages, pages...)
|
||||||
|
allWarnings = append(allWarnings, warnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := Resolve(allPages, inventory)
|
||||||
|
merged := mergeAll(resolved)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all pipeline tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./internal/pipeline/... -v
|
||||||
|
```
|
||||||
|
Expected: PASS — all existing tests still pass (Resolve is a no-op when inventory is empty or no title matches).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/pipeline/pipeline.go
|
||||||
|
git commit -m "feat(pipeline): resolve proposed pages against inventory before writing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire extract.Text into watcher and handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/internal/watcher/watcher.go`
|
||||||
|
- Modify: `ingestion/internal/api/handler.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update watcher.go**
|
||||||
|
|
||||||
|
In `processFile`, replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, string(content), source, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, err := extract.Text(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("extract text: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import: `"github.com/mathiasbq/hyperguild/ingestion/internal/extract"`
|
||||||
|
|
||||||
|
Remove import: `"os"` if no longer used (check — `os` is still used for `os.MkdirAll`, `os.WriteFile`, `os.Stat`; keep it).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update handler.go — single-file path**
|
||||||
|
|
||||||
|
In `IngestPath`, the single-file branch reads:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, readErr := os.ReadFile(req.Path)
|
||||||
|
if readErr != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("read file: %v", readErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, readErr := extract.Text(req.Path)
|
||||||
|
if readErr != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update handler.go — directory walk branch**
|
||||||
|
|
||||||
|
In `IngestPath`, the directory walk reads:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("read %s: %v", path, readErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = filepath.Base(path)
|
||||||
|
}
|
||||||
|
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, string(content), source, req.DryRun)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
content, readErr := extract.Text(path)
|
||||||
|
if readErr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = filepath.Base(path)
|
||||||
|
}
|
||||||
|
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import: `"github.com/mathiasbq/hyperguild/ingestion/internal/extract"` to handler.go.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to verify no compile errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go build ./...
|
||||||
|
```
|
||||||
|
Expected: success, no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && go test ./...
|
||||||
|
```
|
||||||
|
Expected: PASS — all tests pass (watcher tests use .md files, already covered by extract passthrough).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add internal/watcher/watcher.go internal/api/handler.go
|
||||||
|
git commit -m "feat(watcher,api): use extract.Text() for file reading — fixes PDF ingestion"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add poppler-utils to Dockerfile
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/Dockerfile`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add apk install for poppler-utils**
|
||||||
|
|
||||||
|
In `ingestion/Dockerfile`, add `poppler-utils` to the Alpine runtime stage. The current final stage is:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
|
||||||
|
|
||||||
|
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache poppler-utils
|
||||||
|
|
||||||
|
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
|
||||||
|
|
||||||
|
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify Dockerfile builds (local Docker)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && docker build -t ingestion:test .
|
||||||
|
```
|
||||||
|
Expected: image builds successfully; `pdftotext` is available inside.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify pdftotext is accessible in the image**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm ingestion:test pdftotext -v
|
||||||
|
```
|
||||||
|
Expected: prints version string like `pdftotext version 24.x.x`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ingestion && git add Dockerfile
|
||||||
|
git commit -m "chore(docker): add poppler-utils for PDF text extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage check:**
|
||||||
|
|
||||||
|
| Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| PDF extraction via pdftotext | Tasks 2, 6, 7 |
|
||||||
|
| .md and .txt passthrough (no regression) | Task 1 |
|
||||||
|
| Unsupported extension → clear error | Task 1 |
|
||||||
|
| Entry.Aliases loaded from frontmatter | Task 3 |
|
||||||
|
| Fuzzy normalization (case, articles, hyphens) | Task 4 |
|
||||||
|
| Alias matching | Task 4 |
|
||||||
|
| Title matching across different proposed slugs | Task 4 |
|
||||||
|
| Cross-page-type isolation (concept ≠ entity) | Task 4 |
|
||||||
|
| Resolve wired into pipeline.Run | Task 5 |
|
||||||
|
| extract.Text wired into watcher | Task 6 |
|
||||||
|
| extract.Text wired into handler (single + dir) | Task 6 |
|
||||||
|
| Dockerfile includes poppler-utils | Task 7 |
|
||||||
|
|
||||||
|
**Placeholder scan:** None found.
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
- `Resolve([]wiki.Page, map[wiki.PageType][]wiki.Entry) []wiki.Page` — consistent across Tasks 4 and 5.
|
||||||
|
- `extract.Text(path string) (string, error)` — consistent across Tasks 1, 2, and 6.
|
||||||
|
- `Entry.Aliases []string` — added in Task 3, used by Resolve in Task 4 (reads `e.Aliases`).
|
||||||
|
- `readFrontmatter` replaces `readTitle` entirely in Task 3 — no lingering `readTitle` calls.
|
||||||
1073
docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
Normal file
1073
docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
Normal file
File diff suppressed because it is too large
Load Diff
1323
docs/superpowers/plans/2026-04-23-level3-slug-authority.md
Normal file
1323
docs/superpowers/plans/2026-04-23-level3-slug-authority.md
Normal file
File diff suppressed because it is too large
Load Diff
433
docs/superpowers/plans/2026-04-23-source-backrefs.md
Normal file
433
docs/superpowers/plans/2026-04-23-source-backrefs.md
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
# Source Back-References Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** After the LLM produces wiki pages for an ingestion, automatically inject a `## Sources` back-reference on every concept and entity page that the source page links to.
|
||||||
|
|
||||||
|
**Architecture:** A new `injectSourceRefs` post-processing step is inserted between `Resolve` and `mergeAll` in `pipeline.Run`. It finds the source page in the proposed batch, extracts all `[[slug|...]]` wikilinks, then calls `wiki.Merge` with a minimal patch page to add the back-reference. `wiki.Merge` already treats `## Sources` as a bullet section with deduplication — no custom section parsing is needed. For concepts/entities that exist on disk but weren't proposed in the current batch (the common case on re-ingestion), the function loads them from disk and adds them to the pages list so they are updated.
|
||||||
|
|
||||||
|
**Tech Stack:** Go stdlib (`regexp`, `os`, `path/filepath`, `strings`), existing `wiki.Merge` and `wiki.Page` types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `ingestion/internal/pipeline/refs.go` — `injectSourceRefs`, `addSourceRef`, `extractWikilinks`, `findSourcePage`, `findInInventory`
|
||||||
|
- `ingestion/internal/pipeline/refs_test.go` — table-driven tests
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `ingestion/internal/pipeline/pipeline.go` — insert `injectSourceRefs` call between `Resolve` and `mergeAll`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `refs.go` — source back-reference injection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ingestion/internal/pipeline/refs_test.go`
|
||||||
|
- Create: `ingestion/internal/pipeline/refs.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/pipeline/refs_test.go
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeInventory builds a minimal inventory for test use.
|
||||||
|
func makeInventory(concepts, entities []string) map[wiki.PageType][]wiki.Entry {
|
||||||
|
inv := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeConcept: {},
|
||||||
|
wiki.PageTypeEntity: {},
|
||||||
|
wiki.PageTypeSource: {},
|
||||||
|
}
|
||||||
|
for _, slug := range concepts {
|
||||||
|
inv[wiki.PageTypeConcept] = append(inv[wiki.PageTypeConcept], wiki.Entry{Slug: slug, Title: slug})
|
||||||
|
}
|
||||||
|
for _, slug := range entities {
|
||||||
|
inv[wiki.PageTypeEntity] = append(inv[wiki.PageTypeEntity], wiki.Entry{Slug: slug, Title: slug})
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_NoSourcePage(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nFoo.\n"},
|
||||||
|
}
|
||||||
|
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
|
||||||
|
assert.Equal(t, pages, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_InjectsIntoProposedConcept(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/my-article.md",
|
||||||
|
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[domain-driven-design|Domain Driven Design]].\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "wiki/concepts/domain-driven-design.md",
|
||||||
|
Content: "---\ntitle: Domain Driven Design\n---\n\n## Definition\n\nA methodology.\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
|
||||||
|
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
assert.Contains(t, got[1].Content, "## Sources")
|
||||||
|
assert.Contains(t, got[1].Content, "[[my-article|My Article]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_LoadsConceptFromDisk(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
conceptDir := filepath.Join(brainDir, "wiki", "concepts")
|
||||||
|
require.NoError(t, os.MkdirAll(conceptDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(conceptDir, "shape-up.md"),
|
||||||
|
[]byte("---\ntitle: Shape Up\n---\n\n## Definition\n\nA methodology.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/my-article.md",
|
||||||
|
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[shape-up|Shape Up]].\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
inv := makeInventory([]string{"shape-up"}, nil)
|
||||||
|
|
||||||
|
got := injectSourceRefs(pages, inv, brainDir)
|
||||||
|
|
||||||
|
// Should have loaded shape-up.md from disk and added it with source ref.
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
var conceptPage wiki.Page
|
||||||
|
for _, p := range got {
|
||||||
|
if p.Path == "wiki/concepts/shape-up.md" {
|
||||||
|
conceptPage = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, conceptPage.Content, "## Sources")
|
||||||
|
assert.Contains(t, conceptPage.Content, "[[my-article|My Article]]")
|
||||||
|
// Original content preserved.
|
||||||
|
assert.Contains(t, conceptPage.Content, "## Definition")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_NoSelfReference(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/my-article.md",
|
||||||
|
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSelf-link [[my-article|My Article]].\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
|
||||||
|
|
||||||
|
// Only one page — source should not reference itself.
|
||||||
|
assert.Len(t, got, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_DeduplicatesOnReingestion(t *testing.T) {
|
||||||
|
// Concept already has source ref from a prior ingestion.
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/my-article.md",
|
||||||
|
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[ddd|DDD]].\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "wiki/concepts/ddd.md",
|
||||||
|
Content: "---\ntitle: DDD\n---\n\n## Definition\n\nA thing.\n\n## Sources\n\n- [[my-article|My Article]]\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
|
||||||
|
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
// The source ref must appear exactly once.
|
||||||
|
count := 0
|
||||||
|
for _, line := range splitLines(got[1].Content) {
|
||||||
|
if line == "- [[my-article|My Article]]" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, count, "source ref should appear exactly once")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSourceRefs_InjectsIntoEntity(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/book.md",
|
||||||
|
Content: "---\ntitle: Book\n---\n\n## Summary\n\nBy [[ryan-singer|Ryan Singer]].\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "wiki/entities/ryan-singer.md",
|
||||||
|
Content: "---\ntitle: Ryan Singer\n---\n\n## Description\n\nA designer.\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
|
||||||
|
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
var entity wiki.Page
|
||||||
|
for _, p := range got {
|
||||||
|
if p.Path == "wiki/entities/ryan-singer.md" {
|
||||||
|
entity = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, entity.Content, "[[book|Book]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractWikilinks(t *testing.T) {
|
||||||
|
content := "See [[foo|Foo]] and [[bar|Bar]] and [[foo|Foo again]]."
|
||||||
|
got := extractWikilinks(content)
|
||||||
|
assert.True(t, got["foo"])
|
||||||
|
assert.True(t, got["bar"])
|
||||||
|
assert.Len(t, got, 2, "duplicate slugs should be deduplicated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLines is a test helper.
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range splitNewlines(s) {
|
||||||
|
if l != "" {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitNewlines(s string) []string {
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for i, c := range s {
|
||||||
|
if c == '\n' {
|
||||||
|
lines = append(lines, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, s[start:])
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -run "TestInjectSourceRefs|TestExtractWikilinks" -v
|
||||||
|
```
|
||||||
|
Expected: compile error — `injectSourceRefs` and `extractWikilinks` not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement refs.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ingestion/internal/pipeline/refs.go
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wikilinkRE = regexp.MustCompile(`\[\[([^|\]]+)\|`)
|
||||||
|
|
||||||
|
// injectSourceRefs finds the source page in the proposed batch, extracts its wikilinks,
|
||||||
|
// and injects a back-reference into every linked concept or entity page.
|
||||||
|
// Pages that exist on disk but are not in the current batch are loaded and appended
|
||||||
|
// so they will be updated on write.
|
||||||
|
func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry, brainDir string) []wiki.Page {
|
||||||
|
sourceSlug, sourceTitle, found := findSourcePage(pages)
|
||||||
|
if !found {
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate source page content for wikilink extraction.
|
||||||
|
var sourceContent string
|
||||||
|
for _, p := range pages {
|
||||||
|
if strings.HasPrefix(p.Path, "wiki/sources/") &&
|
||||||
|
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
|
||||||
|
sourceContent = p.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedSlugs := extractWikilinks(sourceContent)
|
||||||
|
sourceRef := "- [[" + sourceSlug + "|" + sourceTitle + "]]"
|
||||||
|
|
||||||
|
// Build slug → index map for proposed pages (excluding wiki/sources/).
|
||||||
|
bySlug := make(map[string]int, len(pages))
|
||||||
|
for i, p := range pages {
|
||||||
|
if !strings.HasPrefix(p.Path, "wiki/sources/") {
|
||||||
|
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for slug := range linkedSlugs {
|
||||||
|
if slug == sourceSlug {
|
||||||
|
continue // no self-reference
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx, ok := bySlug[slug]; ok {
|
||||||
|
// Concept/entity is in the proposed batch — inject inline.
|
||||||
|
pages[idx] = addSourceRef(pages[idx], sourceRef)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in proposed batch — look for it in the inventory (exists on disk).
|
||||||
|
pt, ok := findInInventory(slug, inventory)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
|
||||||
|
b, err := os.ReadFile(diskPath)
|
||||||
|
if err != nil {
|
||||||
|
continue // page not found on disk; skip
|
||||||
|
}
|
||||||
|
page := wiki.Page{
|
||||||
|
Path: "wiki/" + string(pt) + "/" + slug + ".md",
|
||||||
|
Content: string(b),
|
||||||
|
}
|
||||||
|
pages = append(pages, addSourceRef(page, sourceRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSourceRef injects sourceRef into the ## Sources bullet section of page.
|
||||||
|
// Uses wiki.Merge so that existing Sources entries are deduplicated and all
|
||||||
|
// other sections are preserved unchanged.
|
||||||
|
func addSourceRef(page wiki.Page, sourceRef string) wiki.Page {
|
||||||
|
patch := wiki.Page{
|
||||||
|
Path: page.Path,
|
||||||
|
Content: "\n## Sources\n\n" + sourceRef + "\n",
|
||||||
|
}
|
||||||
|
return wiki.Merge(page, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWikilinks returns the set of slugs referenced as [[slug|...]] in content.
|
||||||
|
func extractWikilinks(content string) map[string]bool {
|
||||||
|
slugs := make(map[string]bool)
|
||||||
|
for _, m := range wikilinkRE.FindAllStringSubmatch(content, -1) {
|
||||||
|
slugs[m[1]] = true
|
||||||
|
}
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
|
||||||
|
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
|
||||||
|
for _, p := range pages {
|
||||||
|
if strings.HasPrefix(p.Path, "wiki/sources/") {
|
||||||
|
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
|
||||||
|
title = extractTitle(p.Content)
|
||||||
|
if title == "" {
|
||||||
|
title = slug
|
||||||
|
}
|
||||||
|
return slug, title, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findInInventory returns the PageType for a slug if it appears in the inventory.
|
||||||
|
func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wiki.PageType, bool) {
|
||||||
|
for pt, entries := range inventory {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Slug == slug {
|
||||||
|
return pt, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all pipeline tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -v
|
||||||
|
```
|
||||||
|
Expected: all existing tests PASS + 7 new refs tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs && git add ingestion/internal/pipeline/refs.go ingestion/internal/pipeline/refs_test.go && git commit -m "feat(pipeline): inject source back-references into concept and entity pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire injectSourceRefs into pipeline.Run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ingestion/internal/pipeline/pipeline.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Insert the call**
|
||||||
|
|
||||||
|
In `pipeline.go`, locate:
|
||||||
|
|
||||||
|
```go
|
||||||
|
resolved := Resolve(allPages, inventory)
|
||||||
|
merged := mergeAll(resolved)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
resolved := Resolve(allPages, inventory)
|
||||||
|
withRefs := injectSourceRefs(resolved, inventory, brainDir)
|
||||||
|
merged := mergeAll(withRefs)
|
||||||
|
```
|
||||||
|
|
||||||
|
No import changes needed — same package.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all pipeline tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -v
|
||||||
|
```
|
||||||
|
Expected: all tests PASS. The existing `TestRun_WritesPages` and `TestRun_DryRunDoesNotWrite` use LLM mocks that return source pages with no wikilinks to concepts — `injectSourceRefs` is a no-op for them.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full test suite + lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./... && golangci-lint run ./...
|
||||||
|
```
|
||||||
|
Expected: all packages PASS, 0 lint issues.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs && git add ingestion/internal/pipeline/pipeline.go && git commit -m "feat(pipeline): wire source back-reference injection into Run"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
|
||||||
|
| Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Concepts get `## Sources` back-link to ingested source | Task 1 |
|
||||||
|
| Entities get `## Sources` back-link | Task 1 (TestInjectSourceRefs_InjectsIntoEntity) |
|
||||||
|
| Existing pages on disk get updated with new source | Task 1 (TestInjectSourceRefs_LoadsConceptFromDisk) |
|
||||||
|
| Re-ingestion of same source does not duplicate the ref | Task 1 (TestInjectSourceRefs_DeduplicatesOnReingestion) |
|
||||||
|
| Source page does not reference itself | Task 1 (TestInjectSourceRefs_NoSelfReference) |
|
||||||
|
| No-op when batch has no source page | Task 1 (TestInjectSourceRefs_NoSourcePage) |
|
||||||
|
| Wired into Run between Resolve and mergeAll | Task 2 |
|
||||||
|
| Full test suite and lint pass | Task 2 Step 3 |
|
||||||
|
|
||||||
|
**Placeholder scan:** None.
|
||||||
|
|
||||||
|
**Type consistency:** `injectSourceRefs([]wiki.Page, map[wiki.PageType][]wiki.Entry, string) []wiki.Page` — used identically in refs.go (definition) and pipeline.go (call site).
|
||||||
1826
docs/superpowers/plans/2026-04-29-brain-mcp-migration.md
Normal file
1826
docs/superpowers/plans/2026-04-29-brain-mcp-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
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
218
docs/superpowers/specs/2026-04-20-cd-pipeline-design.md
Normal file
218
docs/superpowers/specs/2026-04-20-cd-pipeline-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# CD Pipeline Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
The supervisor (and future services on the koala k3s cluster) have no automated deployment path after CI passes. Images are not built, the cluster is updated manually, and there is no audit trail for what is running where.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
After a push to `main` passes CI, automatically build a container image, push it to the Gitea registry, and update the cluster via GitOps — with a design that scales to many repos and services without per-repo kubeconfig or secret sprawl.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- [ ] Successful `main` push triggers image build and push to `gitea.d-ma.be/<org>/<repo>:<git-sha>`
|
||||||
|
- [ ] Infra repo receives a commit updating the image tag for the deployed service
|
||||||
|
- [ ] Flux reconciles within 60s of the infra repo commit; pod runs the new image
|
||||||
|
- [ ] Rollback = one commit to infra repo reverting the tag
|
||||||
|
- [ ] Secrets (app secrets, registry pull) are SOPS-encrypted in infra repo; no manual `kubectl create secret`
|
||||||
|
- [ ] Adding a new service requires only: adding `apps/<service>/` to infra repo + `cd.yml` to the app repo
|
||||||
|
- [ ] Zero changes to the k3s cluster networking or runner configuration
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Gitea Actions self-hosted runner runs as a **systemd host process** on koala — not a k8s pod; cannot use cluster DNS
|
||||||
|
- k3s uses containerd; no Docker daemon, no nerdctl on koala
|
||||||
|
- Flux is already running (core controllers only); image-reflector/image-automation are NOT installed and will NOT be added
|
||||||
|
- SOPS + age is the secret management standard; no plaintext Secrets in git
|
||||||
|
- All org-level Gitea secrets are shared across repos — minimize the set
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Multi-cluster promotion (koala only for now; infra repo structure supports adding clusters later)
|
||||||
|
- Automated rollback on health check failure (manual rollback via infra repo commit)
|
||||||
|
- Build caching beyond BuildKit's local disk cache
|
||||||
|
- PR preview environments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App repo (supervisor, n8n, etc.)
|
||||||
|
↓ push to main
|
||||||
|
Gitea Actions — ci.yml (lint + test)
|
||||||
|
↓ passes
|
||||||
|
Gitea Actions — cd.yml
|
||||||
|
├─ 1. buildctl → BuildKit (unix socket on koala host)
|
||||||
|
│ → pushes gitea.d-ma.be/<org>/<repo>:<git-sha>
|
||||||
|
├─ 2. Clone infra repo (SSH deploy key)
|
||||||
|
│ → patch apps/<service>/deployment.yaml IMAGE_TAG → <git-sha>
|
||||||
|
│ → git commit + push
|
||||||
|
└─ done
|
||||||
|
|
||||||
|
gitea.d-ma.be/mathias/infra (Flux source)
|
||||||
|
↓ Flux source-controller detects new commit (30s interval)
|
||||||
|
kustomize-controller
|
||||||
|
└─ applies apps/<service>/kustomization.yaml → k3s namespace
|
||||||
|
↓
|
||||||
|
pod runs new image (pulls from gitea.d-ma.be with imagePullSecret)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. BuildKit — systemd service on koala
|
||||||
|
|
||||||
|
BuildKit runs as a rootless systemd service on the koala host, identical to the Gitea runner pattern already in use.
|
||||||
|
|
||||||
|
- Socket: `unix:///run/user/<uid>/buildkit/buildkitd.sock` (rootless) or `/run/buildkit/buildkitd.sock` (root)
|
||||||
|
- Cache: local disk at default BuildKit cache path — persists across builds
|
||||||
|
- Access: `buildctl --addr unix:///run/buildkit/buildkitd.sock` from the runner process (same host, same user)
|
||||||
|
- No k3s involvement for builds
|
||||||
|
|
||||||
|
### 2. Gitea Actions — `cd.yml`
|
||||||
|
|
||||||
|
Separate workflow file; triggers on `main` push after `ci.yml` succeeds.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: cd
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
needs: [ci] # or workflow_run trigger — see implementation plan
|
||||||
|
runs-on: [self-hosted, koala]
|
||||||
|
env:
|
||||||
|
IMAGE: gitea.d-ma.be/${{ github.repository }}:${{ github.sha }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
buildctl --addr unix:///run/buildkit/buildkitd.sock \
|
||||||
|
build \
|
||||||
|
--frontend dockerfile.v0 \
|
||||||
|
--local context=. \
|
||||||
|
--local dockerfile=. \
|
||||||
|
--output type=image,name=$IMAGE,push=true
|
||||||
|
env:
|
||||||
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
|
- name: Update infra repo
|
||||||
|
run: |
|
||||||
|
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra
|
||||||
|
cd /tmp/infra
|
||||||
|
sed -i "s|IMAGE_TAG|${{ github.sha }}|g" apps/${{ env.SERVICE_NAME }}/deployment.yaml
|
||||||
|
git config user.email "cd-bot@d-ma.be"
|
||||||
|
git config user.name "CD Bot"
|
||||||
|
git add apps/${{ env.SERVICE_NAME }}/deployment.yaml
|
||||||
|
git commit -m "chore(deploy): ${{ env.SERVICE_NAME }} → ${{ github.sha }}"
|
||||||
|
git push
|
||||||
|
env:
|
||||||
|
GIT_SSH_COMMAND: ssh -i /tmp/infra-deploy-key -o StrictHostKeyChecking=no
|
||||||
|
```
|
||||||
|
|
||||||
|
`SERVICE_NAME` is set per-repo (either hardcoded in `cd.yml` or derived from the repo name).
|
||||||
|
|
||||||
|
### 3. Org-level Gitea secrets
|
||||||
|
|
||||||
|
Three secrets, set once, inherited by all repos:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `BUILDKIT_REGISTRY_AUTH` | credentials for pushing to `gitea.d-ma.be` (buildctl `--opt` or `~/.docker/config.json`) |
|
||||||
|
| `INFRA_DEPLOY_KEY` | SSH private key with write access to `gitea.d-ma.be/mathias/infra` |
|
||||||
|
| `KUBECONFIG_KOALA` | (optional) kubeconfig for manual `kubectl` steps if ever needed; scoped ServiceAccount |
|
||||||
|
|
||||||
|
### 4. Infra repo structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea.d-ma.be/mathias/infra
|
||||||
|
├── clusters/
|
||||||
|
│ └── koala/
|
||||||
|
│ └── kustomization.yaml # points at ../../apps/*/
|
||||||
|
├── apps/
|
||||||
|
│ ├── supervisor/
|
||||||
|
│ │ ├── namespace.yaml
|
||||||
|
│ │ ├── deployment.yaml # image: gitea.d-ma.be/mathias/supervisor:IMAGE_TAG
|
||||||
|
│ │ ├── service.yaml
|
||||||
|
│ │ ├── secrets.enc.yaml # SOPS-encrypted app secrets (ANTHROPIC_API_KEY, etc.)
|
||||||
|
│ │ └── kustomization.yaml
|
||||||
|
│ ├── n8n/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── imagepullsecret/
|
||||||
|
│ └── secret.enc.yaml # SOPS-encrypted imagePullSecret for gitea.d-ma.be
|
||||||
|
└── flux-system/ # existing Flux bootstrap manifests
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a new service = add `apps/<service>/` directory. The `clusters/koala/kustomization.yaml` uses a glob or explicit list.
|
||||||
|
|
||||||
|
### 5. SOPS + age for Flux
|
||||||
|
|
||||||
|
Flux decrypts SOPS-encrypted files at apply time using an age key stored as a k8s Secret in the `flux-system` namespace. Setup:
|
||||||
|
|
||||||
|
1. Generate age keypair: `age-keygen`
|
||||||
|
2. Store private key: `kubectl create secret generic sops-age --from-file=age.agekey -n flux-system`
|
||||||
|
3. Configure Flux Kustomization with `decryption.provider: sops`
|
||||||
|
4. Encrypt secrets before committing: `sops --encrypt --age <pubkey> secret.yaml > secret.enc.yaml`
|
||||||
|
|
||||||
|
App secrets (e.g., `ANTHROPIC_API_KEY`) and the registry pull secret live as encrypted files in `apps/<service>/` and `apps/imagepullsecret/` respectively.
|
||||||
|
|
||||||
|
### 6. Image pull secret
|
||||||
|
|
||||||
|
Each app namespace needs a `kubernetes.io/dockerconfigjson` Secret to pull from `gitea.d-ma.be`. This Secret is SOPS-encrypted in `apps/imagepullsecret/` and applied to each app namespace via Kustomize `namespace` field or a shared Kustomize component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow: supervisor deploy
|
||||||
|
|
||||||
|
1. Push to `supervisor` main → CI passes (lint/test/vet)
|
||||||
|
2. CD job builds image: `gitea.d-ma.be/mathias/supervisor:abc1234`
|
||||||
|
3. CD job clones infra repo, patches `apps/supervisor/deployment.yaml`, commits
|
||||||
|
4. Flux source-controller detects infra commit within 30s
|
||||||
|
5. kustomize-controller applies `apps/supervisor/kustomization.yaml`
|
||||||
|
6. Flux decrypts `secrets.enc.yaml` → k8s Secret in `supervisor` namespace
|
||||||
|
7. k3s pulls `gitea.d-ma.be/mathias/supervisor:abc1234` using imagePullSecret
|
||||||
|
8. Pod starts with new image; previous pod terminates
|
||||||
|
|
||||||
|
Rollback: `git revert <tag-commit>` in infra repo → Flux reconciles → old image deployed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|----------|-----------|
|
||||||
|
| CI fails | `cd.yml` does not run (`needs: ci` gate) |
|
||||||
|
| BuildKit unreachable | `buildctl` exits non-zero → workflow fails; infra repo untouched |
|
||||||
|
| Image push fails | Workflow fails; infra repo untouched; cluster unchanged |
|
||||||
|
| Infra repo push conflict | Retry once with rebase; fail and alert if still conflicting |
|
||||||
|
| Flux reconcile error | Notification-controller fires alert; pods stay on previous image |
|
||||||
|
| Pod image pull fails | `ImagePullBackOff`; Flux reports degraded Kustomization |
|
||||||
|
| SOPS decrypt fails | Kustomization fails; Flux reports error; no partial apply |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing approach
|
||||||
|
|
||||||
|
1. **BuildKit smoke test** — `buildctl build` with a trivial one-line Dockerfile; verify image appears in Gitea registry
|
||||||
|
2. **cd.yml dry run** — trigger manually on a test branch; verify infra repo commit contains correct sha
|
||||||
|
3. **Flux reconcile test** — push infra commit; verify `flux get kustomizations` shows `Ready` and pod runs new image sha
|
||||||
|
4. **Pull secret test** — delete pod, verify it restarts and pulls from Gitea registry without `ImagePullBackOff`
|
||||||
|
5. **SOPS round-trip test** — encrypt a dummy secret, push to infra repo, verify Flux decrypts and `kubectl get secret` shows correct data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| BuildKit socket path varies by user/rootless mode | Confirm path during setup; hardcode in `cd.yml` |
|
||||||
|
| Infra repo concurrent pushes (multiple repos deploying simultaneously) | Git rebase retry handles this; unlikely at current scale |
|
||||||
|
| age private key lost | Back up to SOPS-accessible location; document recovery procedure |
|
||||||
|
| Registry storage fills up | Set Gitea registry tag retention policy (keep last 20 per repo) |
|
||||||
|
| Gitea deploy key compromised | Rotate via Gitea UI; single key for infra repo only |
|
||||||
322
docs/superpowers/specs/2026-04-20-model-orchestration-design.md
Normal file
322
docs/superpowers/specs/2026-04-20-model-orchestration-design.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Model Orchestration Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
The hyperguild supervisor currently spawns a `claude --print` subprocess for every skill call. The model routing config (`models.yaml`) exists but is dead weight — the model name is injected as text into the task prompt and ignored. Every skill call costs Claude tokens regardless of task complexity or data sensitivity.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Route skill work to the most appropriate model — weighing cost, latency, and quality — with Claude acting as the real supervisor: verifying outputs and deciding when to escalate. Local models on owned hardware handle the common case; Claude escalates through a chain to frontier models only when local quality is insufficient.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- [ ] Each skill dispatches generation to its configured local model via LiteLLM by default
|
||||||
|
- [ ] Claude verifies every local output and either accepts or escalates
|
||||||
|
- [ ] Escalation walks a per-skill chain (local small → local large → Sonnet → Opus) with one attempt per tier
|
||||||
|
- [ ] Every attempt (model, tier, duration, warm state, verdict) is logged in the session JSONL
|
||||||
|
- [ ] Cloud tiers (Sonnet/Opus) self-certify — no separate verifier call
|
||||||
|
- [ ] Zero changes to skill handlers — they call `ExecutorFn` exactly as today
|
||||||
|
- [ ] `LiteLTMBaseURL` already in config; no new env vars required beyond `LLAMA_SWAP_URL`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- One attempt per tier before escalating (no retry within a tier)
|
||||||
|
- Anthropic T&C: Claude is called normally via Anthropic API; local models are called directly via LiteLLM HTTP — no API redirection
|
||||||
|
- `models.yaml` remains the single routing config file
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Auto-rerouting based on real-time warm state (logged, not acted on — Phase 4)
|
||||||
|
- Multi-tenant / public service exposure
|
||||||
|
- RAG/CAG model boosting
|
||||||
|
- Managed Agent cloud delegation (chain stub only in Phase 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP tool call (Claude Code)
|
||||||
|
↓
|
||||||
|
Skill handler — calls ExecutorFn (unchanged)
|
||||||
|
↓
|
||||||
|
Orchestrator.Run (implements ExecutorFn)
|
||||||
|
├─ Resolve chain from models.yaml
|
||||||
|
├─ For each model in chain:
|
||||||
|
│ ├─ [ollama/*] → LiteLLM executor → generate
|
||||||
|
│ │ ↓
|
||||||
|
│ │ Claude verifier (task + output + discipline)
|
||||||
|
│ │ ├─ accept → return Result (log attempt)
|
||||||
|
│ │ └─ escalate → next tier (log attempt)
|
||||||
|
│ │
|
||||||
|
│ └─ [claude-*] → Claude executor (current) → generate + self-certify
|
||||||
|
│ └─ return Result (log attempt)
|
||||||
|
│
|
||||||
|
└─ All tiers exhausted → return best attempt with escalation note
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude is always the verifier for local tiers. At cloud tiers, Claude generates and self-certifies — the verifier call is skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. `internal/exec/litellm.go` — LiteLLM executor
|
||||||
|
|
||||||
|
Calls `POST /v1/chat/completions` on the configured LiteLLM server. Implements the same `ExecutorFn` signature as the existing claude executor.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type LiteLLMExecutor struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLiteLLM(baseURL, apiKey string, timeout time.Duration) *LiteLLMExecutor
|
||||||
|
|
||||||
|
func (e *LiteLLMExecutor) Run(ctx context.Context, req Request) (Result, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Request mapping:
|
||||||
|
- `req.SkillPrompt` → system message
|
||||||
|
- `req.TaskPrompt` → user message
|
||||||
|
- `req.Model` → `model` field in the chat completions request
|
||||||
|
|
||||||
|
Response handling: local models are prompted (via the discipline file output contract) to return a JSON object matching the `Result` schema. The executor attempts `json.Unmarshal` into `Result` directly — no envelope unwrapping needed (unlike the `--output-format json` claude envelope). If unmarshalling fails, the executor returns an error that the orchestrator treats as an automatic escalation trigger.
|
||||||
|
|
||||||
|
### 2. `internal/exec/verifier.go` — Claude verifier
|
||||||
|
|
||||||
|
A focused Claude call that judges local model output. Uses the existing `Executor` (claude subprocess) internally.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Verdict struct {
|
||||||
|
Accept bool `json:"accept"`
|
||||||
|
Feedback string `json:"feedback"` // reason if not accepting; empty if accept
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verifier struct {
|
||||||
|
executor *Executor // the existing claude executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerifier(executor *Executor) *Verifier
|
||||||
|
|
||||||
|
func (v *Verifier) Verify(ctx context.Context, skillPrompt, taskPrompt string, output Result) (Verdict, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier prompt gives Claude:
|
||||||
|
1. The skill discipline file (so it knows the iron laws and output contract)
|
||||||
|
2. The original task prompt (informed verification — Claude sees what was asked)
|
||||||
|
3. The generated output
|
||||||
|
4. A short instruction: "Does this output satisfy the discipline's iron laws and output contract? Reply with JSON: `{\"accept\": true|false, \"feedback\": \"...\"}`"
|
||||||
|
|
||||||
|
The verifier uses a lightweight JSON schema for its own output (a `Verdict` schema), keeping the call fast.
|
||||||
|
|
||||||
|
### 3. `internal/exec/orchestrator.go` — chain walker
|
||||||
|
|
||||||
|
Implements `ExecutorFn`. Walks the escalation chain, delegating generation and verification per tier.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Chain []ChainEntry
|
||||||
|
|
||||||
|
type ChainEntry struct {
|
||||||
|
Model string // e.g. "ollama/phi4", "claude-sonnet-4-5"
|
||||||
|
Tier string // "local" | "subagent" | "managed"
|
||||||
|
IsCloud bool // true for claude-* models; skips verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
type Orchestrator struct {
|
||||||
|
chain Chain
|
||||||
|
litellm *LiteLLMExecutor
|
||||||
|
claude *Executor
|
||||||
|
verifier *Verifier
|
||||||
|
llamaSwapURL string // for warm-state probe
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(chain Chain, litellm *LiteLLMExecutor, claude *Executor, verifier *Verifier, llamaSwapURL string) *Orchestrator
|
||||||
|
|
||||||
|
func (o *Orchestrator) Run(ctx context.Context, req Request) (Result, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
```
|
||||||
|
for each entry in chain:
|
||||||
|
warm = probe llama-swap (if local tier)
|
||||||
|
start = now()
|
||||||
|
if entry.IsCloud:
|
||||||
|
result, err = claude.Run(ctx, req with entry.Model)
|
||||||
|
log attempt(model, tier, duration, warm, verified=true)
|
||||||
|
if err == nil: return result
|
||||||
|
else:
|
||||||
|
result, err = litellm.Run(ctx, req with entry.Model)
|
||||||
|
duration = now() - start
|
||||||
|
if err != nil:
|
||||||
|
log attempt(model, tier, duration, warm, verified=false)
|
||||||
|
continue // automatic escalation on parse/network error
|
||||||
|
verdict = verifier.Verify(ctx, req.SkillPrompt, req.TaskPrompt, result)
|
||||||
|
log attempt(model, tier, duration, warm, verified=verdict.Accept)
|
||||||
|
if verdict.Accept: return result
|
||||||
|
// inject verifier feedback into next tier's task prompt
|
||||||
|
req.TaskPrompt = req.TaskPrompt + "\n\nPrior attempt feedback: " + verdict.Feedback
|
||||||
|
|
||||||
|
return error("all tiers exhausted")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `internal/config/models.go` — chain parser
|
||||||
|
|
||||||
|
Replaces the current single-model resolution with chain parsing.
|
||||||
|
|
||||||
|
Updated `models.yaml` format:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
verifier: claude-sonnet-4-6 # fixed verifier for all local tiers
|
||||||
|
|
||||||
|
llama_swap_url: http://koala:8080 # for warm-state probing
|
||||||
|
|
||||||
|
default_chain:
|
||||||
|
- ollama/qwen3-coder-30b-tuned
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
|
||||||
|
skills:
|
||||||
|
tdd:
|
||||||
|
chain:
|
||||||
|
- ollama/qwen3-coder-30b-tuned
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
review:
|
||||||
|
chain:
|
||||||
|
- ollama/devstral-tuned
|
||||||
|
- ollama/gemma4
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
debug:
|
||||||
|
chain:
|
||||||
|
- ollama/deepseek-r1-tuned
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
spec:
|
||||||
|
chain:
|
||||||
|
- ollama/phi4
|
||||||
|
- ollama/gemma4
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
- claude-opus-4-6
|
||||||
|
retrospective:
|
||||||
|
chain:
|
||||||
|
- ollama/qwen3-coder-30b-tuned
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
trainer:
|
||||||
|
chain:
|
||||||
|
- ollama/qwen3-coder-30b-tuned
|
||||||
|
- claude-sonnet-4-5
|
||||||
|
```
|
||||||
|
|
||||||
|
The parser exposes:
|
||||||
|
```go
|
||||||
|
func (m *Models) ChainFor(skill string) Chain
|
||||||
|
func (m *Models) Verifier() string
|
||||||
|
func (m *Models) LlamaSwapURL() string
|
||||||
|
```
|
||||||
|
|
||||||
|
Caller override (`model` param in MCP tool call) pins the chain to a single entry — one model, no escalation. This preserves the existing override behaviour for power users.
|
||||||
|
|
||||||
|
### 5. `internal/session/session.go` — updated `Attempt` struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Attempt struct {
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Tier string `json:"tier"` // local | subagent | managed
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
WarmStart bool `json:"warm_start"` // model was already loaded in llama-swap
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||||
|
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||||
|
OutputSummary string `json:"output_summary,omitempty"`
|
||||||
|
RunnerOutput string `json:"runner_output,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `cmd/supervisor/main.go` — one wiring change
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Before:
|
||||||
|
reg.Register(review.New(review.Config{ExecutorFn: executor.Run, ...}))
|
||||||
|
|
||||||
|
// After:
|
||||||
|
chain := models.ChainFor("review")
|
||||||
|
orch := exec.NewOrchestrator(chain, litellmExec, claudeExec, verifier, models.LlamaSwapURL())
|
||||||
|
reg.Register(review.New(review.Config{ExecutorFn: orch.Run, ...}))
|
||||||
|
```
|
||||||
|
|
||||||
|
One orchestrator per skill, sharing the same `litellmExec`, `claudeExec`, and `verifier` instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow example: `review` skill call
|
||||||
|
|
||||||
|
1. Claude Code calls `review` tool with `files: ["internal/foo.go"]`
|
||||||
|
2. Skill handler builds task prompt, calls `orch.Run`
|
||||||
|
3. Orchestrator resolves chain: `[devstral, gemma4, sonnet]`
|
||||||
|
4. Probes llama-swap: devstral is warm
|
||||||
|
5. LiteLLM calls devstral → returns JSON result
|
||||||
|
6. Verifier asks Claude: "does this review satisfy the iron laws?"
|
||||||
|
7. Claude: `{"accept": false, "feedback": "missing line references for all findings"}`
|
||||||
|
8. Orchestrator logs attempt #1 (devstral, local, 4200ms, warm, escalate)
|
||||||
|
9. Injects feedback into task prompt, calls gemma4
|
||||||
|
10. Verifier: `{"accept": true}`
|
||||||
|
11. Orchestrator logs attempt #2 (gemma4, local, 6100ms, cold, accept)
|
||||||
|
12. Returns result to skill handler → MCP response
|
||||||
|
|
||||||
|
Session JSONL records both attempts. You can see: devstral was warm but produced weak output; gemma4 was cold but passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Session JSONL is the primary store. Each `Entry.Attempts` slice records the full escalation trail. To analyse across sessions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Which models are escalating most?
|
||||||
|
jq -r '.attempts[] | select(.verdict == "escalate") | .model' brain/sessions/*.jsonl | sort | uniq -c
|
||||||
|
|
||||||
|
# Average latency per model
|
||||||
|
jq -r '.attempts[] | [.model, .duration_ms] | @tsv' brain/sessions/*.jsonl | awk '{sum[$1]+=$2; n[$1]++} END {for (m in sum) print m, sum[m]/n[m]}'
|
||||||
|
|
||||||
|
# Cold start frequency
|
||||||
|
jq -r '.attempts[] | select(.warm_start == false) | .model' brain/sessions/*.jsonl | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
No new metrics infrastructure needed for Phase 3. Phase 4 can build a dashboard on top of this data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|----------|-----------|
|
||||||
|
| LiteLLM unreachable | Log attempt as error, escalate immediately |
|
||||||
|
| Local model returns unparseable JSON | Log attempt as error, escalate |
|
||||||
|
| Verifier call fails | Log, treat as escalate (safe default) |
|
||||||
|
| All tiers exhausted | Return error to skill handler; skill returns MCP error to caller |
|
||||||
|
| Caller passes `model` override | Single-entry chain, no escalation, no verifier call |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing approach
|
||||||
|
|
||||||
|
- `TestLiteLLMExecutor`: mock HTTP server returning valid/invalid JSON; verify parse logic and error escalation
|
||||||
|
- `TestVerifier`: fake claude executor returning accept/escalate verdicts; verify prompt construction
|
||||||
|
- `TestOrchestrator`: table-driven — chains of 1/2/3 tiers, various accept/escalate/error combinations; verify attempt log contents and final result
|
||||||
|
- `TestModelsChainFor`: YAML parsing for all skill overrides and default_chain fallback
|
||||||
|
- Integration smoke test: start real LiteLLM (or mock), call `review` tool via MCP, verify attempt log written
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Local models ignore output contract → bad JSON | Discipline files already specify JSON output contract; parse failure auto-escalates |
|
||||||
|
| Verifier Claude call adds latency to every local attempt | Verifier prompt is small and fast; acceptable tradeoff for quality gate |
|
||||||
|
| llama-swap warm probe adds overhead | Probe is a single lightweight HTTP GET; timeout at 200ms, treat failure as `warm_start: false` |
|
||||||
|
| Chain exhaustion leaves caller with no result | Return structured error via MCP; caller can retry with explicit `model` override |
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Brain Ingestion Pipeline — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-22
|
||||||
|
**Status:** approved
|
||||||
|
**Author:** Mathias + Claude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a structured ingestion pipeline to the hyperguild brain. The pipeline accepts raw content (directly or from files) and uses an LLM to produce structured wiki pages in `brain/wiki/` — the declarative layer of the Two-Layer Brain. Three fixed knowledge classes: **concepts**, **entities**, **sources**.
|
||||||
|
|
||||||
|
This spec covers:
|
||||||
|
- Three new packages in the `ingestion` Go module (`llm`, `wiki`, `pipeline`, `watcher`)
|
||||||
|
- Two new HTTP endpoints on the ingestion server (`/ingest`, `/ingest-path`)
|
||||||
|
- A background file watcher for `brain/raw/`
|
||||||
|
- Config additions to both the ingestion server and the supervisor
|
||||||
|
|
||||||
|
It does **not** cover Layer 2 (training data, `brain/training-data/`) — that is the trainer worker's concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Information Model
|
||||||
|
|
||||||
|
Three fixed wiki page classes, matching the Two-Layer Brain design spec and the existing `ingestion-svc` model:
|
||||||
|
|
||||||
|
### `wiki/sources/<slug>.md`
|
||||||
|
One page per ingested source (project, book, article, note). Updated (not replaced) on re-ingestion.
|
||||||
|
|
||||||
|
Required frontmatter: `title`, `type` (article|pdf|book|video|note|project), `domain`, `source_url`, `date_ingested`, `last_updated`, `aliases`.
|
||||||
|
|
||||||
|
Body sections: Summary · Key Claims · Concepts Introduced or Reinforced · Entities Mentioned · Open Questions Raised. Books add: Chapters · Argument Arc · Updates (dated, append-only).
|
||||||
|
|
||||||
|
### `wiki/concepts/<slug>.md`
|
||||||
|
One page per idea, framework, methodology, or pattern (e.g. Domain Driven Design, TDD, event sourcing).
|
||||||
|
|
||||||
|
Required frontmatter: `title`, `domain`, `last_updated`, `aliases`.
|
||||||
|
|
||||||
|
Body sections: Definition · Why It Matters · Related Concepts · Related Entities · Sources · Evolving Notes.
|
||||||
|
|
||||||
|
### `wiki/entities/<slug>.md`
|
||||||
|
One page per person, tool, organisation, technology, or product.
|
||||||
|
|
||||||
|
Required frontmatter: `title`, `type` (person|company|tool|model|framework|technology), `domain`, `last_updated`, `aliases`.
|
||||||
|
|
||||||
|
Body sections: Description · Relevance · Key Positions/Products/Claims · Related Concepts · Related Entities · Sources.
|
||||||
|
|
||||||
|
### Wikilink format
|
||||||
|
All cross-references use `[[slug|Display Text]]`. Slug = lowercase title, spaces→hyphens, non-alphanumeric stripped. Slugs must resolve to an existing file in the wiki.
|
||||||
|
|
||||||
|
### Supporting files
|
||||||
|
- `brain/wiki/index.md` — auto-rebuilt on every ingest: one-sentence summary per page, grouped by type
|
||||||
|
- `brain/log.md` — append-only audit trail: date, source, pages written, warnings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New packages (`ingestion` module)
|
||||||
|
|
||||||
|
```
|
||||||
|
ingestion/internal/
|
||||||
|
llm/ — OpenAI-compatible HTTP client (chat completions, retry on 429,
|
||||||
|
configurable timeout and temperature)
|
||||||
|
wiki/ — Page types, slug utilities, merge logic, inventory loader,
|
||||||
|
index rebuilder, log appender
|
||||||
|
pipeline/ — Orchestrates one ingest run end-to-end (content or extracted file text)
|
||||||
|
watcher/ — Polls brain/raw/ and triggers pipeline on new files
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `api/` and `search/` packages are updated; no other existing packages change.
|
||||||
|
|
||||||
|
### Brain directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
brain/
|
||||||
|
wiki/
|
||||||
|
concepts/ ← LLM-structured concept pages
|
||||||
|
entities/ ← LLM-structured entity pages
|
||||||
|
sources/ ← LLM-structured source pages
|
||||||
|
index.md ← auto-rebuilt on each ingest
|
||||||
|
knowledge/ ← quick raw notes via brain_write (BM25-searchable, unchanged)
|
||||||
|
raw/ ← drop zone; watcher picks up files here
|
||||||
|
processed/ ← moved here on success (organised by date: processed/YYYY-MM-DD/)
|
||||||
|
failed/ ← moved here on failure
|
||||||
|
sessions/ ← session logs (retrospective/trainer concern, not touched here)
|
||||||
|
training-data/ ← Layer 2 (trainer worker concern, not touched here)
|
||||||
|
log.md ← append-only audit trail
|
||||||
|
CLAUDE.md ← schema document injected into every ingest prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
If `brain/CLAUDE.md` is absent, the pipeline falls back to an embedded default schema compiled into the binary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /ingest`
|
||||||
|
|
||||||
|
Ingest content provided directly by the caller.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "...",
|
||||||
|
"source": "shape-up-book",
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pages": ["wiki/sources/shape-up.md", "wiki/concepts/betting-table.md"],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`source` is the human-readable name used when writing/updating `wiki/sources/<slug>.md`. `dry_run: true` returns the page contents without writing.
|
||||||
|
|
||||||
|
### `POST /ingest-path`
|
||||||
|
|
||||||
|
Ingest a file or walk a directory recursively. Supports `.md`, `.txt`, `.pdf`.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/Users/mathias/brain/raw/shape-up.pdf",
|
||||||
|
"source": "shape-up-book",
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `path` is a directory, all supported files within it are ingested in sequence. `source` is optional for directory ingestion — if omitted, the LLM derives it from each file's name and content.
|
||||||
|
|
||||||
|
**Response:** same shape as `/ingest`, with pages and warnings aggregated across all files.
|
||||||
|
|
||||||
|
### Supervisor skill update
|
||||||
|
|
||||||
|
`brain_ingest` in `internal/skills/brain/handlers.go` gains an optional `path` field. If `path` is set, it calls `/ingest-path`; otherwise `/ingest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
`pipeline.Run(ctx, cfg, brainDir, content, source, dryRun)` — called by both HTTP handlers after any file reading is done.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. **Load inventory** — walk `brain/wiki/{concepts,entities,sources}/`, build slug index grouped by type. Injected into prompt so LLM knows what to update vs create.
|
||||||
|
2. **Load schema** — read `brain/CLAUDE.md`; fall back to embedded default if absent.
|
||||||
|
3. **Chunk** — split content at `INGEST_CHUNK_SIZE` chars (default 6000; split on paragraph boundary). If `INGEST_CHUNK_SIZE=0`, no chunking.
|
||||||
|
4. **LLM call per chunk** — returns JSON array of `{"path": "wiki/concepts/foo.md", "content": "..."}`. Prompt structure: system instruction → date → schema → inventory → non-negotiable slug/wikilink rules → source content.
|
||||||
|
5. **Parse + truncation recovery** — strip markdown fences if present. If JSON array is truncated mid-object (token limit), salvage all complete objects before the break and log a warning.
|
||||||
|
6. **Merge** — combine pages with the same path across chunks:
|
||||||
|
- Bullet sections (Related Concepts, Related Entities, Sources, Key Claims): union unique lines
|
||||||
|
- Append sections (Evolving Notes, Updates, Open Questions): append new content
|
||||||
|
- All other sections: keep first occurrence
|
||||||
|
- Frontmatter: keep first occurrence
|
||||||
|
7. **Write** — create subdirs as needed, write files atomically. In dry-run mode, return page map without writing.
|
||||||
|
8. **Rebuild `index.md`** — one-sentence summary per page (derived from first body paragraph), grouped by type, with page count header.
|
||||||
|
9. **Append to `log.md`** — date, source, list of pages written, warning count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Watcher
|
||||||
|
|
||||||
|
Background goroutine started at server startup (when `INGEST_WATCH_INTERVAL > 0`).
|
||||||
|
|
||||||
|
**Poll loop:**
|
||||||
|
1. Walk `brain/raw/` for files with supported extensions (`.md`, `.txt`, `.pdf`), excluding `processed/` and `failed/` subdirs.
|
||||||
|
2. For each file found: derive source from filename (strip extension, kebab-to-title), call `pipeline.Run` with the file content.
|
||||||
|
3. On success: move file to `brain/raw/processed/YYYY-MM-DD/<filename>`.
|
||||||
|
4. On failure: move file to `brain/raw/failed/<filename>`, append error to `brain/log.md`.
|
||||||
|
5. Sleep `INGEST_WATCH_INTERVAL` seconds, repeat.
|
||||||
|
|
||||||
|
Files are processed one at a time (no concurrency within the watcher) to avoid LLM rate-limit collisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM Prompt
|
||||||
|
|
||||||
|
**System:**
|
||||||
|
> You are a wiki agent. Read the source material and produce structured wiki pages following the schema provided. Output ONLY a valid JSON array — no markdown fences, no other text. Each element must have: `"path"` (relative path within wiki, e.g. `"wiki/sources/foo.md"`) and `"content"` (full markdown including YAML frontmatter). Follow the schema strictly: correct frontmatter fields, wikilinks as `[[slug|Display Text]]`, dates in YYYY-MM-DD format, paraphrase rather than quoting verbatim.
|
||||||
|
|
||||||
|
**User (built dynamically):**
|
||||||
|
1. Today's date
|
||||||
|
2. Full schema (`brain/CLAUDE.md` content)
|
||||||
|
3. Existing wiki inventory grouped by type (for update-vs-create decisions)
|
||||||
|
4. Non-negotiable rules: slug format, wikilink format, one-source-per-book, section type enforcement
|
||||||
|
5. Source content (the chunk)
|
||||||
|
|
||||||
|
Temperature: 0.2 for reproducibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Ingestion server (new env vars)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `INGEST_LLM_URL` | `http://iguana:4000/v1` | OpenAI-compatible endpoint |
|
||||||
|
| `INGEST_LLM_KEY` | (empty) | API key |
|
||||||
|
| `INGEST_LLM_MODEL` | `koala/qwen35-9b-fast` | Model name |
|
||||||
|
| `INGEST_LLM_TIMEOUT` | `15` | LLM call timeout (minutes) |
|
||||||
|
| `INGEST_CHUNK_SIZE` | `6000` | Max chars per LLM call (0 = no chunking) |
|
||||||
|
| `INGEST_WATCH_INTERVAL` | `30` | Watcher poll interval in seconds (0 = disabled) |
|
||||||
|
|
||||||
|
### Supervisor (new env vars + wiring)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `INGEST_SVC_URL` | (empty) | URL of ingestion server for `brain_ingest` |
|
||||||
|
| `KB_RETRIEVAL_URL` | (empty) | URL of KB retrieval server for `brain_search` |
|
||||||
|
|
||||||
|
`config.go` gets two new fields. `main.go` passes them to `brain.New()`. Both tools are only registered as MCP tools when the respective URL is configured (already implemented in `skill.go`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| Package | What is tested |
|
||||||
|
|---|---|
|
||||||
|
| `wiki/` | Slug generation (edge cases: apostrophes, colons, version strings), merge logic (bullets union, append, keep-first), inventory loading from temp dir, truncation recovery (valid partial JSON), index rebuild output |
|
||||||
|
| `pipeline/` | Integration test: temp brain dir + mock LLM HTTP server returning fixture JSON; verify files written to correct paths, index rebuilt, log appended |
|
||||||
|
| `api/` | Handler tests for `/ingest` and `/ingest-path` using mock pipeline; 400 on missing fields, 200 with expected response shape |
|
||||||
|
| `watcher/` | File placed in `brain/raw/` is moved to `processed/` on mock-pipeline success; moved to `failed/` on error |
|
||||||
|
|
||||||
|
All tests are table-driven. No real LLM calls in tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Python validation/correction loop (can be added later; the LLM prompt enforces schema rules as non-negotiable instructions)
|
||||||
|
- `brain/training-data/` — trainer worker concern
|
||||||
|
- `brain/sessions/` — retrospective/sessionlog concern
|
||||||
|
- Upload endpoint (multipart HTTP) — `scp`/rsync to `brain/raw/` + watcher covers this
|
||||||
|
- Qdrant vector indexing — `brain_search` calls a separate KB retrieval service; ingestion does not write to Qdrant
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Level 3: Strip Slug Authority from LLM — Design Spec
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The ingestion pipeline currently asks the LLM to produce full wiki pages including the file path (e.g. `wiki/sources/finbert-huggingface.md`). This causes two classes of bug:
|
||||||
|
|
||||||
|
1. **Slug proliferation** — the LLM invents different slugs for the same concept across chunks or runs, producing duplicate pages that diverge in content.
|
||||||
|
2. **Unstable paths** — the LLM may shorten, expand, or vary titles, making deduplication via `Resolve` unreliable because the slug mismatch is upstream of the normalizer.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Strip slug authority from the LLM entirely. The LLM returns a minimal structured object. The pipeline computes all slugs deterministically from titles using `wiki.Slug(title)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM JSON Contract
|
||||||
|
|
||||||
|
### Output format (per page)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "FinBERT",
|
||||||
|
"type": "concept",
|
||||||
|
"subtype": "framework",
|
||||||
|
"domain": "ai-llm",
|
||||||
|
"content": "## Definition\n\nA BERT-based model fine-tuned for financial sentiment...\n\n## Related\n\n- [[Sentiment Analysis]]\n- [[Hugging Face]]\n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Required | Values |
|
||||||
|
|-------|----------|--------|
|
||||||
|
| `title` | yes | Human-readable title, e.g. "FinBERT" |
|
||||||
|
| `type` | yes | `"source"` \| `"concept"` \| `"entity"` |
|
||||||
|
| `subtype` | for entity/source | entity: `person\|company\|tool\|model\|framework\|technology`; source: `article\|pdf\|book\|video\|note\|project` |
|
||||||
|
| `domain` | no | tag string, e.g. `ai-llm`, `finance` |
|
||||||
|
| `content` | yes | Markdown body sections only — no frontmatter, no path |
|
||||||
|
|
||||||
|
**Wikilinks in content:** `[[Display Name]]` only. No slug. The pipeline canonicalizes to `[[slug|Display Name]]` in a post-processing step.
|
||||||
|
|
||||||
|
**The LLM never writes slugs, paths, or frontmatter.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Changes
|
||||||
|
|
||||||
|
### New type: `RawPage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RawPage struct {
|
||||||
|
Title string
|
||||||
|
Type string // "source" | "concept" | "entity"
|
||||||
|
Subtype string
|
||||||
|
Domain string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New step order
|
||||||
|
|
||||||
|
```
|
||||||
|
ParseRawPages → BuildPages → Resolve → CanonicalizeLinks → injectSourceRefs → mergeAll → write
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step descriptions
|
||||||
|
|
||||||
|
**`ParseRawPages(output string) ([]RawPage, []string)`**
|
||||||
|
Replaces `ParsePages`. Deserializes JSON objects with the new schema. Same truncation-recovery logic as today. Returns `(pages, warnings)`.
|
||||||
|
|
||||||
|
**`BuildPages(rawPages []RawPage, sourceSlug, date string) []wiki.Page`**
|
||||||
|
Converts `RawPage → wiki.Page`:
|
||||||
|
- Computes slug: `wiki.Slug(page.Title)`
|
||||||
|
- Computes path: `wiki/<type>/<slug>.md`
|
||||||
|
- Assembles frontmatter:
|
||||||
|
```
|
||||||
|
---
|
||||||
|
title: <Title>
|
||||||
|
type: <type>
|
||||||
|
subtype: <subtype> # omitted if empty
|
||||||
|
domain: <domain> # omitted if empty
|
||||||
|
created: <date>
|
||||||
|
source: <sourceSlug> # omitted for the source page itself
|
||||||
|
---
|
||||||
|
```
|
||||||
|
- Concatenates frontmatter + content
|
||||||
|
|
||||||
|
**`Resolve(pages []wiki.Page, inventory) []wiki.Page`**
|
||||||
|
Unchanged. Normalizes near-duplicate titles to existing inventory slugs.
|
||||||
|
|
||||||
|
**`CanonicalizeLinks(pages []wiki.Page, inventory) ([]wiki.Page, []string)`**
|
||||||
|
New. Builds a title→slug map from inventory + current batch. Replaces `[[Display Name]]` with `[[slug|Display Name]]` in each page's content. Titles with no known slug are left as-is and returned as warnings.
|
||||||
|
|
||||||
|
**`injectSourceRefs`**
|
||||||
|
Unchanged. Reads `[[slug|...]]` links (post-canonicalization) to inject back-references.
|
||||||
|
|
||||||
|
**`mergeAll → write`**
|
||||||
|
Unchanged.
|
||||||
|
|
||||||
|
### `pipeline.Run` signature change
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
`source` is already passed (it's the display name / filename). A new internal `sourceSlug` is derived from it via `wiki.Slug(source)` before calling `BuildPages`. No API change needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `ingestion/internal/pipeline/parse.go` | Replace `ParsePages` with `ParseRawPages` + `RawPage` type |
|
||||||
|
| `ingestion/internal/pipeline/build.go` | New file: `BuildPages` |
|
||||||
|
| `ingestion/internal/pipeline/links.go` | New file: `CanonicalizeLinks` |
|
||||||
|
| `ingestion/internal/pipeline/pipeline.go` | Wire new steps; derive `sourceSlug` from `source` |
|
||||||
|
| `ingestion/internal/pipeline/prompt.go` | New system prompt + `BuildPrompt` for new JSON format |
|
||||||
|
| `brain/schema.md` | Update wikilink format and JSON schema docs |
|
||||||
|
|
||||||
|
`resolve.go`, `refs.go`, `backfill.go`, `merge.go` — no changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wikilink Format
|
||||||
|
|
||||||
|
- **LLM output**: `[[Display Name]]`
|
||||||
|
- **Stored on disk**: `[[slug|Display Name]]`
|
||||||
|
- **`CanonicalizeLinks`** converts between the two using the inventory
|
||||||
|
|
||||||
|
This matches Obsidian's display-alias syntax that the existing codebase already uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- `ParseRawPages`: table-driven, cover valid JSON, truncated output, unknown type, missing title
|
||||||
|
- `BuildPages`: table-driven, cover slug computation, frontmatter assembly, source page (no `source:` field), entity with subtype
|
||||||
|
- `CanonicalizeLinks`: cover known title → replaced, unknown title → left as-is + warning, multiple links in one page
|
||||||
|
- Integration test: full `Run` call with mock LLM returning new JSON format, assert no slug duplication across two chunks of the same source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Re-ingesting existing pages (user will trigger manually after deploy)
|
||||||
|
- Changing the `BackfillRefs` endpoint (already correct, slug-based)
|
||||||
|
- Changing the `Resolve` fuzzy-match algorithm
|
||||||
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=
|
||||||
|
|||||||
36
ingestion/Dockerfile
Normal file
36
ingestion/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
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" \
|
||||||
|
-o /out/ingestion ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache poppler-utils
|
||||||
|
|
||||||
|
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
|
||||||
|
|
||||||
|
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# brain/ is writable state — mount a PersistentVolume here
|
||||||
|
VOLUME /app/brain
|
||||||
|
|
||||||
|
ENV INGEST_BRAIN_DIR=/app/brain
|
||||||
|
ENV INGEST_PORT=3300
|
||||||
|
|
||||||
|
USER ingestion
|
||||||
|
|
||||||
|
EXPOSE 3300
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/ingestion"]
|
||||||
@@ -2,34 +2,253 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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/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/reranker"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
|
"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 {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
brainDir := os.Getenv("INGEST_BRAIN_DIR")
|
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
||||||
if brainDir == "" {
|
port := envOr("INGEST_PORT", "3300")
|
||||||
brainDir = "../brain"
|
|
||||||
|
llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1")
|
||||||
|
llmKey := os.Getenv("INGEST_LLM_KEY")
|
||||||
|
llmModel := envOr("INGEST_LLM_MODEL", "koala/qwen35-9b-fast")
|
||||||
|
llmTimeoutMins := envInt("INGEST_LLM_TIMEOUT", 15)
|
||||||
|
chunkSize := envInt("INGEST_CHUNK_SIZE", 6000)
|
||||||
|
watchInterval := envInt("INGEST_WATCH_INTERVAL", 30)
|
||||||
|
|
||||||
|
llmClient := llm.New(llmURL, llmKey, llmModel, time.Duration(llmTimeoutMins)*time.Minute)
|
||||||
|
|
||||||
|
pipelineCfg := pipeline.Config{
|
||||||
|
Complete: llmClient.Complete,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
port := os.Getenv("INGEST_PORT")
|
h := api.NewHandler(brainDir, logger, pipelineCfg)
|
||||||
if port == "" {
|
|
||||||
port = "3300"
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := api.NewHandler(brainDir, logger)
|
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()
|
||||||
|
if watchInterval > 0 {
|
||||||
|
watcher.Start(ctx, watcher.Config{
|
||||||
|
BrainDir: brainDir,
|
||||||
|
Interval: time.Duration(watchInterval) * time.Second,
|
||||||
|
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("/query", h.Query)
|
mux.HandleFunc("POST /query", h.Query)
|
||||||
mux.HandleFunc("/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-path", h.IngestPath)
|
||||||
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
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
|
||||||
logger.Info("ingestion server starting", "addr", addr, "brain_dir", brainDir)
|
watchIntervalLog := "disabled"
|
||||||
|
if watchInterval > 0 {
|
||||||
|
watchIntervalLog = fmt.Sprintf("%ds", watchInterval)
|
||||||
|
}
|
||||||
|
logger.Info("ingestion server starting",
|
||||||
|
"addr", addr,
|
||||||
|
"brain_dir", brainDir,
|
||||||
|
"llm_url", llmURL,
|
||||||
|
"llm_model", llmModel,
|
||||||
|
"chunk_size", chunkSize,
|
||||||
|
"watch_interval", watchIntervalLog,
|
||||||
|
"mcp_enabled", true,
|
||||||
|
)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
logger.Error("server stopped", "err", err)
|
logger.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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,23 +11,43 @@ 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/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.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
brainDir string
|
brainDir string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
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/.
|
||||||
func NewHandler(brainDir string, logger *slog.Logger) *Handler {
|
func NewHandler(brainDir string, logger *slog.Logger, pipelineCfg pipeline.Config) *Handler {
|
||||||
return &Handler{brainDir: brainDir, logger: logger}
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
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 {
|
||||||
@@ -35,82 +55,441 @@ 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 {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ingestPathRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ingestResponse struct {
|
||||||
|
Pages []string `json:"pages"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query handles POST /query — full-text search across the brain wiki.
|
// Query handles POST /query — full-text search across the brain wiki.
|
||||||
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||||
var req queryRequest
|
var req queryRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Query) == "" {
|
if strings.TrimSpace(req.Query) == "" {
|
||||||
http.Error(w, "query is required", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "query is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Limit == 0 {
|
if req.Limit == 0 {
|
||||||
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)
|
||||||
http.Error(w, "search error", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "search error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, map[string]any{"results": results})
|
writeJSON(w, map[string]any{"results": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write handles POST /write — write raw content to brain/raw/.
|
// WriteNoteOptions configures how a brain note is written.
|
||||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
//
|
||||||
var req writeRequest
|
// When both Wing and Hall are non-empty, the note routes into the
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
// wing/hall/created_at injected into its YAML frontmatter.
|
||||||
return
|
//
|
||||||
}
|
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||||
if req.Content == "" {
|
// with optional type/domain frontmatter (legacy behaviour).
|
||||||
http.Error(w, "content is required", http.StatusBadRequest)
|
type WriteNoteOptions struct {
|
||||||
return
|
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.
|
||||||
|
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
|
if opts.Content == "" {
|
||||||
|
return "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := req.Filename
|
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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
rawDir := filepath.Join(h.brainDir, "raw")
|
rawDir := filepath.Join(brainDir, "knowledge")
|
||||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||||
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
|
return "", fmt.Errorf("create raw dir: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finalContent := req.Content
|
finalContent := opts.Content
|
||||||
if req.Type != "" || req.Domain != "" {
|
if opts.Type != "" || opts.Domain != "" {
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
if req.Type != "" {
|
if opts.Type != "" {
|
||||||
fmt.Fprintf(&fm, "type: %s\n", req.Type)
|
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||||
}
|
}
|
||||||
if req.Domain != "" {
|
if opts.Domain != "" {
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", req.Domain)
|
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
finalContent = fm.String() + req.Content
|
finalContent = fm.String() + opts.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
dest := filepath.Join(rawDir, filepath.Base(filename))
|
if strings.ContainsAny(filename, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid filename")
|
||||||
|
}
|
||||||
|
base := filepath.Base(filename)
|
||||||
|
if base == "." || base == ".." || base == "" {
|
||||||
|
return "", fmt.Errorf("invalid filename")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(base, ".md") {
|
||||||
|
base += ".md"
|
||||||
|
}
|
||||||
|
dest := filepath.Join(rawDir, base)
|
||||||
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return filepath.ToSlash(rel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write handles POST /write — write raw content to brain/knowledge/.
|
||||||
|
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req writeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||||
|
if err != nil {
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
http.Error(w, "write error", http.StatusInternalServerError)
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ingestRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Content) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Source) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "source is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rel, _ := filepath.Rel(h.brainDir, dest)
|
result, err := pipeline.Run(r.Context(), h.pipeline, h.brainDir, req.Content, req.Source, req.DryRun)
|
||||||
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
|
if err != nil {
|
||||||
|
h.logger.Error("ingest failed", "source", req.Source, "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := result.Pages
|
||||||
|
if pages == nil {
|
||||||
|
pages = []string{}
|
||||||
|
}
|
||||||
|
warnings := result.Warnings
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedExtensions lists file extensions that IngestPath will process.
|
||||||
|
var supportedExtensions = map[string]bool{
|
||||||
|
".md": true,
|
||||||
|
".txt": true,
|
||||||
|
".pdf": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||||
|
func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ingestPathRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Path) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "path is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("path not accessible: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var allPages []string
|
||||||
|
var allWarnings []string
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
err = filepath.WalkDir(req.Path, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if !supportedExtensions[ext] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, readErr := extract.Text(path)
|
||||||
|
if readErr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = filepath.Base(path)
|
||||||
|
}
|
||||||
|
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
|
||||||
|
if runErr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("ingest %s: %v", path, runErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allPages = append(allPages, result.Pages...)
|
||||||
|
allWarnings = append(allWarnings, result.Warnings...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("walk dir failed", "path", req.Path, "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("walk error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||||
|
if !supportedExtensions[ext] {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, readErr := extract.Text(req.Path)
|
||||||
|
if readErr != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = filepath.Base(req.Path)
|
||||||
|
}
|
||||||
|
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
|
||||||
|
if runErr != nil {
|
||||||
|
h.logger.Error("ingest-path failed", "path", req.Path, "err", runErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allPages = result.Pages
|
||||||
|
allWarnings = result.Warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
if allPages == nil {
|
||||||
|
allPages = []string{}
|
||||||
|
}
|
||||||
|
if allWarnings == nil {
|
||||||
|
allWarnings = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, ingestResponse{Pages: allPages, Warnings: allWarnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ingestRawRequest struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Pages []pipeline.RawPage `json:"pages"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestRaw handles POST /ingest-raw — run the pipeline on pre-parsed RawPages,
|
||||||
|
// skipping the LLM extraction step. Use when the caller has already produced
|
||||||
|
// structured page data (e.g. from a more capable model or manual curation).
|
||||||
|
func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ingestRawRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Source) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "source is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Pages) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "pages is required and must be non-empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := result.Pages
|
||||||
|
if pages == nil {
|
||||||
|
pages = []string{}
|
||||||
|
}
|
||||||
|
warnings := result.Warnings
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackfillRefs handles POST /backfill-refs — injects source back-references
|
||||||
|
// into all concept and entity pages based on existing wiki/sources/ pages.
|
||||||
|
func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := pipeline.BackfillRefs(r.Context(), h.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("backfill-refs failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "backfill error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]int{"updated": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg}) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,25 +13,43 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// stubComplete returns a fixed JSON RawPage so tests never call a real LLM.
|
||||||
|
func stubComplete(_ context.Context, _, _ string) (string, error) {
|
||||||
|
return `[{"title":"Test Source","type":"source","subtype":"article","content":"## Summary\n\nSome content here.\n"}]`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubPipelineCfg() pipeline.Config {
|
||||||
|
return pipeline.Config{
|
||||||
|
Complete: stubComplete,
|
||||||
|
ChunkSize: 0,
|
||||||
|
Schema: "# Test Schema\nwiki/sources/, wiki/concepts/, wiki/entities/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setup(t *testing.T) (string, *api.Handler) {
|
func setup(t *testing.T) (string, *api.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
filepath.Join(dir, "wiki", "concepts", "tdd.md"),
|
filepath.Join(dir, "knowledge", "tdd.md"),
|
||||||
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
|
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
return dir, api.NewHandler(dir, logger)
|
return dir, api.NewHandler(dir, logger, stubPipelineCfg())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Existing tests (Write / Query)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestQuery_ReturnsResults(t *testing.T) {
|
func TestQuery_ReturnsResults(t *testing.T) {
|
||||||
_, h := setup(t)
|
_, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5})
|
body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5})
|
||||||
@@ -46,7 +65,7 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
|||||||
assert.NotEmpty(t, results)
|
assert.NotEmpty(t, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_CreatesRawFile(t *testing.T) {
|
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"content": "# Test note\n\nSome content.",
|
"content": "# Test note\n\nSome content.",
|
||||||
@@ -62,8 +81,7 @@ func TestWrite_CreatesRawFile(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
assert.NotEmpty(t, resp["path"])
|
assert.NotEmpty(t, resp["path"])
|
||||||
|
|
||||||
written := filepath.Join(dir, "raw", "test-note.md")
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
|
||||||
content, err := os.ReadFile(written)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "Some content.")
|
assert.Contains(t, string(content), "Some content.")
|
||||||
}
|
}
|
||||||
@@ -93,7 +111,7 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
|||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md"))
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "type: concept")
|
assert.Contains(t, string(content), "type: concept")
|
||||||
assert.Contains(t, string(content), "domain: software")
|
assert.Contains(t, string(content), "domain: software")
|
||||||
@@ -109,7 +127,206 @@ func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
|||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
entries, _ := os.ReadDir(filepath.Join(dir, "raw"))
|
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||||
assert.Len(t, entries, 1)
|
// +1 because setup already wrote tdd.md
|
||||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
assert.Len(t, entries, 2)
|
||||||
|
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /ingest
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIngest_Validation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"missing content", map[string]any{"source": "test-source"}},
|
||||||
|
{"missing source", map[string]any{"content": "some content"}},
|
||||||
|
{"whitespace content", map[string]any{"content": " ", "source": "test-source"}},
|
||||||
|
{"whitespace source", map[string]any{"content": "some content", "source": " "}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(tc.body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Ingest(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngest_Success(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "some content about shape-up methodology",
|
||||||
|
"source": "shape-up-book",
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Ingest(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"]
|
||||||
|
require.True(t, ok, "response must have pages field")
|
||||||
|
pagesSlice, ok := pages.([]any)
|
||||||
|
require.True(t, ok, "pages must be an array")
|
||||||
|
assert.NotEmpty(t, pagesSlice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /ingest-path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIngestPath_MissingPath(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"source": "test-source"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPath_File(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
// Create a temp file with content
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.md")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"path": f,
|
||||||
|
"source": "test-doc",
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"]
|
||||||
|
require.True(t, ok, "response must have pages field")
|
||||||
|
pagesSlice, ok := pages.([]any)
|
||||||
|
require.True(t, ok, "pages must be an array")
|
||||||
|
assert.NotEmpty(t, pagesSlice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /ingest-raw
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIngestRaw_Validation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"missing source", map[string]any{"pages": []any{map[string]any{"title": "X", "type": "concept", "content": "x"}}}},
|
||||||
|
{"missing pages", map[string]any{"source": "test-source"}},
|
||||||
|
{"empty pages", map[string]any{"source": "test-source", "pages": []any{}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(tc.body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestRaw(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestRaw_Success(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "test-article",
|
||||||
|
"pages": []any{
|
||||||
|
map[string]any{"title": "Test Article", "type": "source", "subtype": "article", "domain": "Testing", "content": "## Summary\n\nThis is a test article about [[Test Concept]].\n"},
|
||||||
|
map[string]any{"title": "Test Concept", "type": "concept", "domain": "Testing", "content": "A concept for testing.\n"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestRaw(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages := resp["pages"].([]any)
|
||||||
|
assert.Len(t, pages, 2)
|
||||||
|
|
||||||
|
// Verify files were written
|
||||||
|
sourcePath := filepath.Join(dir, "wiki", "sources", "test-article.md")
|
||||||
|
assert.FileExists(t, sourcePath)
|
||||||
|
conceptPath := filepath.Join(dir, "wiki", "concepts", "test-concept.md")
|
||||||
|
assert.FileExists(t, conceptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestRaw_DryRun(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "dry-run-test",
|
||||||
|
"pages": []any{
|
||||||
|
map[string]any{"title": "Dry Run Source", "type": "source", "subtype": "article", "content": "Content."},
|
||||||
|
},
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestRaw(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages := resp["pages"].([]any)
|
||||||
|
assert.NotEmpty(t, pages)
|
||||||
|
|
||||||
|
// Verify no files were written
|
||||||
|
sourcePath := filepath.Join(dir, "wiki", "sources", "dry-run-test.md")
|
||||||
|
assert.NoFileExists(t, sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPath_Directory(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
// Create a temp dir with one .md file
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"path": dir,
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"]
|
||||||
|
require.True(t, ok, "response must have pages field")
|
||||||
|
pagesSlice, ok := pages.([]any)
|
||||||
|
require.True(t, ok, "pages must be an array")
|
||||||
|
assert.NotEmpty(t, pagesSlice)
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
39
ingestion/internal/extract/extract.go
Normal file
39
ingestion/internal/extract/extract.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// ingestion/internal/extract/extract.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text reads the file at path and returns its plain-text content.
|
||||||
|
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
||||||
|
func Text(path string) (string, error) {
|
||||||
|
ext := strings.ToLower(fileExt(path))
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt":
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
case ".pdf":
|
||||||
|
return extractPDF(path)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExt returns the file extension including the dot, lowercased.
|
||||||
|
func fileExt(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '.' {
|
||||||
|
return path[i:]
|
||||||
|
}
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
62
ingestion/internal/extract/extract_test.go
Normal file
62
ingestion/internal/extract/extract_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// ingestion/internal/extract/extract_test.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestText_Markdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.md")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("# Hello\n\nWorld."), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# Hello\n\nWorld.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_Txt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.txt")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("plain text"), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "plain text", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_UnsupportedExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "data.csv")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("a,b,c"), 0o644))
|
||||||
|
|
||||||
|
_, err := Text(path)
|
||||||
|
assert.ErrorContains(t, err, "unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_PDF(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("pdftotext"); err != nil {
|
||||||
|
t.Skip("pdftotext not available")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
pdfPath := filepath.Join(dir, "test.pdf")
|
||||||
|
|
||||||
|
// Minimal valid PDF containing the text "Hello PDF".
|
||||||
|
minimalPDF := "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||||
|
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||||
|
"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n" +
|
||||||
|
"4 0 obj<</Length 44>>\nstream\nBT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\nendstream\nendobj\n" +
|
||||||
|
"xref\n0 5\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\n0000000310 00000 n\n" +
|
||||||
|
"trailer<</Size 5/Root 1 0 R>>\nstartxref\n406\n%%EOF\n"
|
||||||
|
require.NoError(t, os.WriteFile(pdfPath, []byte(minimalPDF), 0o644))
|
||||||
|
|
||||||
|
got, err := Text(pdfPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, got, "Hello PDF")
|
||||||
|
}
|
||||||
28
ingestion/internal/extract/pdf.go
Normal file
28
ingestion/internal/extract/pdf.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// ingestion/internal/extract/pdf.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractPDF runs pdftotext on path and returns the extracted text.
|
||||||
|
// pdftotext must be installed (package: poppler-utils on Alpine/Debian, poppler on Homebrew).
|
||||||
|
func extractPDF(path string) (string, error) {
|
||||||
|
cmd := exec.Command("pdftotext", "-q", path, "-")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("pdftotext: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(stdout.String()), nil
|
||||||
|
}
|
||||||
119
ingestion/internal/llm/client.go
Normal file
119
ingestion/internal/llm/client.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client calls an OpenAI-compatible chat completions endpoint.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client.
|
||||||
|
func New(baseURL, apiKey, model string, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []message `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message message `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a system + user message and returns the assistant's reply.
|
||||||
|
// Retries once on HTTP 429 using Retry-After header or 5s backoff.
|
||||||
|
func (c *Client) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
|
body := chatRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: []message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
Temperature: 0.2,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
do := func() (*http.Response, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
return c.httpClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := do()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call LLM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
wait := 5 * time.Second
|
||||||
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
|
if secs, err := strconv.Atoi(ra); err == nil {
|
||||||
|
wait = time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
resp, err = do()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("retry LLM call: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
out, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cr chatResponse
|
||||||
|
if err := json.Unmarshal(out, &cr); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(cr.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("LLM returned no choices")
|
||||||
|
}
|
||||||
|
return cr.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
86
ingestion/internal/llm/client_test.go
Normal file
86
ingestion/internal/llm/client_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mockServer(t *testing.T, response string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/chat/completions", r.URL.Path)
|
||||||
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{"message": map[string]any{"role": "assistant", "content": response}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Complete(t *testing.T) {
|
||||||
|
srv := mockServer(t, "hello world")
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "", "test-model", 10*time.Second)
|
||||||
|
got, err := c.Complete(context.Background(), "you are helpful", "say hello")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ReturnsErrorOnNon200(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "overloaded", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "", "test-model", 10*time.Second)
|
||||||
|
_, err := c.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_SendsAuthHeader(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{"message": map[string]any{"content": "ok"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "my-key", "test-model", 10*time.Second)
|
||||||
|
_, err := c.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Bearer my-key", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Retries429(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{"message": map[string]any{"content": "retried"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "", "test-model", 10*time.Second)
|
||||||
|
got, err := c.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "retried", got)
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
366
ingestion/internal/mcp/handlers.go
Normal file
366
ingestion/internal/mcp/handlers.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tools returns the tool descriptors. Handler bodies for each tool are filled
|
||||||
|
// in subsequent tasks; this file currently only provides the descriptors.
|
||||||
|
func (s *Server) tools() []map[string]any {
|
||||||
|
str := func(desc string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": desc}
|
||||||
|
}
|
||||||
|
int_ := func(desc string) map[string]any {
|
||||||
|
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 {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "object", "required": required, "properties": props,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
return []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "brain_query",
|
||||||
|
"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{
|
||||||
|
"query": str("search terms"),
|
||||||
|
"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",
|
||||||
|
"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{
|
||||||
|
"content": str("markdown content"),
|
||||||
|
"filename": str("optional filename or slug"),
|
||||||
|
"type": str("optional frontmatter type (legacy)"),
|
||||||
|
"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"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_ingest_raw",
|
||||||
|
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
||||||
|
"inputSchema": schema([]string{"source", "pages"}, map[string]any{
|
||||||
|
"source": str("source name"),
|
||||||
|
"pages": map[string]any{"type": "array"},
|
||||||
|
"dry_run": map[string]any{"type": "boolean"},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_ingest",
|
||||||
|
"description": "Ingest content into the brain wiki via the LLM extraction pipeline.",
|
||||||
|
"inputSchema": schema([]string{}, map[string]any{
|
||||||
|
"content": str("raw content; required when path is empty"),
|
||||||
|
"source": str("source name; required when path is empty"),
|
||||||
|
"path": str("file path; mutually exclusive with content+source"),
|
||||||
|
"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",
|
||||||
|
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||||
|
"inputSchema": schema([]string{"session_id"}, map[string]any{
|
||||||
|
"session_id": str("session identifier"),
|
||||||
|
"skill": str("skill name"),
|
||||||
|
"phase": str("phase within the skill"),
|
||||||
|
"project_root": str("absolute project root"),
|
||||||
|
"final_status": str("pass | fail | skip (legacy: ok | error | skipped also accepted)"),
|
||||||
|
"file_path": str("optional file produced"),
|
||||||
|
"model_used": str("optional model identifier"),
|
||||||
|
"duration_ms": int_("optional duration in ms"),
|
||||||
|
"message": str("optional free-text"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainQueryArgs struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
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) {
|
||||||
|
var a brainQueryArgs
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if a.Limit == 0 {
|
||||||
|
a.Limit = 5
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainWriteArgs struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Type string `json:"type,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) {
|
||||||
|
var a brainWriteArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Pages []pipeline.RawPage `json:"pages"`
|
||||||
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainIngestRawArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Source == "" {
|
||||||
|
return nil, fmt.Errorf("source is required")
|
||||||
|
}
|
||||||
|
if len(a.Pages) == 0 {
|
||||||
|
return nil, fmt.Errorf("pages must be non-empty")
|
||||||
|
}
|
||||||
|
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
|
}
|
||||||
|
pages := result.Pages
|
||||||
|
if pages == nil {
|
||||||
|
pages = []string{}
|
||||||
|
}
|
||||||
|
warnings := result.Warnings
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []string{}
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainIngestArgs struct {
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainIngestArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Path != "" && a.Content != "" {
|
||||||
|
return nil, fmt.Errorf("path and content+source are mutually exclusive")
|
||||||
|
}
|
||||||
|
if a.Path == "" && a.Content == "" {
|
||||||
|
return nil, fmt.Errorf("either path or content+source is required")
|
||||||
|
}
|
||||||
|
if s.pipeline.Complete == nil {
|
||||||
|
return nil, fmt.Errorf("LLM not configured: set INGEST_LLM_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Path != "" {
|
||||||
|
text, err := extract.Text(a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: %w", err)
|
||||||
|
}
|
||||||
|
source := a.Source
|
||||||
|
if source == "" {
|
||||||
|
source = filepath.Base(strings.TrimSuffix(a.Path, filepath.Ext(a.Path)))
|
||||||
|
}
|
||||||
|
return s.runIngest(ctx, text, source, a.DryRun)
|
||||||
|
}
|
||||||
|
if a.Source == "" {
|
||||||
|
return nil, fmt.Errorf("source is required when content is provided")
|
||||||
|
}
|
||||||
|
return s.runIngest(ctx, a.Content, a.Source, a.DryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionLogArgs struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Skill string `json:"skill,omitempty"`
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
ProjectRoot string `json:"project_root,omitempty"`
|
||||||
|
FinalStatus string `json:"final_status,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
ModelUsed string `json:"model_used,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a sessionLogArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.SessionID == "" {
|
||||||
|
return nil, fmt.Errorf("session_id is required")
|
||||||
|
}
|
||||||
|
entry := session.Entry{
|
||||||
|
SessionID: a.SessionID,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Skill: a.Skill,
|
||||||
|
Phase: a.Phase,
|
||||||
|
ProjectRoot: a.ProjectRoot,
|
||||||
|
FinalStatus: a.FinalStatus,
|
||||||
|
FilePath: a.FilePath,
|
||||||
|
ModelUsed: a.ModelUsed,
|
||||||
|
DurationMs: a.DurationMs,
|
||||||
|
Message: a.Message,
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.brainDir, "sessions")
|
||||||
|
if err := session.Append(dir, a.SessionID, entry); err != nil {
|
||||||
|
return nil, fmt.Errorf("append: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
|
||||||
|
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
|
}
|
||||||
|
pages := result.Pages
|
||||||
|
if pages == nil {
|
||||||
|
pages = []string{}
|
||||||
|
}
|
||||||
|
warnings := result.Warnings
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []string{}
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||||
|
}
|
||||||
334
ingestion/internal/mcp/handlers_test.go
Normal file
334
ingestion/internal/mcp/handlers_test.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toolCall(t *testing.T, srv http.Handler, name string, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
bodyBytes, err := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": args},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(bodyBytes))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainQueryReturnsResults(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
knowledge := filepath.Join(brainDir, "knowledge")
|
||||||
|
require.NoError(t, os.MkdirAll(knowledge, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(knowledge, "tdd.md"),
|
||||||
|
[]byte("# TDD\n\nTest-driven development is iterative.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"})
|
||||||
|
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
content := result["content"].([]any)
|
||||||
|
require.NotEmpty(t, content)
|
||||||
|
text := content[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, "tdd.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteCreatesFile(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "# Test\n\nbody",
|
||||||
|
"filename": "test.md",
|
||||||
|
"type": "note",
|
||||||
|
"domain": "personal",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "type: note")
|
||||||
|
assert.Contains(t, string(got), "domain: personal")
|
||||||
|
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) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
"filename": "../escape.md",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
"filename": "notes..draft.md",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "knowledge", "notes..draft.md"))
|
||||||
|
require.NoError(t, err, "filename with embedded .. should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIngestRawDryRun(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{
|
||||||
|
"source": "test-source",
|
||||||
|
"dry_run": true,
|
||||||
|
"pages": []map[string]any{
|
||||||
|
{
|
||||||
|
"title": "Test Concept",
|
||||||
|
"type": "concept",
|
||||||
|
"content": "## Definition\nA test concept.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
content := result["content"].([]any)
|
||||||
|
text := content[0].(map[string]any)["text"].(string)
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Pages []string `json:"pages"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(text), &parsed))
|
||||||
|
require.NotEmpty(t, parsed.Pages, "expected at least one page path")
|
||||||
|
assert.Contains(t, parsed.Pages[0], "wiki/concepts/test-concept.md")
|
||||||
|
|
||||||
|
// dry_run: no file should exist
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "wiki", "concepts", "test-concept.md"))
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIngestRejectsBoth(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
"source": "y",
|
||||||
|
"path": "/tmp/foo.md",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIngestRequiresOne(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
|
"content": "x",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil) // nil pipelineCfg → no LLM
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
||||||
|
"content": "some content",
|
||||||
|
"source": "test",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
errObj := resp["error"].(map[string]any)
|
||||||
|
assert.Contains(t, errObj["message"].(string), "LLM not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLogAppends(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "session_log", map[string]any{
|
||||||
|
"session_id": "session-x",
|
||||||
|
"skill": "tdd",
|
||||||
|
"phase": "red",
|
||||||
|
"final_status": "ok",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "sessions", "session-x.jsonl"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), `"skill":"tdd"`)
|
||||||
|
assert.Contains(t, string(got), `"phase":"red"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLogRequiresSessionID(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
}
|
||||||
35
ingestion/internal/mcp/integration_test.go
Normal file
35
ingestion/internal/mcp/integration_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMCPMountedHandler(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("POST /mcp", srv)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
out, _ := io.ReadAll(resp.Body)
|
||||||
|
assert.Contains(t, string(out), `"brain_query"`)
|
||||||
|
}
|
||||||
180
ingestion/internal/mcp/server.go
Normal file
180
ingestion/internal/mcp/server.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
|
// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
|
||||||
|
// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
type request struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID any `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID any `json:"id,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||||
|
type Server struct {
|
||||||
|
brainDir string
|
||||||
|
pipeline pipeline.Config
|
||||||
|
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
|
||||||
|
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
||||||
|
// 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{}
|
||||||
|
if pipelineCfg != nil {
|
||||||
|
cfg = *pipelineCfg
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
// 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
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, nil, -32700, "parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 notifications (no id) must not receive a response.
|
||||||
|
if req.ID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result any
|
||||||
|
var rpcErr *rpcError
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "initialize":
|
||||||
|
result = map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||||
|
"serverInfo": map[string]any{"name": "ingestion-brain", "version": "0.1.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
result = map[string]any{"tools": s.tools()}
|
||||||
|
|
||||||
|
case "tools/call":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||||
|
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = map[string]any{
|
||||||
|
"content": []map[string]any{{"type": "text", "text": string(out)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: result,
|
||||||
|
Error: rpcErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, id any, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Error: &rpcError{Code: code, Message: msg},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCall dispatches a tools/call to the appropriate tool handler.
|
||||||
|
func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
switch name {
|
||||||
|
case "brain_query":
|
||||||
|
return s.brainQuery(ctx, args)
|
||||||
|
case "brain_write":
|
||||||
|
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":
|
||||||
|
return s.brainIngestRaw(ctx, args)
|
||||||
|
case "brain_ingest":
|
||||||
|
return s.brainIngest(ctx, args)
|
||||||
|
case "session_log":
|
||||||
|
return s.sessionLog(ctx, args)
|
||||||
|
case "brain_answer":
|
||||||
|
return s.brainAnswer(ctx, args)
|
||||||
|
case "brain_classify":
|
||||||
|
return s.brainClassify(ctx, args)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ingestion/internal/mcp/server_test.go
Normal file
93
ingestion/internal/mcp/server_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func body(t *testing.T, v any) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return bytes.NewBuffer(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerInitialize(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": map[string]any{},
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
assert.Equal(t, "2024-11-05", result["protocolVersion"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerToolsList(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
tools := resp["result"].(map[string]any)["tools"].([]any)
|
||||||
|
names := make([]string, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
names = append(names, t.(map[string]any)["name"].(string))
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{
|
||||||
|
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
||||||
|
"brain_ingest_raw", "brain_ingest",
|
||||||
|
"brain_answer", "brain_classify", "session_log",
|
||||||
|
}, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "method": "notifications/initialized",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Empty(t, strings.TrimSpace(rr.Body.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
errObj := resp["error"].(map[string]any)
|
||||||
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
|
assert.Contains(t, errObj["message"].(string), "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user