Compare commits
125 Commits
e74320a8e8
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37fdd33b2d | ||
|
|
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 |
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
|
||||
- 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`
|
||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||
- **`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
|
||||
|
||||
|
||||
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
|
||||
@@ -1,44 +1,26 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
SERVICE: supervisor
|
||||
IMAGE: gitea.d-ma.be/mathias/supervisor
|
||||
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 supervisor image
|
||||
run: |
|
||||
set -e
|
||||
trap 'rm -f /tmp/supervisor-image.tar' EXIT
|
||||
IMAGE_TAG="${{ github.sha }}"
|
||||
echo "Building ${IMAGE}:${IMAGE_TAG}"
|
||||
|
||||
buildctl --addr "${BUILDKIT_HOST}" build \
|
||||
--frontend dockerfile.v0 \
|
||||
--local context=. \
|
||||
--local dockerfile=. \
|
||||
--opt build-arg:VERSION="${IMAGE_TAG}" \
|
||||
--output type=oci,dest=/tmp/supervisor-image.tar
|
||||
|
||||
skopeo copy \
|
||||
oci-archive:/tmp/supervisor-image.tar \
|
||||
docker://${IMAGE}:${IMAGE_TAG} \
|
||||
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
||||
|
||||
echo "Built and pushed ${IMAGE}:${IMAGE_TAG}"
|
||||
|
||||
- name: Build and push ingestion image
|
||||
run: |
|
||||
set -e
|
||||
@@ -59,6 +41,28 @@ jobs:
|
||||
|
||||
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
|
||||
@@ -74,17 +78,89 @@ jobs:
|
||||
|
||||
cd /tmp/infra-update
|
||||
|
||||
sed -i "s|gitea.d-ma.be/mathias/supervisor:.*|gitea.d-ma.be/mathias/supervisor:${IMAGE_TAG}|" \
|
||||
"k3s/apps/${SERVICE}/deployment.yaml"
|
||||
|
||||
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||
"k3s/apps/${SERVICE}/ingestion-deployment.yaml"
|
||||
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||
|
||||
sed -i "s|gitea.d-ma.be/mathias/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
||||
"k3s/apps/routing/deployment.yaml"
|
||||
|
||||
git config user.email "cd-bot@d-ma.be"
|
||||
git config user.name "CD Bot"
|
||||
git add "k3s/apps/${SERVICE}/deployment.yaml" "k3s/apps/${SERVICE}/ingestion-deployment.yaml"
|
||||
git commit -m "chore(deploy): ${SERVICE}+ingestion → ${IMAGE_TAG}"
|
||||
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: ${SERVICE}+ingestion → ${IMAGE_TAG}"
|
||||
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
||||
|
||||
- name: Trigger Flux reconcile (immediate)
|
||||
run: |
|
||||
kubectl -n flux-system annotate gitrepository flux-system \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
kubectl -n flux-system annotate kustomization apps \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
|
||||
- name: Wait for Flux to apply new ingestion image
|
||||
run: |
|
||||
EXPECTED="gitea.d-ma.be/mathias/ingestion:${{ github.sha }}"
|
||||
for i in $(seq 1 60); do
|
||||
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||
echo "✓ Flux applied ingestion image after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kubectl get deploy ingestion -n supervisor \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||
| grep -qx "$EXPECTED" \
|
||||
|| { echo "✗ Flux did not apply ingestion image within 60s"; exit 1; }
|
||||
|
||||
- name: Verify ingestion rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/ingestion \
|
||||
--namespace supervisor \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
echo "── pod status ──"
|
||||
kubectl get pods -n supervisor -o wide
|
||||
echo "── events ──"
|
||||
kubectl get events -n supervisor --sort-by='.lastTimestamp' | tail -20
|
||||
echo "── describe ──"
|
||||
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Wait for Flux to apply new routing image
|
||||
run: |
|
||||
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
|
||||
for i in $(seq 1 60); do
|
||||
CURRENT=$(kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||
echo "✓ Flux applied routing image after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||
| grep -qx "$EXPECTED" \
|
||||
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
||||
|
||||
- name: Verify routing rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/routing \
|
||||
--namespace routing \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
echo "── pod status ──"
|
||||
kubectl get pods -n routing -o wide
|
||||
echo "── events ──"
|
||||
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
||||
echo "── describe ──"
|
||||
kubectl describe pods -n routing -l app=routing | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -13,15 +13,7 @@ brain/training-data/**/*.jsonl
|
||||
# Go
|
||||
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
|
||||
.context/system-prompt.txt
|
||||
|
||||
# ── Sensitive ──
|
||||
.env
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"supervisor": {
|
||||
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
|
||||
"env": {
|
||||
"SUPERVISOR_URL": "http://koala:30320/mcp"
|
||||
"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
|
||||
93
DECISIONS.md
93
DECISIONS.md
@@ -67,6 +67,50 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
||||
|
||||
---
|
||||
|
||||
## Plan 6: routing pod reuses internal/skills/{review,debug,retrospective,trainer}
|
||||
|
||||
Plan 6 (Mode 2 routing pod, 2026-05-04) introduces a second consumer of
|
||||
the four cost-routable skill packages. The routing pod constructs each
|
||||
skill via `<pkg>.New(Config{...})` and hands it `routing.Router.Run` as
|
||||
the `CompleteFunc`.
|
||||
|
||||
**Preserved code (do not delete):**
|
||||
- `internal/skills/{review,debug,retrospective,trainer}/`
|
||||
- `internal/registry`, `internal/mcp`, `internal/exec/litellm.go`
|
||||
- `internal/routing/`, `cmd/routing/`
|
||||
|
||||
---
|
||||
|
||||
## Plan 7: supervisor pod retired (2026-05-12)
|
||||
|
||||
**What was deleted:** `cmd/supervisor/`, `internal/skills/{tdd,spec}/`,
|
||||
root `Dockerfile`, supervisor k8s manifests (Deployment, Service, Ingress,
|
||||
NodePort 30320), `supervisor` entry removed from all `.mcp.json` configs.
|
||||
|
||||
**Coverage:** `tdd`/`spec` → SKILL.md files in `~/dev/.skills/`; `review`,
|
||||
`debug`, `retrospective`, `trainer` → routing pod; `brain_*`/`session_log` →
|
||||
brain MCP; `tier` → `hyperguild tier` CLI.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-12 — brain_answer and brain_classify: LLM routing via berget.ai → iguana
|
||||
|
||||
**Context:** Brain MCP returned raw BM25 excerpts with no synthesis. Adding
|
||||
LLM-backed tools enables Q&A and ingestion enrichment without a separate service.
|
||||
|
||||
**Decision:** Two new MCP tools in the ingestion service (`ingestion/internal/mcp/`):
|
||||
- `brain_answer(query)` — BM25 top-10 → LLM synthesis → answer + sources
|
||||
- `brain_classify(text)` — LLM classifies doc into type/title/tags
|
||||
|
||||
Primary LLM: berget.ai `gemma4:31b` (EU cloud, spend tokens while available).
|
||||
Fallback: iguana `gemma4:31b` (local Ollama). Reranker deferred to follow-up.
|
||||
Router lives in `ingestion/internal/llm.Router`; opt-in via `BRAIN_LLM_PRIMARY_URL`.
|
||||
|
||||
**Consequences:** Brain becomes a knowledge assistant, not just a search index.
|
||||
When berget.ai tokens run out, flip `BRAIN_LLM_PRIMARY_URL` to iguana.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-08 — Mistral Vibe gets its own adapter
|
||||
|
||||
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
|
||||
@@ -74,3 +118,52 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
||||
**Decision**: The root context-sync generates a `mathias.md` prompt and `mathias.toml` agent config in `~/.vibe/`. This is the one tool that needs a custom adapter path.
|
||||
|
||||
**Consequences**: Run `vibe --agent mathias` to use your conventions. Other Vibe users on the machine aren't affected.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-18 — project_create commits staging namespace directly to infra main
|
||||
|
||||
**Context:** `project_create` writes a k8s namespace manifest into the infra
|
||||
repo so Flux brings up a staging environment for the new project. Initial
|
||||
implementation pushed to a `staging/<name>` branch, which required manual PR
|
||||
merge before Flux saw the namespace — defeating the "one tool call, project
|
||||
exists, staging reconciling within 60s" goal.
|
||||
|
||||
**Decision:** Option A — commit directly to `main`. `callInfraCommit` passes
|
||||
`branch: "main"` to gitea-mcp's `file_write_branch`; no PR, no merge step.
|
||||
|
||||
**Consequences:** Staging namespace appears in cluster within ~60s of the
|
||||
`project_create` call. Consistent with project-wide TBD policy (CLAUDE.md):
|
||||
commit directly to main, every commit deployable. Acceptable because the
|
||||
manifest is a fresh namespace under `k3s/staging/<name>/` — isolated, low
|
||||
blast-radius, and Flux will simply recreate it if the file is bad. Manual
|
||||
review gating was friction for no compensating safety gain on experiment
|
||||
namespaces.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-18 — pgvector over Qdrant for brain hybrid retrieval (supersedes 2026-04-08)
|
||||
|
||||
**Context:** The 2026-04-08 ADR chose Qdrant for vector store. Since then,
|
||||
postgres18 with pgvector has been deployed in the `databases` namespace on
|
||||
koala and is already the shared default for the rest of the project
|
||||
(CLAUDE.md lists `pgvector (vector), BM25` as the primary search layer and
|
||||
Qdrant only as a fallback "when >1M vectors or hybrid retrieval"). Qdrant
|
||||
itself has never been deployed — `kubectl get` finds no pod, service, or
|
||||
manifest. Standing up a new vector engine for a single consumer is friction
|
||||
that the original ADR did not weigh.
|
||||
|
||||
**Decision:** Use pgvector for brain hybrid retrieval. Issue #8 — and any
|
||||
follow-on embedding work — targets the existing `postgres18` instance:
|
||||
|
||||
- one table `brain_embeddings(path TEXT PRIMARY KEY, embedding VECTOR(768), updated_at TIMESTAMPTZ)`,
|
||||
IVFFlat or HNSW index by feel once volume warrants
|
||||
- BM25 stays as today (file walk + token frequency); cosine via pgvector
|
||||
- hybrid scoring done in SQL or Go; pick once we measure
|
||||
- nomic-embed-text on iguana ollama provides 768-dim vectors
|
||||
|
||||
**Consequences:** One database engine instead of two. Backups, monitoring,
|
||||
and connection pooling already solved. Trade-off: pgvector at >1M vectors
|
||||
or under hybrid-search load may underperform Qdrant — revisit only when
|
||||
benchmarks hurt. The 2026-04-08 ADR is superseded for the brain use case;
|
||||
Qdrant remains the noted fallback path in CLAUDE.md if scale demands it.
|
||||
|
||||
50
Dockerfile
50
Dockerfile
@@ -1,50 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /out/supervisor ./cmd/supervisor
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
# Node.js 22 slim — needed for claude CLI subprocess
|
||||
FROM node:22-slim
|
||||
|
||||
# Install claude CLI (provides the `claude` binary the supervisor shells out to)
|
||||
RUN npm install -g @anthropic-ai/claude-code \
|
||||
&& claude --version \
|
||||
&& echo "claude CLI installed"
|
||||
|
||||
# Copy supervisor binary
|
||||
COPY --from=builder /out/supervisor /usr/local/bin/supervisor
|
||||
|
||||
# Bake in config (models.yaml + skill discipline files)
|
||||
COPY config/ /app/config/
|
||||
|
||||
# Run as non-root
|
||||
RUN groupadd -r supervisor && useradd -r -g supervisor -d /app supervisor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# brain/ is writable state — mount a PersistentVolume here
|
||||
VOLUME /app/brain
|
||||
|
||||
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
||||
ENV SUPERVISOR_MODELS_FILE=/app/config/models.yaml
|
||||
ENV SUPERVISOR_BRAIN_DIR=/app/brain
|
||||
ENV SUPERVISOR_SESSIONS_DIR=/app/brain/sessions
|
||||
ENV SUPERVISOR_PORT=3200
|
||||
|
||||
USER supervisor
|
||||
|
||||
EXPOSE 3200
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/supervisor"]
|
||||
30
Dockerfile.routing
Normal file
30
Dockerfile.routing
Normal file
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /out/routing ./cmd/routing
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM gcr.io/distroless/base-debian12
|
||||
|
||||
COPY --from=builder /out/routing /usr/local/bin/routing
|
||||
COPY config/ /app/config/
|
||||
|
||||
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
||||
ENV ROUTING_PORT=3210
|
||||
|
||||
EXPOSE 3210
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/routing"]
|
||||
43
README.md
43
README.md
@@ -10,10 +10,12 @@ into a searchable brain.
|
||||
```
|
||||
Your Claude Code session (in any project)
|
||||
│
|
||||
│ MCP tools (over stdio bridge → HTTP)
|
||||
▼
|
||||
supervisor :3200 — skill workers: tdd, retrospective
|
||||
ingestion :3300 — brain HTTP API: query wiki, write notes
|
||||
│ MCP over HTTP (Tailscale)
|
||||
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
||||
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
||||
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
||||
│
|
||||
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
||||
│
|
||||
▼
|
||||
brain/
|
||||
@@ -55,18 +57,28 @@ Create `.mcp.json` in your project root:
|
||||
{
|
||||
"mcpServers": {
|
||||
"supervisor": {
|
||||
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
|
||||
"env": {
|
||||
"SUPERVISOR_URL": "http://localhost:3200/mcp"
|
||||
}
|
||||
"type": "http",
|
||||
"url": "http://koala:30320/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
|
||||
|
||||
@@ -100,6 +112,17 @@ The supervisor probes connectivity at call time:
|
||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
||||
| `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)
|
||||
|
||||
|
||||
44
Taskfile.yml
44
Taskfile.yml
@@ -12,9 +12,6 @@ tasks:
|
||||
desc: Regenerate all harness-specific context files
|
||||
cmds:
|
||||
- bash scripts/context-sync.sh
|
||||
sources:
|
||||
- .context/PROJECT.md
|
||||
- .skills/*/SKILL.md
|
||||
|
||||
context:sync:claude:
|
||||
cmds: [bash scripts/context-sync.sh claude]
|
||||
@@ -42,6 +39,22 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
desc: Run ingestion server in development mode
|
||||
dir: ingestion
|
||||
@@ -57,7 +70,6 @@ tasks:
|
||||
desc: Build all binaries
|
||||
cmds:
|
||||
- task: supervisor:build
|
||||
- task: bridge:build
|
||||
- task: ingestion:build
|
||||
|
||||
supervisor:build:
|
||||
@@ -65,11 +77,6 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
desc: Build ingestion server binary
|
||||
dir: ingestion
|
||||
@@ -79,8 +86,20 @@ tasks:
|
||||
# ── Quality ────────────────────────────────────────────────────────────────
|
||||
|
||||
check:
|
||||
desc: Run all checks (lint + test + vet) across all modules
|
||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||
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: test
|
||||
- task: vet
|
||||
@@ -109,6 +128,11 @@ tasks:
|
||||
-H "Content-Type: application/json" \
|
||||
-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 ──────────────────────────────────────────────────────────
|
||||
|
||||
tag:
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
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 `[[slug|Display Text]]`.
|
||||
All cross-references use `[[Display Name]]` — just the display name, no slug, no pipe.
|
||||
|
||||
Rules:
|
||||
- slug = lowercase filename without .md, spaces → hyphens, strip all non-alphanumeric except hyphens
|
||||
- The `|` separator is REQUIRED — never use `[[Title]]` without a slug
|
||||
- Examples: `[[domain-driven-design|Domain Driven Design]]`, `[[ryan-singer|Ryan Singer]]`
|
||||
- Slugs must resolve to an existing file in the inventory, or a file you are creating in this response
|
||||
- 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.)
|
||||
|
||||
Slug generation examples:
|
||||
- "Domain Driven Design" → `domain-driven-design`
|
||||
- "It's Complicated" → `its-complicated`
|
||||
- "gRPC" → `grpc`
|
||||
- "GPT-4o" → `gpt-4o`
|
||||
Examples: `[[Domain Driven Design]]`, `[[Ryan Singer]]`, `[[Shape Up]]`
|
||||
|
||||
## Domains
|
||||
|
||||
@@ -30,17 +43,6 @@ Use one of: `ai-llm`, `software-engineering`, `product-strategy`, `finance-marke
|
||||
|
||||
One page per ingested source. Books are NEVER split across multiple source pages — update the existing one.
|
||||
|
||||
Required frontmatter:
|
||||
```yaml
|
||||
title: <exact title>
|
||||
type: article | pdf | book | video | note | project
|
||||
domain: <domain>
|
||||
date_ingested: YYYY-MM-DD
|
||||
last_updated: YYYY-MM-DD
|
||||
aliases:
|
||||
- <exact title>
|
||||
```
|
||||
|
||||
Body sections (in this order):
|
||||
|
||||
### Summary
|
||||
@@ -50,10 +52,10 @@ Body sections (in this order):
|
||||
Bulleted list. Paraphrase — no verbatim quotes or code.
|
||||
|
||||
### Concepts Introduced or Reinforced
|
||||
Wikilinks to wiki/concepts/ ONLY. One per line.
|
||||
Wikilinks to concept pages ONLY. One per line.
|
||||
|
||||
### Entities Mentioned
|
||||
Wikilinks to wiki/entities/ ONLY. One per line.
|
||||
Wikilinks to entity pages ONLY. One per line.
|
||||
|
||||
### Open Questions Raised
|
||||
Gaps or follow-up questions from this source.
|
||||
@@ -75,15 +77,6 @@ Dated entries appended on re-ingestion. NEVER rewrite — only append.
|
||||
|
||||
One page per idea, framework, methodology, or pattern.
|
||||
|
||||
Required frontmatter:
|
||||
```yaml
|
||||
title: <concept name>
|
||||
domain: <domain>
|
||||
last_updated: YYYY-MM-DD
|
||||
aliases:
|
||||
- <exact title>
|
||||
```
|
||||
|
||||
Body sections (in this order):
|
||||
|
||||
### Definition
|
||||
@@ -93,13 +86,13 @@ One-paragraph plain-language explanation.
|
||||
Practical significance. Why should anyone care?
|
||||
|
||||
### Related Concepts
|
||||
Wikilinks to wiki/concepts/ ONLY.
|
||||
Wikilinks to concept pages ONLY.
|
||||
|
||||
### Related Entities
|
||||
Wikilinks to wiki/entities/ ONLY.
|
||||
Wikilinks to entity pages ONLY.
|
||||
|
||||
### Sources
|
||||
Wikilinks to wiki/sources/ ONLY.
|
||||
Wikilinks to source pages ONLY.
|
||||
|
||||
### Evolving Notes
|
||||
Updated as new sources arrive. Append, do not rewrite.
|
||||
@@ -110,16 +103,6 @@ Updated as new sources arrive. Append, do not rewrite.
|
||||
|
||||
One page per person, tool, organisation, technology, or product.
|
||||
|
||||
Required frontmatter:
|
||||
```yaml
|
||||
title: <name>
|
||||
type: person | company | tool | model | framework | technology
|
||||
domain: <domain>
|
||||
last_updated: YYYY-MM-DD
|
||||
aliases:
|
||||
- <exact title>
|
||||
```
|
||||
|
||||
Body sections (in this order):
|
||||
|
||||
### Description
|
||||
@@ -132,23 +115,23 @@ Why this entity matters to this knowledge base.
|
||||
With dates where known.
|
||||
|
||||
### Related Concepts
|
||||
Wikilinks to wiki/concepts/ ONLY.
|
||||
Wikilinks to concept pages ONLY.
|
||||
|
||||
### Related Entities
|
||||
Wikilinks to wiki/entities/ ONLY.
|
||||
Wikilinks to entity pages ONLY.
|
||||
|
||||
### Sources
|
||||
Wikilinks to wiki/sources/ ONLY.
|
||||
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: `{"path": "wiki/<type>/<slug>.md", "content": "...full markdown..."}`
|
||||
3. Slugs are kebab-case: lowercase, spaces→hyphens, strip special characters
|
||||
4. Every wikilink must be `[[slug|Display Text]]` — the pipe separator is required
|
||||
5. Dates always YYYY-MM-DD
|
||||
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 (Related Concepts → concepts/ only, etc.)
|
||||
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,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/config"
|
||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/org"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
||||
skilldebug "github.com/mathiasbq/supervisor/internal/skills/debug"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/spec"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
||||
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("load config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
models, err := config.LoadModels(cfg.ModelsFile)
|
||||
if err != nil {
|
||||
logger.Error("load models", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
protocolsPrompt, err := os.ReadFile(cfg.ConfigDir + "/protocols.md")
|
||||
if err != nil {
|
||||
logger.Error("read protocols.md", "path", cfg.ConfigDir+"/protocols.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// prependProtocols prepends the shared protocols to a skill discipline file.
|
||||
prependProtocols := func(skillPrompt []byte) string {
|
||||
return string(protocolsPrompt) + "\n---\n\n" + string(skillPrompt)
|
||||
}
|
||||
|
||||
tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
|
||||
if err != nil {
|
||||
logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
|
||||
if err != nil {
|
||||
logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
reviewPrompt, err := os.ReadFile(cfg.ConfigDir + "/review.md")
|
||||
if err != nil {
|
||||
logger.Error("read review.md", "path", cfg.ConfigDir+"/review.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
debugPrompt, err := os.ReadFile(cfg.ConfigDir + "/debug.md")
|
||||
if err != nil {
|
||||
logger.Error("read debug.md", "path", cfg.ConfigDir+"/debug.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
specPrompt, err := os.ReadFile(cfg.ConfigDir + "/spec.md")
|
||||
if err != nil {
|
||||
logger.Error("read spec.md", "path", cfg.ConfigDir+"/spec.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
trainerReaderPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-reader.md")
|
||||
if err != nil {
|
||||
logger.Error("read trainer-reader.md", "path", cfg.ConfigDir+"/trainer-reader.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
trainerWriterPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-writer.md")
|
||||
if err != nil {
|
||||
logger.Error("read trainer-writer.md", "path", cfg.ConfigDir+"/trainer-writer.md", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
litellm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
||||
|
||||
tierFn := func(ctx context.Context) tier.Info {
|
||||
return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL)
|
||||
}
|
||||
|
||||
reg := registry.New()
|
||||
reg.Register(tdd.New(tdd.Config{
|
||||
SkillPrompt: prependProtocols(tddPrompt),
|
||||
DefaultModel: models.ModelFor("tdd", ""),
|
||||
CompleteFunc: litellm.Complete,
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
IngestBaseURL: cfg.IngestBaseURL,
|
||||
}))
|
||||
reg.Register(brain.New(brain.Config{
|
||||
IngestBaseURL: cfg.IngestBaseURL,
|
||||
IngestSvcURL: cfg.IngestSvcURL,
|
||||
KBRetrievalURL: cfg.KBRetrievalURL,
|
||||
}))
|
||||
reg.Register(org.New(org.Config{
|
||||
TierFn: tierFn,
|
||||
}))
|
||||
reg.Register(sessionlog.New(sessionlog.Config{
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
}))
|
||||
reg.Register(retrospective.New(retrospective.Config{
|
||||
SkillPrompt: prependProtocols(retroPrompt),
|
||||
DefaultModel: models.ModelFor("retrospective", ""),
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
CompleteFunc: litellm.Complete,
|
||||
}))
|
||||
reg.Register(review.New(review.Config{
|
||||
SkillPrompt: prependProtocols(reviewPrompt),
|
||||
DefaultModel: models.ModelFor("review", ""),
|
||||
CompleteFunc: litellm.Complete,
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
IngestBaseURL: cfg.IngestBaseURL,
|
||||
}))
|
||||
reg.Register(skilldebug.New(skilldebug.Config{
|
||||
SkillPrompt: prependProtocols(debugPrompt),
|
||||
DefaultModel: models.ModelFor("debug", ""),
|
||||
CompleteFunc: litellm.Complete,
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
IngestBaseURL: cfg.IngestBaseURL,
|
||||
}))
|
||||
reg.Register(spec.New(spec.Config{
|
||||
SkillPrompt: prependProtocols(specPrompt),
|
||||
DefaultModel: models.ModelFor("spec", ""),
|
||||
CompleteFunc: litellm.Complete,
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
IngestBaseURL: cfg.IngestBaseURL,
|
||||
}))
|
||||
reg.Register(trainer.New(trainer.Config{
|
||||
ReaderPrompt: prependProtocols(trainerReaderPrompt),
|
||||
WriterPrompt: prependProtocols(trainerWriterPrompt),
|
||||
DefaultModel: models.ModelFor("trainer", ""),
|
||||
CompleteFunc: litellm.Complete,
|
||||
SessionsDir: cfg.SessionsDir,
|
||||
BrainDir: cfg.BrainDir,
|
||||
}))
|
||||
|
||||
srv := mcp.NewServer(reg)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", srv)
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
logger.Info("supervisor starting", "addr", addr, "version", "v0.5.0")
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
logger.Error("server stopped", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBinaryCompiles(t *testing.T) {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("build failed: %s\n%s", err, out)
|
||||
}
|
||||
}
|
||||
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.
|
||||
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
@@ -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
|
||||
|
||||
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 (
|
||||
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
|
||||
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/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/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/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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -15,6 +15,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
|
||||
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
|
||||
|
||||
@@ -6,16 +6,53 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
@@ -54,6 +91,68 @@ func main() {
|
||||
|
||||
h := api.NewHandler(brainDir, logger, pipelineCfg)
|
||||
|
||||
var answerComplete pipeline.CompleteFunc
|
||||
if primaryURL := os.Getenv("BRAIN_LLM_PRIMARY_URL"); primaryURL != "" {
|
||||
primaryModel := envOr("BRAIN_LLM_PRIMARY_MODEL", "gemma4:31b")
|
||||
primaryKey := os.Getenv("BERGET_API_KEY")
|
||||
timeoutMS := envInt("BRAIN_LLM_TIMEOUT_MS", 10000)
|
||||
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||
|
||||
primary := llm.New(primaryURL, primaryKey, primaryModel, timeout)
|
||||
router := &llm.Router{Primary: primary}
|
||||
|
||||
if fallbackURL := os.Getenv("BRAIN_LLM_FALLBACK_URL"); fallbackURL != "" {
|
||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||
}
|
||||
answerComplete = router.Complete
|
||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||
}
|
||||
|
||||
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete, answerComplete)
|
||||
if rerankURL := os.Getenv("BRAIN_RERANKER_URL"); rerankURL != "" {
|
||||
rerankModel := envOr("BRAIN_RERANKER_MODEL", "dengcao/Qwen3-Reranker-0.6B:F16")
|
||||
mcpSrv = mcpSrv.WithReranker(reranker.New(rerankURL, rerankModel))
|
||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||
}
|
||||
|
||||
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||
// be set together for the path to wire on; otherwise BM25-only.
|
||||
var vectorStore *vectorstore.PGStore
|
||||
pgDSN := os.Getenv("BRAIN_PG_DSN")
|
||||
embedURL := os.Getenv("BRAIN_EMBED_URL")
|
||||
switch {
|
||||
case pgDSN != "" && embedURL != "":
|
||||
embedModel := envOr("BRAIN_EMBED_MODEL", "nomic-embed-text:latest")
|
||||
store, err := vectorstore.New(context.Background(), pgDSN)
|
||||
if err != nil {
|
||||
logger.Error("vector store init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := store.Init(context.Background()); err != nil {
|
||||
logger.Error("vector store migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
vectorStore = store
|
||||
embedder := embed.New(embedURL, embedModel)
|
||||
mcpSrv = mcpSrv.WithHybridRetrieval(vectorAdapter{s: store}, embedder)
|
||||
h.WithEmbedSync(store, embedder)
|
||||
logger.Info("brain hybrid retrieval enabled",
|
||||
"pg", redactDSN(pgDSN),
|
||||
"embed_url", embedURL, "embed_model", embedModel)
|
||||
case pgDSN == "" && embedURL == "":
|
||||
// disabled — fine
|
||||
default:
|
||||
logger.Error("BRAIN_PG_DSN and BRAIN_EMBED_URL must be set together")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mcpToken := os.Getenv("BRAIN_MCP_TOKEN")
|
||||
if mcpToken == "" {
|
||||
logger.Error("BRAIN_MCP_TOKEN not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if watchInterval > 0 {
|
||||
watcher.Start(ctx, watcher.Config{
|
||||
@@ -62,12 +161,79 @@ func main() {
|
||||
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.HandleFunc("POST /query", h.Query)
|
||||
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
|
||||
watchIntervalLog := "disabled"
|
||||
@@ -81,6 +247,7 @@ func main() {
|
||||
"llm_model", llmModel,
|
||||
"chunk_size", chunkSize,
|
||||
"watch_interval", watchIntervalLog,
|
||||
"mcp_enabled", true,
|
||||
)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
logger.Error("server stopped", "err", err)
|
||||
|
||||
@@ -2,10 +2,29 @@ module github.com/mathiasbq/hyperguild/ingestion
|
||||
|
||||
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 (
|
||||
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/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
|
||||
)
|
||||
|
||||
@@ -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/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/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/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 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -11,15 +11,20 @@ import (
|
||||
"strings"
|
||||
"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/vectorstore"
|
||||
)
|
||||
|
||||
// Handler serves the ingestion HTTP API.
|
||||
type Handler struct {
|
||||
brainDir string
|
||||
logger *slog.Logger
|
||||
pipeline pipeline.Config
|
||||
brainDir string
|
||||
logger *slog.Logger
|
||||
pipeline pipeline.Config
|
||||
embedStore vectorstore.Store
|
||||
embedClient vectorstore.Embedder
|
||||
}
|
||||
|
||||
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
|
||||
@@ -30,9 +35,19 @@ func NewHandler(brainDir string, logger *slog.Logger, pipelineCfg pipeline.Confi
|
||||
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 {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
type writeRequest struct {
|
||||
@@ -40,6 +55,8 @@ type writeRequest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type ingestRequest struct {
|
||||
@@ -74,7 +91,12 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
h.logger.Error("query failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "search error")
|
||||
@@ -84,6 +106,120 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
// WriteNoteOptions configures how a brain note is written.
|
||||
//
|
||||
// When both Wing and Hall are non-empty, the note routes into the
|
||||
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||
// wing/hall/created_at injected into its YAML frontmatter.
|
||||
//
|
||||
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||
// with optional type/domain frontmatter (legacy behaviour).
|
||||
type WriteNoteOptions struct {
|
||||
Content string
|
||||
Filename string
|
||||
Type string
|
||||
Domain string
|
||||
Wing string
|
||||
Hall string
|
||||
}
|
||||
|
||||
// WriteNote writes a markdown note into the brain. Returns the path
|
||||
// relative to brainDir (forward-slashed). Filename traversal is rejected.
|
||||
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
if opts.Content == "" {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
if opts.Wing != "" && opts.Hall != "" {
|
||||
return writeHallNote(brainDir, opts)
|
||||
}
|
||||
if opts.Wing != "" || opts.Hall != "" {
|
||||
return "", fmt.Errorf("wing and hall must be set together")
|
||||
}
|
||||
return writeLegacyNote(brainDir, opts)
|
||||
}
|
||||
|
||||
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
||||
// wing/hall/created_at frontmatter.
|
||||
func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
slug := opts.Filename
|
||||
if slug == "" {
|
||||
slug = time.Now().UTC().Format("2006-01-02-150405") + "-auto"
|
||||
}
|
||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
if opts.Type != "" {
|
||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||
}
|
||||
if opts.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||
// callers that have not adopted the wing/hall taxonomy.
|
||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
filename := opts.Filename
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||
}
|
||||
|
||||
rawDir := filepath.Join(brainDir, "knowledge")
|
||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create raw dir: %w", err)
|
||||
}
|
||||
|
||||
finalContent := opts.Content
|
||||
if opts.Type != "" || opts.Domain != "" {
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
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")
|
||||
finalContent = fm.String() + opts.Content
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
@@ -91,53 +227,75 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
filename := req.Filename
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||
}
|
||||
|
||||
rawDir := filepath.Join(h.brainDir, "knowledge")
|
||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create raw dir")
|
||||
return
|
||||
}
|
||||
|
||||
finalContent := req.Content
|
||||
if req.Type != "" || req.Domain != "" {
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
if req.Type != "" {
|
||||
fmt.Fprintf(&fm, "type: %s\n", req.Type)
|
||||
}
|
||||
if req.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", req.Domain)
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
finalContent = fm.String() + req.Content
|
||||
}
|
||||
|
||||
base := filepath.Base(filename)
|
||||
if !strings.HasSuffix(base, ".md") {
|
||||
base += ".md"
|
||||
}
|
||||
dest := filepath.Join(rawDir, base)
|
||||
if !strings.HasPrefix(filepath.Clean(dest)+string(os.PathSeparator), filepath.Clean(rawDir)+string(os.PathSeparator)) {
|
||||
writeError(w, http.StatusBadRequest, "invalid filename")
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||
if err != nil {
|
||||
h.logger.Error("write failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "write error")
|
||||
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})
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(h.brainDir, dest)
|
||||
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
|
||||
// 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.
|
||||
@@ -214,16 +372,16 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
if !supportedExtensions[ext] {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
content, readErr := extract.Text(path)
|
||||
if readErr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("read %s: %v", path, readErr))
|
||||
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, string(content), source, req.DryRun)
|
||||
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
|
||||
@@ -243,16 +401,16 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||
return
|
||||
}
|
||||
content, readErr := os.ReadFile(req.Path)
|
||||
content, readErr := extract.Text(req.Path)
|
||||
if readErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("read file: %v", readErr))
|
||||
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, string(content), source, req.DryRun)
|
||||
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")
|
||||
@@ -271,6 +429,60 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
)
|
||||
|
||||
// stubComplete returns a fixed JSON page so tests never call a real LLM.
|
||||
// stubComplete returns a fixed JSON RawPage so tests never call a real LLM.
|
||||
func stubComplete(_ context.Context, _, _ string) (string, error) {
|
||||
return `[{"path":"wiki/sources/test-source.md","content":"# Test Source\n\nSome content here.\n"}]`, nil
|
||||
return `[{"title":"Test Source","type":"source","subtype":"article","content":"## Summary\n\nSome content here.\n"}]`, nil
|
||||
}
|
||||
|
||||
func stubPipelineCfg() pipeline.Config {
|
||||
@@ -226,6 +226,85 @@ func TestIngestPath_File(t *testing.T) {
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
wait := 5 * time.Second
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil {
|
||||
@@ -98,7 +98,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
|
||||
return "", fmt.Errorf("retry LLM call: %w", err)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ func mockServer(t *testing.T, response string) *httptest.Server {
|
||||
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{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"role": "assistant", "content": response}},
|
||||
},
|
||||
@@ -51,7 +51,7 @@ 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{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{"message": map[string]any{"content": "ok"}}},
|
||||
})
|
||||
}))
|
||||
@@ -72,7 +72,7 @@ func TestClient_Retries429(t *testing.T) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{"message": map[string]any{"content": "retried"}}},
|
||||
})
|
||||
}))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
41
ingestion/internal/oauth/metadata_test.go
Normal file
41
ingestion/internal/oauth/metadata_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package oauth_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMetadataHandler_ReturnsJSON(t *testing.T) {
|
||||
h := oauth.MetadataHandler("https://brain-mcp.d-ma.be")
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"])
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"])
|
||||
assert.ElementsMatch(t, []any{"client_credentials"}, body["grant_types_supported"])
|
||||
assert.ElementsMatch(t,
|
||||
[]any{"client_secret_post", "client_secret_basic"},
|
||||
body["token_endpoint_auth_methods_supported"])
|
||||
}
|
||||
|
||||
func TestMetadataHandler_StripsTrailingSlashFromIssuer(t *testing.T) {
|
||||
h := oauth.MetadataHandler("https://brain-mcp.d-ma.be/")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"])
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"])
|
||||
}
|
||||
87
ingestion/internal/oauth/token.go
Normal file
87
ingestion/internal/oauth/token.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// TokenConfig is the static configuration for the token endpoint. All
|
||||
// three fields are required.
|
||||
type TokenConfig struct {
|
||||
// ClientID and ClientSecret are the single accepted credentials.
|
||||
// claude.ai's custom-MCP UI persists these on its side.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// AccessToken is the bearer value handed back on a successful
|
||||
// exchange. In this deployment it is BRAIN_MCP_TOKEN — the same
|
||||
// static token the rest of the auth middleware already accepts —
|
||||
// so no JWT machinery is needed downstream.
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// TokenHandler serves POST /oauth/token. Implements the
|
||||
// client_credentials grant only, with client_secret_post and
|
||||
// client_secret_basic auth methods (both advertised by MetadataHandler).
|
||||
// Errors follow RFC 6749 §5.2 — JSON body with an "error" field.
|
||||
//
|
||||
// Mount with no auth — credentials live in the request body / header.
|
||||
func TokenHandler(cfg TokenConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeOAuthError(w, http.StatusMethodNotAllowed, "invalid_request", "POST required")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "form parse")
|
||||
return
|
||||
}
|
||||
if r.PostForm.Get("grant_type") != "client_credentials" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||
"only client_credentials is supported")
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := extractClientCreds(r)
|
||||
if !constantTimeEqual(clientID, cfg.ClientID) ||
|
||||
!constantTimeEqual(clientSecret, cfg.ClientSecret) {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "bad credentials")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}{cfg.AccessToken, "bearer"})
|
||||
}
|
||||
}
|
||||
|
||||
// extractClientCreds returns the client_id and client_secret pair from
|
||||
// either client_secret_basic (HTTP Basic) or client_secret_post (form
|
||||
// fields). When both are present, Basic wins per RFC 6749 §2.3.1.
|
||||
func extractClientCreds(r *http.Request) (string, string) {
|
||||
if id, secret, ok := r.BasicAuth(); ok {
|
||||
return id, secret
|
||||
}
|
||||
return r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}{code, desc})
|
||||
}
|
||||
134
ingestion/internal/oauth/token_test.go
Normal file
134
ingestion/internal/oauth/token_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package oauth_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTokenServer() *httptest.Server {
|
||||
return httptest.NewServer(oauth.TokenHandler(oauth.TokenConfig{
|
||||
ClientID: "the-client",
|
||||
ClientSecret: "the-secret",
|
||||
AccessToken: "BRAIN_TOKEN_VALUE",
|
||||
}))
|
||||
}
|
||||
|
||||
func postForm(t *testing.T, srv *httptest.Server, vals url.Values, basic [2]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", strings.NewReader(vals.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if basic[0] != "" {
|
||||
req.SetBasicAuth(basic[0], basic[1])
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestTokenHandler_ClientSecretPost_Success(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
resp := postForm(t, srv, url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {"the-client"},
|
||||
"client_secret": {"the-secret"},
|
||||
}, [2]string{})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "BRAIN_TOKEN_VALUE", body["access_token"])
|
||||
assert.Equal(t, "bearer", body["token_type"])
|
||||
}
|
||||
|
||||
func TestTokenHandler_ClientSecretBasic_Success(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
resp := postForm(t, srv,
|
||||
url.Values{"grant_type": {"client_credentials"}},
|
||||
[2]string{"the-client", "the-secret"},
|
||||
)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTokenHandler_WrongSecret(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
resp := postForm(t, srv, url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {"the-client"},
|
||||
"client_secret": {"wrong"},
|
||||
}, [2]string{})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "invalid_client", body["error"])
|
||||
}
|
||||
|
||||
func TestTokenHandler_BadGrantType(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
resp := postForm(t, srv, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"the-client"},
|
||||
"client_secret": {"the-secret"},
|
||||
}, [2]string{})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, "unsupported_grant_type", body["error"])
|
||||
}
|
||||
|
||||
func TestTokenHandler_RejectsGet(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
resp, err := http.Get(srv.URL + "/oauth/token")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTokenHandler_BasicMalformed_FallsThrough(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
// Malformed (non-base64) Authorization header — handler should treat
|
||||
// the request as missing creds, not crash.
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token",
|
||||
strings.NewReader("grant_type=client_credentials"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic ###not-base64###")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTokenHandler_BasicNoColon(t *testing.T) {
|
||||
srv := newTokenServer()
|
||||
defer srv.Close()
|
||||
// "client-only" base64 — missing the `:secret` half.
|
||||
enc := base64.StdEncoding.EncodeToString([]byte("the-client"))
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token",
|
||||
strings.NewReader("grant_type=client_credentials"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+enc)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
91
ingestion/internal/pipeline/backfill.go
Normal file
91
ingestion/internal/pipeline/backfill.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// ingestion/internal/pipeline/backfill.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
// BackfillRefs walks wiki/sources/ and injects source back-references into every
|
||||
// concept and entity page that each source links to.
|
||||
// Changes for all sources are accumulated in memory before writing, so multiple
|
||||
// sources referencing the same concept are merged in one pass.
|
||||
// Deduplication is handled by wiki.Merge — running this multiple times is safe.
|
||||
// Returns the number of concept/entity pages written.
|
||||
func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
||||
inventory, err := wiki.LoadInventory(brainDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load inventory: %w", err)
|
||||
}
|
||||
|
||||
sourcesDir := filepath.Join(brainDir, "wiki", "sources")
|
||||
entries, err := os.ReadDir(sourcesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("read sources dir: %w", err)
|
||||
}
|
||||
|
||||
// Accumulate all changes before writing: relPath → updated Page.
|
||||
// Collecting first means two sources that both link the same concept
|
||||
// get both refs merged before a single write.
|
||||
pending := make(map[string]wiki.Page)
|
||||
|
||||
for _, e := range entries {
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sourceContent := string(b)
|
||||
sourceSlug := strings.TrimSuffix(e.Name(), ".md")
|
||||
sourceTitle := extractTitle(sourceContent)
|
||||
if sourceTitle == "" {
|
||||
sourceTitle = sourceSlug
|
||||
}
|
||||
sourceRef := "- [[" + sourceSlug + "|" + sourceTitle + "]]"
|
||||
|
||||
for slug := range extractWikilinks(sourceContent) {
|
||||
if slug == sourceSlug {
|
||||
continue
|
||||
}
|
||||
pt, ok := findInInventory(slug, inventory)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
relPath := "wiki/" + string(pt) + "/" + slug + ".md"
|
||||
|
||||
// Start from already-accumulated version if we've seen this page.
|
||||
page, seen := pending[relPath]
|
||||
if !seen {
|
||||
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
page = wiki.Page{Path: relPath, Content: string(raw)}
|
||||
}
|
||||
pending[relPath] = addSourceRef(page, sourceRef)
|
||||
}
|
||||
}
|
||||
|
||||
for relPath, page := range pending {
|
||||
dest := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
||||
return 0, fmt.Errorf("write %s: %w", relPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return len(pending), nil
|
||||
}
|
||||
107
ingestion/internal/pipeline/backfill_test.go
Normal file
107
ingestion/internal/pipeline/backfill_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// ingestion/internal/pipeline/backfill_test.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupBrainDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, sub := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func TestBackfillRefs_UpdatesConcept(t *testing.T) {
|
||||
dir := setupBrainDir(t)
|
||||
writeFile(t, filepath.Join(dir, "wiki/sources/shape-up.md"),
|
||||
"---\ntitle: Shape Up\n---\n\n## Summary\n\nSee [[betting|Betting]].\n")
|
||||
writeFile(t, filepath.Join(dir, "wiki/concepts/betting.md"),
|
||||
"---\ntitle: Betting\n---\n\n## Definition\n\nA resource allocation technique.\n")
|
||||
|
||||
n, err := BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, n)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/concepts/betting.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "## Sources")
|
||||
assert.Contains(t, string(got), "[[shape-up|Shape Up]]")
|
||||
assert.Contains(t, string(got), "## Definition") // original content preserved
|
||||
}
|
||||
|
||||
func TestBackfillRefs_Deduplication(t *testing.T) {
|
||||
dir := setupBrainDir(t)
|
||||
writeFile(t, filepath.Join(dir, "wiki/sources/shape-up.md"),
|
||||
"---\ntitle: Shape Up\n---\n\n## Summary\n\nSee [[betting|Betting]].\n")
|
||||
writeFile(t, filepath.Join(dir, "wiki/concepts/betting.md"),
|
||||
"---\ntitle: Betting\n---\n\n## Definition\n\nA technique.\n")
|
||||
|
||||
// Run twice — should not duplicate the ref.
|
||||
_, err := BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
_, err = BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/concepts/betting.md"))
|
||||
require.NoError(t, err)
|
||||
|
||||
count := 0
|
||||
for _, line := range splitLines(string(got)) {
|
||||
if line == "- [[shape-up|Shape Up]]" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "ref should appear exactly once after two runs")
|
||||
}
|
||||
|
||||
func TestBackfillRefs_MultipleSources(t *testing.T) {
|
||||
dir := setupBrainDir(t)
|
||||
writeFile(t, filepath.Join(dir, "wiki/sources/book-a.md"),
|
||||
"---\ntitle: Book A\n---\n\n## Summary\n\nSee [[shaping|Shaping]].\n")
|
||||
writeFile(t, filepath.Join(dir, "wiki/sources/book-b.md"),
|
||||
"---\ntitle: Book B\n---\n\n## Summary\n\nAlso [[shaping|Shaping]].\n")
|
||||
writeFile(t, filepath.Join(dir, "wiki/concepts/shaping.md"),
|
||||
"---\ntitle: Shaping\n---\n\n## Definition\n\nA design activity.\n")
|
||||
|
||||
n, err := BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, n) // one concept page written
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/concepts/shaping.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "[[book-a|Book A]]")
|
||||
assert.Contains(t, string(got), "[[book-b|Book B]]")
|
||||
}
|
||||
|
||||
func TestBackfillRefs_NoSourcesDir(t *testing.T) {
|
||||
dir := t.TempDir() // no wiki/sources subdir
|
||||
n, err := BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestBackfillRefs_SkipsUnknownSlugs(t *testing.T) {
|
||||
dir := setupBrainDir(t)
|
||||
// Source links to a slug not in inventory and not on disk.
|
||||
writeFile(t, filepath.Join(dir, "wiki/sources/article.md"),
|
||||
"---\ntitle: Article\n---\n\n## Summary\n\nSee [[ghost-slug|Ghost]].\n")
|
||||
|
||||
n, err := BackfillRefs(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
106
ingestion/internal/pipeline/build.go
Normal file
106
ingestion/internal/pipeline/build.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// ingestion/internal/pipeline/build.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
// BuildPages converts RawPages from the LLM into wiki.Pages with computed slugs,
|
||||
// paths, and YAML frontmatter. sourceSlug is the slug of the source being ingested
|
||||
// (derived from the filename, not the LLM title). Pages whose title resolves to an
|
||||
// empty slug are skipped and returned as warnings instead.
|
||||
func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
||||
out := make([]wiki.Page, 0, len(rawPages))
|
||||
var warnings []string
|
||||
for _, rp := range rawPages {
|
||||
slug := computeSlug(rp, sourceSlug)
|
||||
if slug == "" {
|
||||
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
||||
continue
|
||||
}
|
||||
out = append(out, buildPage(rp, sourceSlug, date))
|
||||
}
|
||||
return out, warnings
|
||||
}
|
||||
|
||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||
if rp.Type == "source" {
|
||||
return sourceSlug
|
||||
}
|
||||
return wiki.Slug(rp.Title)
|
||||
}
|
||||
|
||||
func buildPage(rp RawPage, sourceSlug, date string) wiki.Page {
|
||||
var slug, dir string
|
||||
switch rp.Type {
|
||||
case "source":
|
||||
slug = sourceSlug
|
||||
dir = "wiki/sources"
|
||||
case "concept":
|
||||
slug = wiki.Slug(rp.Title)
|
||||
dir = "wiki/concepts"
|
||||
case "entity":
|
||||
slug = wiki.Slug(rp.Title)
|
||||
dir = "wiki/entities"
|
||||
default:
|
||||
slug = wiki.Slug(rp.Title)
|
||||
dir = "wiki/" + rp.Type
|
||||
}
|
||||
|
||||
path := dir + "/" + slug + ".md"
|
||||
fm := buildFrontmatter(rp, date)
|
||||
|
||||
return wiki.Page{
|
||||
Path: path,
|
||||
Content: fm + "\n" + rp.Content,
|
||||
}
|
||||
}
|
||||
|
||||
func buildFrontmatter(rp RawPage, date string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
fmt.Fprintf(&sb, "title: %s\n", yamlScalar(rp.Title))
|
||||
|
||||
switch rp.Type {
|
||||
case "source":
|
||||
subtype := rp.Subtype
|
||||
if subtype == "" {
|
||||
subtype = "article"
|
||||
}
|
||||
fmt.Fprintf(&sb, "type: %s\n", yamlScalar(subtype))
|
||||
if rp.Domain != "" {
|
||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||
}
|
||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||
case "concept":
|
||||
if rp.Domain != "" {
|
||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||
}
|
||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||
case "entity":
|
||||
if rp.Subtype != "" {
|
||||
fmt.Fprintf(&sb, "type: %s\n", yamlScalar(rp.Subtype))
|
||||
}
|
||||
if rp.Domain != "" {
|
||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||
}
|
||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||
default:
|
||||
if rp.Domain != "" {
|
||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||
}
|
||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "aliases:\n - %s\n", yamlScalar(rp.Title))
|
||||
sb.WriteString("---\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func yamlScalar(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
167
ingestion/internal/pipeline/build_test.go
Normal file
167
ingestion/internal/pipeline/build_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// ingestion/internal/pipeline/build_test.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildPages_SourcePage(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{
|
||||
Title: "Shape Up",
|
||||
Type: "source",
|
||||
Subtype: "book",
|
||||
Domain: "product-strategy",
|
||||
Content: "## Summary\n\nA book about shaping product work.\n",
|
||||
},
|
||||
}
|
||||
pages, warnings := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
|
||||
p := pages[0]
|
||||
assert.Equal(t, "wiki/sources/shape-up.md", p.Path)
|
||||
assert.Contains(t, p.Content, "title: 'Shape Up'")
|
||||
assert.Contains(t, p.Content, "type: 'book'")
|
||||
assert.Contains(t, p.Content, "domain: 'product-strategy'")
|
||||
assert.Contains(t, p.Content, "date_ingested: 2026-04-23")
|
||||
assert.Contains(t, p.Content, "last_updated: 2026-04-23")
|
||||
assert.Contains(t, p.Content, "aliases:\n - 'Shape Up'")
|
||||
assert.Contains(t, p.Content, "## Summary")
|
||||
assert.True(t, strings.HasPrefix(p.Content, "---\n"), "content must start with frontmatter")
|
||||
}
|
||||
|
||||
func TestBuildPages_ConceptPage(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{
|
||||
Title: "Betting",
|
||||
Type: "concept",
|
||||
Domain: "product-strategy",
|
||||
Content: "## Definition\n\nA resource allocation technique.\n",
|
||||
},
|
||||
}
|
||||
pages, warnings := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
|
||||
p := pages[0]
|
||||
assert.Equal(t, "wiki/concepts/betting.md", p.Path)
|
||||
assert.Contains(t, p.Content, "title: 'Betting'")
|
||||
assert.Contains(t, p.Content, "domain: 'product-strategy'")
|
||||
assert.Contains(t, p.Content, "last_updated: 2026-04-23")
|
||||
assert.Contains(t, p.Content, "aliases:\n - 'Betting'")
|
||||
assert.NotContains(t, p.Content, "date_ingested")
|
||||
assert.Contains(t, p.Content, "## Definition")
|
||||
}
|
||||
|
||||
func TestBuildPages_EntityPage(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{
|
||||
Title: "Ryan Singer",
|
||||
Type: "entity",
|
||||
Subtype: "person",
|
||||
Domain: "product-strategy",
|
||||
Content: "## Description\n\nA product designer.\n",
|
||||
},
|
||||
}
|
||||
pages, warnings := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
|
||||
p := pages[0]
|
||||
assert.Equal(t, "wiki/entities/ryan-singer.md", p.Path)
|
||||
assert.Contains(t, p.Content, "title: 'Ryan Singer'")
|
||||
assert.Contains(t, p.Content, "type: 'person'")
|
||||
assert.Contains(t, p.Content, "domain: 'product-strategy'")
|
||||
assert.Contains(t, p.Content, "last_updated: 2026-04-23")
|
||||
assert.Contains(t, p.Content, "aliases:\n - 'Ryan Singer'")
|
||||
assert.NotContains(t, p.Content, "date_ingested")
|
||||
}
|
||||
|
||||
func TestBuildPages_SourceSlugUsedForSourcePage(t *testing.T) {
|
||||
// LLM title differs from filename — pipeline uses sourceSlug for the source page path.
|
||||
raw := []RawPage{
|
||||
{Title: "FinBERT: A Pretrained Model", Type: "source", Subtype: "article", Content: "## Summary\n\nA model.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "finbert-huggingface", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Equal(t, "wiki/sources/finbert-huggingface.md", pages[0].Path)
|
||||
}
|
||||
|
||||
func TestBuildPages_ConceptSlugDerivedFromTitle(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Domain-Driven Design", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "some-source", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Equal(t, "wiki/concepts/domain-driven-design.md", pages[0].Path)
|
||||
}
|
||||
|
||||
func TestBuildPages_SourceDefaultSubtype(t *testing.T) {
|
||||
// If subtype is omitted for a source, default to "article"
|
||||
raw := []RawPage{
|
||||
{Title: "Some Post", Type: "source", Content: "## Summary\n\nA post.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "some-post", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Contains(t, pages[0].Content, "type: 'article'")
|
||||
}
|
||||
|
||||
func TestBuildPages_OmitsDomainWhenEmpty(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Betting", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "src", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.NotContains(t, pages[0].Content, "domain:")
|
||||
}
|
||||
|
||||
func TestBuildPages_MultiplePages(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Shape Up", Type: "source", Subtype: "book", Content: "## Summary\n\nA book.\n"},
|
||||
{Title: "Betting", Type: "concept", Content: "## Definition\n\nA technique.\n"},
|
||||
{Title: "Ryan Singer", Type: "entity", Subtype: "person", Content: "## Description\n\nA designer.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 3)
|
||||
assert.Equal(t, "wiki/sources/shape-up.md", pages[0].Path)
|
||||
assert.Equal(t, "wiki/concepts/betting.md", pages[1].Path)
|
||||
assert.Equal(t, "wiki/entities/ryan-singer.md", pages[2].Path)
|
||||
}
|
||||
|
||||
func TestBuildPages_TitleWithColon(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Shape Up: The Basecamp Method", Type: "source", Subtype: "book", Content: "## Summary\n\nA book.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
// Title with colon must be quoted in YAML
|
||||
assert.Contains(t, pages[0].Content, "title: 'Shape Up: The Basecamp Method'")
|
||||
assert.Contains(t, pages[0].Content, "aliases:\n - 'Shape Up: The Basecamp Method'")
|
||||
}
|
||||
|
||||
func TestBuildPages_EntityNoSubtype(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Basecamp", Type: "entity", Content: "## Description\n\nA company.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "src", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.NotContains(t, pages[0].Content, "type:")
|
||||
assert.Contains(t, pages[0].Content, "title: 'Basecamp'")
|
||||
}
|
||||
|
||||
func TestBuildPages_EmptyTitleSkippedWithWarning(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
||||
{Title: "Betting", Type: "concept", Content: "## Definition\n\nA technique.\n"},
|
||||
}
|
||||
pages, warnings := BuildPages(raw, "src", "2026-04-23")
|
||||
require.Len(t, pages, 1, "empty-title page should be skipped")
|
||||
assert.Equal(t, "wiki/concepts/betting.md", pages[0].Path)
|
||||
assert.Len(t, warnings, 1)
|
||||
assert.Contains(t, warnings[0], "empty title")
|
||||
}
|
||||
70
ingestion/internal/pipeline/links.go
Normal file
70
ingestion/internal/pipeline/links.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// ingestion/internal/pipeline/links.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
// plainLinkRE matches [[Display Name]] — wikilinks without a slug pipe.
|
||||
// It does NOT match [[slug|Display]] (those already have a pipe).
|
||||
var plainLinkRE = regexp.MustCompile(`\[\[([^\]|]+)\]\]`)
|
||||
|
||||
// CanonicalizeLinks converts [[Display Name]] wikilinks to [[slug|Display Name]]
|
||||
// using a title→slug map built from the inventory and current batch.
|
||||
// Unknown titles are left as-is and returned as warnings.
|
||||
func CanonicalizeLinks(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) ([]wiki.Page, []string) {
|
||||
titleToSlug := buildTitleMap(pages, inventory)
|
||||
|
||||
var allWarnings []string
|
||||
out := make([]wiki.Page, len(pages))
|
||||
for i, p := range pages {
|
||||
newContent, warnings := canonicalizeContent(p.Content, titleToSlug)
|
||||
p.Content = newContent
|
||||
out[i] = p
|
||||
allWarnings = append(allWarnings, warnings...)
|
||||
}
|
||||
return out, allWarnings
|
||||
}
|
||||
|
||||
// buildTitleMap builds a lowercase-title → slug map from inventory and current batch.
|
||||
// Current batch entries take precedence over inventory (they may be updates).
|
||||
func buildTitleMap(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, entries := range inventory {
|
||||
for _, e := range entries {
|
||||
m[strings.ToLower(e.Title)] = e.Slug
|
||||
}
|
||||
}
|
||||
// Current batch overrides inventory
|
||||
for _, p := range pages {
|
||||
title := extractTitle(p.Content)
|
||||
slug := strings.TrimSuffix(filepath.Base(p.Path), ".md")
|
||||
if title != "" && slug != "" {
|
||||
m[strings.ToLower(title)] = slug
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func canonicalizeContent(content string, titleToSlug map[string]string) (string, []string) {
|
||||
var warnings []string
|
||||
result := plainLinkRE.ReplaceAllStringFunc(content, func(match string) string {
|
||||
sub := plainLinkRE.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
displayName := sub[1]
|
||||
slug, ok := titleToSlug[strings.ToLower(displayName)]
|
||||
if !ok {
|
||||
warnings = append(warnings, fmt.Sprintf("unknown wikilink: [[%s]]", displayName))
|
||||
return match
|
||||
}
|
||||
return "[[" + slug + "|" + displayName + "]]"
|
||||
})
|
||||
return result, warnings
|
||||
}
|
||||
125
ingestion/internal/pipeline/links_test.go
Normal file
125
ingestion/internal/pipeline/links_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// ingestion/internal/pipeline/links_test.go
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
func TestCanonicalizeLinks_KnownTitle(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/shape-up.md",
|
||||
Content: "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nSee [[Betting]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{
|
||||
wiki.PageTypeConcept: {
|
||||
{Slug: "betting", Title: "Betting"},
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
assert.NotContains(t, got[0].Content, "[[Betting]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_UnknownTitleLeftAsIs(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/shape-up.md",
|
||||
Content: "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nSee [[Ghost Concept]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.NotEmpty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[Ghost Concept]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_AlreadyCanonicalLinkUntouched(t *testing.T) {
|
||||
// Links already in [[slug|Display]] format must not be double-converted
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/shape-up.md",
|
||||
Content: "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nSee [[betting|Betting]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{
|
||||
wiki.PageTypeConcept: {
|
||||
{Slug: "betting", Title: "Betting"},
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
// Should remain exactly as-is — not double-wrapped
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
assert.NotContains(t, got[0].Content, "[[betting|[[betting|Betting]]]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_CaseInsensitiveMatch(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/foo.md",
|
||||
Content: "---\ntitle: 'Foo'\n---\n\n## Summary\n\nSee [[domain driven design]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{
|
||||
wiki.PageTypeConcept: {
|
||||
{Slug: "domain-driven-design", Title: "Domain Driven Design"},
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[domain-driven-design|domain driven design]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_CurrentBatchPagesResolved(t *testing.T) {
|
||||
// A concept created in the same batch should be canonicalizable
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/shape-up.md",
|
||||
Content: "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nSee [[Betting]].\n",
|
||||
},
|
||||
{
|
||||
Path: "wiki/concepts/betting.md",
|
||||
Content: "---\ntitle: 'Betting'\n---\n\n## Definition\n\nA technique.\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{} // empty — Betting is in the batch, not inventory
|
||||
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 2)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_MultipleLinksInOnePage(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/foo.md",
|
||||
Content: "---\ntitle: 'Foo'\n---\n\n## Summary\n\nSee [[Betting]] and [[Shape Up]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{
|
||||
wiki.PageTypeConcept: {
|
||||
{Slug: "betting", Title: "Betting"},
|
||||
},
|
||||
wiki.PageTypeSource: {
|
||||
{Slug: "shape-up", Title: "Shape Up"},
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
assert.Contains(t, got[0].Content, "[[shape-up|Shape Up]]")
|
||||
}
|
||||
@@ -5,13 +5,22 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
// ParsePages parses LLM output as a JSON array of {path, content} objects.
|
||||
// If the array is truncated mid-object (token limit), it salvages all complete objects.
|
||||
func ParsePages(output string) ([]wiki.Page, []string) {
|
||||
// RawPage is the LLM's output format — minimal structured data with no path or frontmatter.
|
||||
// The pipeline derives slugs, paths, and frontmatter from these fields.
|
||||
type RawPage struct {
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"` // "source" | "concept" | "entity"
|
||||
Subtype string `json:"subtype"` // entity: person|company|tool|model|framework|technology; source: article|pdf|book|video|note|project
|
||||
Domain string `json:"domain"`
|
||||
Content string `json:"content"` // Markdown body only — no frontmatter
|
||||
}
|
||||
|
||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||
// If the output contains invalid JSON escape sequences (e.g. \. from Markdown),
|
||||
// it attempts repair before falling back to truncation recovery.
|
||||
func ParseRawPages(output string) ([]RawPage, []string) {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return nil, []string{"LLM returned empty output"}
|
||||
@@ -19,23 +28,30 @@ func ParsePages(output string) ([]wiki.Page, []string) {
|
||||
|
||||
output = stripFences(output)
|
||||
|
||||
var pages []wiki.Page
|
||||
// Fast path: valid JSON.
|
||||
var pages []RawPage
|
||||
if err := json.Unmarshal([]byte(output), &pages); err == nil {
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// Repair pass: fix invalid escape sequences (e.g. \. \d from Markdown content).
|
||||
repaired := repairJSON(output)
|
||||
if err := json.Unmarshal([]byte(repaired), &pages); err == nil {
|
||||
return pages, []string{"repaired invalid JSON escape sequences in LLM output"}
|
||||
}
|
||||
|
||||
// Truncation recovery: find last `}` that closes a complete object.
|
||||
idx := strings.LastIndex(output, "}")
|
||||
idx := strings.LastIndex(repaired, "}")
|
||||
if idx < 0 {
|
||||
return nil, []string{"LLM output contained no complete JSON objects"}
|
||||
}
|
||||
|
||||
start := strings.Index(output, "[")
|
||||
start := strings.Index(repaired, "[")
|
||||
if start < 0 {
|
||||
return nil, []string{"LLM output contained no JSON array opening bracket"}
|
||||
}
|
||||
|
||||
candidate := output[start:idx+1] + "]"
|
||||
candidate := repaired[start:idx+1] + "]"
|
||||
if err := json.Unmarshal([]byte(candidate), &pages); err != nil {
|
||||
return nil, []string{fmt.Sprintf("truncation recovery failed: %v", err)}
|
||||
}
|
||||
@@ -43,6 +59,45 @@ func ParsePages(output string) ([]wiki.Page, []string) {
|
||||
return pages, []string{fmt.Sprintf("LLM output was truncated; recovered %d page(s)", len(pages))}
|
||||
}
|
||||
|
||||
// repairJSON replaces invalid JSON escape sequences (e.g. \. \d \p) with
|
||||
// a properly escaped backslash followed by the same character.
|
||||
// It iterates byte-by-byte to correctly skip already-valid escape sequences
|
||||
// (including \\) without requiring lookbehind support.
|
||||
func repairJSON(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] != '\\' {
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// We have a backslash. Peek at the next character.
|
||||
if i+1 >= len(s) {
|
||||
// Trailing backslash — emit as-is.
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
next := s[i+1]
|
||||
switch next {
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u':
|
||||
// Valid JSON escape sequence — emit both characters as-is.
|
||||
b.WriteByte(s[i])
|
||||
b.WriteByte(next)
|
||||
i += 2
|
||||
default:
|
||||
// Invalid escape — double the backslash.
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(next)
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripFences(s string) string {
|
||||
for _, prefix := range []string{"```json\n", "```json\r\n", "```\n", "```\r\n"} {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
|
||||
@@ -8,39 +8,80 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePages_ValidJSON(t *testing.T) {
|
||||
input := `[{"path":"wiki/sources/foo.md","content":"# Foo"},{"path":"wiki/concepts/bar.md","content":"# Bar"}]`
|
||||
pages, warnings := ParsePages(input)
|
||||
func TestParseRawPages_ValidJSON(t *testing.T) {
|
||||
input := `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product-strategy","content":"## Summary\n\nFoo."},{"title":"Betting","type":"concept","content":"## Definition\n\nA technique."}]`
|
||||
pages, warnings := ParseRawPages(input)
|
||||
require.Len(t, pages, 2)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, "wiki/sources/foo.md", pages[0].Path)
|
||||
assert.Equal(t, "wiki/concepts/bar.md", pages[1].Path)
|
||||
assert.Equal(t, "Shape Up", pages[0].Title)
|
||||
assert.Equal(t, "source", pages[0].Type)
|
||||
assert.Equal(t, "book", pages[0].Subtype)
|
||||
assert.Equal(t, "product-strategy", pages[0].Domain)
|
||||
assert.Equal(t, "Betting", pages[1].Title)
|
||||
assert.Equal(t, "concept", pages[1].Type)
|
||||
assert.Empty(t, pages[1].Subtype)
|
||||
}
|
||||
|
||||
func TestParsePages_StripsFences(t *testing.T) {
|
||||
input := "```json\n[{\"path\":\"wiki/sources/foo.md\",\"content\":\"# Foo\"}]\n```"
|
||||
pages, warnings := ParsePages(input)
|
||||
assert.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParsePages_TruncationRecovery(t *testing.T) {
|
||||
input := `[{"path":"wiki/sources/foo.md","content":"# Foo"},{"path":"wiki/concepts/bar.md","content":"trunc`
|
||||
pages, warnings := ParsePages(input)
|
||||
func TestParseRawPages_StripsFences(t *testing.T) {
|
||||
input := "```json\n[{\"title\":\"Foo\",\"type\":\"concept\",\"content\":\"## Definition\\n\\nFoo.\"}]\n```"
|
||||
pages, warnings := ParseRawPages(input)
|
||||
require.Len(t, pages, 1)
|
||||
assert.Equal(t, "wiki/sources/foo.md", pages[0].Path)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, "Foo", pages[0].Title)
|
||||
}
|
||||
|
||||
func TestParseRawPages_TruncationRecovery(t *testing.T) {
|
||||
input := `[{"title":"Foo","type":"concept","content":"## Definition\n\nFoo."},{"title":"Bar","type":"concept","content":"trunc`
|
||||
pages, warnings := ParseRawPages(input)
|
||||
require.Len(t, pages, 1)
|
||||
assert.Equal(t, "Foo", pages[0].Title)
|
||||
assert.NotEmpty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParsePages_EmptyInput(t *testing.T) {
|
||||
pages, warnings := ParsePages("")
|
||||
func TestParseRawPages_EmptyInput(t *testing.T) {
|
||||
pages, warnings := ParseRawPages("")
|
||||
assert.Empty(t, pages)
|
||||
assert.NotEmpty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParsePages_PlainFence(t *testing.T) {
|
||||
input := "```\n[{\"path\":\"wiki/sources/foo.md\",\"content\":\"ok\"}]\n```"
|
||||
pages, warnings := ParsePages(input)
|
||||
assert.Len(t, pages, 1)
|
||||
func TestParseRawPages_PlainFence(t *testing.T) {
|
||||
input := "```\n[{\"title\":\"Foo\",\"type\":\"concept\",\"content\":\"ok\"}]\n```"
|
||||
pages, warnings := ParseRawPages(input)
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseRawPages_MissingTitle(t *testing.T) {
|
||||
// Missing title — still parsed, Title is empty string
|
||||
input := `[{"type":"concept","content":"## Definition\n\nFoo."}]`
|
||||
pages, warnings := ParseRawPages(input)
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Empty(t, pages[0].Title)
|
||||
}
|
||||
|
||||
func TestParseRawPages_InvalidEscapeRepaired(t *testing.T) {
|
||||
// LLM copied markdown escaped list numbers (\.) into JSON — invalid escape
|
||||
raw := "[{\"title\":\"Foo\",\"type\":\"concept\",\"content\":\"Step 4\\. Do it.\"}]"
|
||||
pages, warnings := ParseRawPages(raw)
|
||||
require.Len(t, pages, 1)
|
||||
assert.Equal(t, "Foo", pages[0].Title)
|
||||
assert.Contains(t, pages[0].Content, `4\.`)
|
||||
assert.NotEmpty(t, warnings) // repair warning
|
||||
}
|
||||
|
||||
func TestRepairJSON_FixesInvalidEscapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{`{"a":"foo\.bar"}`, `{"a":"foo\\.bar"}`},
|
||||
{`{"a":"\\n is fine"}`, `{"a":"\\n is fine"}`}, // valid \n untouched
|
||||
{`{"a":"\d+ items"}`, `{"a":"\\d+ items"}`},
|
||||
{`{"a":"already \\ escaped"}`, `{"a":"already \\ escaped"}`}, // valid \\ untouched
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := repairJSON(tc.in)
|
||||
assert.Equal(t, tc.want, got, "input: %s", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
schema = loadSchema(brainDir)
|
||||
}
|
||||
|
||||
sourceSlug := wiki.Slug(source)
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
chunks := Chunk(content, cfg.ChunkSize)
|
||||
|
||||
var allPages []wiki.Page
|
||||
var allRaw []RawPage
|
||||
var allWarnings []string
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -52,16 +54,40 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("LLM call: %w", err)
|
||||
}
|
||||
pages, warnings := ParsePages(output)
|
||||
allPages = append(allPages, pages...)
|
||||
raw, warnings := ParseRawPages(output)
|
||||
allRaw = append(allRaw, raw...)
|
||||
allWarnings = append(allWarnings, warnings...)
|
||||
}
|
||||
|
||||
merged := mergeAll(allPages)
|
||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||
}
|
||||
|
||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||
// step. Use this when the caller has already produced the structured RawPage data
|
||||
// (e.g. from a more capable model or manual curation).
|
||||
func RunRaw(brainDir, source string, rawPages []RawPage, dryRun bool) (Result, error) {
|
||||
inventory, err := wiki.LoadInventory(brainDir)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("load inventory: %w", err)
|
||||
}
|
||||
|
||||
sourceSlug := wiki.Slug(source)
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
var written []string
|
||||
|
||||
return buildAndWrite(rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
||||
}
|
||||
|
||||
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
||||
func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
||||
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
|
||||
warnings = append(warnings, buildWarnings...)
|
||||
resolved := Resolve(pages, inventory)
|
||||
canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory)
|
||||
warnings = append(warnings, linkWarnings...)
|
||||
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
||||
merged := mergeAll(withRefs)
|
||||
|
||||
var written []string
|
||||
for _, page := range merged {
|
||||
if !dryRun {
|
||||
dest := filepath.Join(brainDir, filepath.FromSlash(page.Path))
|
||||
@@ -77,14 +103,14 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
|
||||
if !dryRun {
|
||||
if err := wiki.RebuildIndex(brainDir, date); err != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("rebuild index: %v", err))
|
||||
warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err))
|
||||
}
|
||||
if err := wiki.AppendLog(brainDir, source, written, allWarnings, date); err != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("append log: %v", err))
|
||||
if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("append log: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return Result{Pages: written, Warnings: allWarnings}, nil
|
||||
return Result{Pages: written, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||
)
|
||||
|
||||
func TestRun_WritesPages(t *testing.T) {
|
||||
@@ -24,20 +23,25 @@ func TestRun_WritesPages(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
||||
}
|
||||
|
||||
llmResponse := mustJSON([]wiki.Page{
|
||||
llmResponse := mustJSON([]RawPage{
|
||||
{
|
||||
Path: "wiki/sources/test-article.md",
|
||||
Content: "---\ntitle: Test Article\ntype: article\ndomain: software-engineering\ndate_ingested: 2026-04-22\nlast_updated: 2026-04-22\naliases:\n - Test Article\n---\n\n## Summary\n\nA test article.\n\n## Key Claims\n\n- It tests things.\n\n## Concepts Introduced or Reinforced\n\n## Entities Mentioned\n\n## Open Questions Raised\n",
|
||||
Title: "Test Article",
|
||||
Type: "source",
|
||||
Subtype: "article",
|
||||
Domain: "software-engineering",
|
||||
Content: "## Summary\n\nA test article.\n\n## Key Claims\n\n- It tests things.\n\n## Concepts Introduced or Reinforced\n\n[[Testing]]\n\n## Entities Mentioned\n\n## Open Questions Raised\n",
|
||||
},
|
||||
{
|
||||
Path: "wiki/concepts/testing.md",
|
||||
Content: "---\ntitle: Testing\ndomain: software-engineering\nlast_updated: 2026-04-22\naliases:\n - Testing\n---\n\n## Definition\n\nThe practice of verifying software.\n\n## Why It Matters\n\nCatches bugs.\n\n## Related Concepts\n\n## Related Entities\n\n## Sources\n\n## Evolving Notes\n",
|
||||
Title: "Testing",
|
||||
Type: "concept",
|
||||
Domain: "software-engineering",
|
||||
Content: "## Definition\n\nThe practice of verifying software.\n\n## Why It Matters\n\nCatches bugs.\n\n## Related Concepts\n\n## Related Entities\n\n## Sources\n\n## Evolving Notes\n",
|
||||
},
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"role": "assistant", "content": llmResponse}},
|
||||
},
|
||||
@@ -53,7 +57,6 @@ func TestRun_WritesPages(t *testing.T) {
|
||||
result, err := Run(context.Background(), cfg, brainDir, "An article about testing.", "test-article", false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Pages, 2)
|
||||
assert.Empty(t, result.Warnings)
|
||||
|
||||
_, err = os.Stat(filepath.Join(brainDir, "wiki", "sources", "test-article.md"))
|
||||
require.NoError(t, err)
|
||||
@@ -71,13 +74,15 @@ func TestRun_DryRunDoesNotWrite(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
||||
}
|
||||
|
||||
llmResponse := mustJSON([]wiki.Page{{
|
||||
Path: "wiki/sources/foo.md",
|
||||
Content: "---\ntitle: Foo\n---\n\n## Summary\n\nFoo.\n",
|
||||
llmResponse := mustJSON([]RawPage{{
|
||||
Title: "Foo",
|
||||
Type: "source",
|
||||
Subtype: "article",
|
||||
Content: "## Summary\n\nFoo.\n",
|
||||
}})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
||||
})
|
||||
}))
|
||||
@@ -98,14 +103,14 @@ func TestRun_MergesDuplicatePaths(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
||||
}
|
||||
|
||||
// LLM returns same path twice (simulates multi-chunk merge)
|
||||
llmResponse := mustJSON([]wiki.Page{
|
||||
{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nFirst.\n\n## Related Concepts\n\n- [[bar|Bar]]\n"},
|
||||
{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nSecond.\n\n## Related Concepts\n\n- [[baz|Baz]]\n"},
|
||||
// LLM returns same title twice (simulates multi-chunk duplicate)
|
||||
llmResponse := mustJSON([]RawPage{
|
||||
{Title: "Foo", Type: "concept", Content: "## Definition\n\nFirst.\n\n## Related Concepts\n\n[[Bar]]\n"},
|
||||
{Title: "Foo", Type: "concept", Content: "## Definition\n\nSecond.\n\n## Related Concepts\n\n[[Baz]]\n"},
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
||||
})
|
||||
}))
|
||||
@@ -120,8 +125,9 @@ func TestRun_MergesDuplicatePaths(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
// keep-first for Definition, union for Related Concepts
|
||||
assert.Contains(t, string(content), "First.")
|
||||
assert.Contains(t, string(content), "[[bar|Bar]]")
|
||||
assert.Contains(t, string(content), "[[baz|Baz]]")
|
||||
// Bar and Baz unknown in empty inventory → left as plain [[links]]
|
||||
assert.Contains(t, string(content), "[[Bar]]")
|
||||
assert.Contains(t, string(content), "[[Baz]]")
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
|
||||
@@ -12,12 +12,15 @@ import (
|
||||
const systemPrompt = `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 before or after.
|
||||
Each element must have:
|
||||
"path" — relative path within the wiki, e.g. "wiki/sources/foo.md"
|
||||
"content" — full markdown content of the page including YAML frontmatter
|
||||
Each element must have exactly these fields:
|
||||
"title" — exact page title (e.g. "FinBERT", "Ryan Singer", "Shape Up")
|
||||
"type" — exactly one of: "source", "concept", "entity"
|
||||
"subtype" — for source: article|pdf|book|video|note|project; for entity: person|company|tool|model|framework|technology; omit for concept
|
||||
"domain" — one of the domains in the schema (omit if none fits)
|
||||
"content" — Markdown body only — NO frontmatter, NO path, NO slug
|
||||
|
||||
Follow the schema strictly: correct frontmatter fields, wikilinks as [[slug|Display Text]],
|
||||
dates in YYYY-MM-DD format, and paraphrase rather than quoting verbatim.`
|
||||
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
|
||||
Only link to pages listed in the inventory or pages you are creating in this response.`
|
||||
|
||||
// BuildPrompt constructs the user prompt for a single chunk.
|
||||
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry) string {
|
||||
@@ -30,7 +33,7 @@ func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]w
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("## Existing wiki pages\n\n")
|
||||
sb.WriteString("Link ONLY to pages in this inventory or pages you are creating in this response.\n\n")
|
||||
sb.WriteString("Reference these pages by display name only — [[Display Name]] — in your content.\n\n")
|
||||
|
||||
for _, pt := range []wiki.PageType{wiki.PageTypeConcept, wiki.PageTypeEntity, wiki.PageTypeSource} {
|
||||
entries := inventory[pt]
|
||||
@@ -39,19 +42,19 @@ func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]w
|
||||
fmt.Fprintf(&sb, "%s — (none yet)\n\n", label)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s — link ONLY under the matching section:\n", label)
|
||||
fmt.Fprintf(&sb, "%s:\n", label)
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&sb, " - [[%s|%s]]\n", e.Slug, e.Title)
|
||||
fmt.Fprintf(&sb, " - %s\n", e.Title)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("## Non-negotiable rules\n\n")
|
||||
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
|
||||
sb.WriteString("2. Slugs are kebab-case: lowercase, spaces→hyphens, no special chars.\n")
|
||||
sb.WriteString("3. Wikilinks: [[slug|Display Text]] — the pipe is required.\n")
|
||||
sb.WriteString("4. Section links must match their section type.\n")
|
||||
sb.WriteString("5. One source page per book — update it if inventory shows it exists.\n\n")
|
||||
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
|
||||
sb.WriteString("3. Wikilinks: [[Display Name]] — no slug, no pipe. The pipeline handles slugs.\n")
|
||||
sb.WriteString("4. Section links must match their section type (Related Concepts → concepts only, etc.).\n")
|
||||
sb.WriteString("5. One source page per book — if inventory shows it exists, return it as an UPDATE.\n\n")
|
||||
|
||||
fmt.Fprintf(&sb, "## Source: %s\n\n", source)
|
||||
sb.WriteString(content)
|
||||
|
||||
115
ingestion/internal/pipeline/refs.go
Normal file
115
ingestion/internal/pipeline/refs.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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 + "]]"
|
||||
|
||||
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
|
||||
}
|
||||
if idx, ok := bySlug[slug]; ok {
|
||||
pages[idx] = addSourceRef(pages[idx], sourceRef)
|
||||
continue
|
||||
}
|
||||
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 := 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
|
||||
// using wiki.Merge, which deduplicates bullets automatically.
|
||||
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
|
||||
}
|
||||
172
ingestion/internal/pipeline/refs_test.go
Normal file
172
ingestion/internal/pipeline/refs_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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]]")
|
||||
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())
|
||||
assert.Len(t, got, 1)
|
||||
}
|
||||
|
||||
func TestInjectSourceRefs_DeduplicatesOnReingestion(t *testing.T) {
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
if line := s[start:i]; line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if last := s[start:]; last != "" {
|
||||
out = append(out, last)
|
||||
}
|
||||
return out
|
||||
}
|
||||
88
ingestion/internal/pipeline/resolve.go
Normal file
88
ingestion/internal/pipeline/resolve.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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 {
|
||||
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 {
|
||||
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))
|
||||
for _, article := range []string{"the ", "a ", "an "} {
|
||||
s = strings.TrimPrefix(s, article)
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
90
ingestion/internal/pipeline/resolve_test.go
Normal file
90
ingestion/internal/pipeline/resolve_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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 := []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 := []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) {
|
||||
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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
119
ingestion/internal/reranker/reranker.go
Normal file
119
ingestion/internal/reranker/reranker.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package reranker scores (query, document) pairs against a cross-encoder
|
||||
// served by an Ollama-compatible backend.
|
||||
//
|
||||
// Wire format is Ollama's `/api/generate`. The model is prompted with the
|
||||
// Qwen3-Reranker yes/no template — the canonical interface the model
|
||||
// itself was trained against — and the first token of the response is
|
||||
// treated as a binary relevance vote: "yes" → 1.0, anything else → 0.0.
|
||||
// Ties are expected to be broken by the caller's primary retrieval score
|
||||
// (e.g. BM25), so the binary signal is a filter rather than a ranking
|
||||
// substitute.
|
||||
package reranker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client posts rerank requests to an Ollama-compatible endpoint.
|
||||
type Client struct {
|
||||
URL string
|
||||
Model string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New constructs a Client. Returns nil when url is empty so callers can
|
||||
// treat a missing BRAIN_RERANKER_URL as "feature disabled" with a single
|
||||
// nil check.
|
||||
func New(url, model string) *Client {
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
URL: strings.TrimRight(url, "/"),
|
||||
Model: model,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Score returns one [0, 1] relevance score per input document, parallel
|
||||
// to the input order. Each (query, doc) pair is scored independently —
|
||||
// Qwen3-Reranker is a cross-encoder and expects per-pair calls.
|
||||
func (c *Client) Score(ctx context.Context, query string, docs []string) ([]float64, error) {
|
||||
out := make([]float64, len(docs))
|
||||
for i, doc := range docs {
|
||||
s, err := c.scoreOne(ctx, query, doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rerank doc %d: %w", i, err)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) scoreOne(ctx context.Context, query, doc string) (float64, error) {
|
||||
prompt := buildPrompt(query, doc)
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"model": c.Model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"options": map[string]any{
|
||||
"num_predict": 4,
|
||||
"temperature": 0,
|
||||
},
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.URL+"/api/generate", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return 0, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parseYesNo(out.Response), nil
|
||||
}
|
||||
|
||||
// buildPrompt assembles the Qwen3-Reranker chat template. Kept verbatim
|
||||
// because the model was trained on this exact wording.
|
||||
func buildPrompt(query, doc string) string {
|
||||
return "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n" +
|
||||
"<|im_start|>user\n<Instruct>: Given a web search query, retrieve relevant passages that answer the query\n" +
|
||||
"<Query>: " + query + "\n" +
|
||||
"<Document>: " + doc + "<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n\n</think>\n\n"
|
||||
}
|
||||
|
||||
// parseYesNo extracts the first meaningful token from response and
|
||||
// returns 1.0 when it starts with "yes" (case-insensitive), 0.0 otherwise.
|
||||
// Any leading whitespace, `<think>` block, or punctuation is skipped.
|
||||
func parseYesNo(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
// Strip any `<think>…</think>` block the model may emit even with empty thinking.
|
||||
if idx := strings.Index(s, "</think>"); idx != -1 {
|
||||
s = strings.TrimSpace(s[idx+len("</think>"):])
|
||||
}
|
||||
s = strings.ToLower(s)
|
||||
if strings.HasPrefix(s, "yes") {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
119
ingestion/internal/reranker/reranker_test.go
Normal file
119
ingestion/internal/reranker/reranker_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package reranker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeOllama responds to /api/generate based on a per-document
|
||||
// {needle → answer} map: if the prompt contains the needle, returns
|
||||
// the mapped answer.
|
||||
type fakeOllama struct {
|
||||
t *testing.T
|
||||
answers map[string]string // needle → "yes" or "no"
|
||||
calls int
|
||||
lastBody map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeOllama) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(f.t, http.MethodPost, r.Method)
|
||||
require.Equal(f.t, "/api/generate", r.URL.Path)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(f.t, err)
|
||||
var p map[string]any
|
||||
require.NoError(f.t, json.Unmarshal(body, &p))
|
||||
f.calls++
|
||||
f.lastBody = p
|
||||
prompt := p["prompt"].(string)
|
||||
answer := "no"
|
||||
for needle, a := range f.answers {
|
||||
if strings.Contains(prompt, needle) {
|
||||
answer = a
|
||||
break
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"model": p["model"], "response": answer, "done": true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestNew_EmptyURLReturnsNil(t *testing.T) {
|
||||
assert.Nil(t, reranker.New("", "model"))
|
||||
}
|
||||
|
||||
func TestScore_YesAndNoOrdered(t *testing.T) {
|
||||
f := &fakeOllama{t: t, answers: map[string]string{
|
||||
"alpha doc": "yes",
|
||||
"beta doc": "no",
|
||||
"gamma doc": "yes",
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
c := reranker.New(srv.URL, "test-model")
|
||||
require.NotNil(t, c)
|
||||
|
||||
scores, err := c.Score(context.Background(), "what is alpha",
|
||||
[]string{"alpha doc body", "beta doc body", "gamma doc body"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scores, 3)
|
||||
assert.Equal(t, 1.0, scores[0])
|
||||
assert.Equal(t, 0.0, scores[1])
|
||||
assert.Equal(t, 1.0, scores[2])
|
||||
assert.Equal(t, 3, f.calls)
|
||||
}
|
||||
|
||||
func TestScore_SendsCorrectShape(t *testing.T) {
|
||||
f := &fakeOllama{t: t, answers: map[string]string{"hello": "yes"}}
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
c := reranker.New(srv.URL, "qwen3-rerank")
|
||||
_, err := c.Score(context.Background(), "greeting", []string{"hello world"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3-rerank", f.lastBody["model"])
|
||||
prompt := f.lastBody["prompt"].(string)
|
||||
assert.Contains(t, prompt, "greeting")
|
||||
assert.Contains(t, prompt, "hello world")
|
||||
assert.Contains(t, prompt, `"yes" or "no"`)
|
||||
}
|
||||
|
||||
func TestScore_HandlesAmbiguousResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": "maybe — unclear", "done": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := reranker.New(srv.URL, "m")
|
||||
scores, err := c.Score(context.Background(), "q", []string{"d"})
|
||||
require.NoError(t, err)
|
||||
// Anything that does not start with "yes" (case-insensitive, after
|
||||
// whitespace/think trim) is treated as "no" = 0.
|
||||
assert.Equal(t, []float64{0}, scores)
|
||||
}
|
||||
|
||||
func TestScore_EmptyDocsReturnsEmpty(t *testing.T) {
|
||||
c := reranker.New("http://127.0.0.1:1", "m")
|
||||
scores, err := c.Score(context.Background(), "q", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, scores)
|
||||
}
|
||||
|
||||
func TestScore_UpstreamErrorPropagates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := reranker.New(srv.URL, "m")
|
||||
_, err := c.Score(context.Background(), "q", []string{"d"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user