Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98cfae595c | ||
|
|
2a595b5a92 | ||
|
|
b7a2cc5fdf | ||
|
|
db638cca11 | ||
|
|
38579598e0 | ||
|
|
d6fa92b176 | ||
|
|
9173f9058d | ||
|
|
3e84a41fed | ||
|
|
0e0571c7da | ||
|
|
7a27cf71a2 | ||
|
|
63df6d3283 | ||
|
|
f04b03e07e | ||
|
|
6c61f93146 | ||
|
|
95a69fc2c1 | ||
|
|
bb8bc0478c | ||
|
|
a961a3c064 | ||
|
|
bec28f9014 | ||
|
|
b62ac57382 | ||
|
|
aa918388b9 | ||
|
|
e8dbcf6eef | ||
|
|
0eeb1df4a2 | ||
|
|
9febb1bba1 | ||
|
|
5dc247b994 | ||
|
|
2125558196 | ||
|
|
2beaac2feb | ||
|
|
525811bc1a | ||
|
|
bad0581623 | ||
|
|
a94b860c2e | ||
|
|
f8cf27e5de | ||
|
|
49b188e9c9 | ||
|
|
bc011cc1f0 | ||
|
|
2726896079 | ||
|
|
2b7bbe38c7 | ||
|
|
1b00cbc0ae | ||
|
|
4f78fecd06 | ||
|
|
d5f112b600 | ||
|
|
ea9518e712 | ||
|
|
e34cd6c12b | ||
|
|
3084c4173d | ||
|
|
72be87b4e7 | ||
|
|
153ef6ccac | ||
|
|
2148565ee6 | ||
|
|
f43e0bccbf | ||
|
|
f53ee18cb6 | ||
|
|
c153e9105c | ||
|
|
ce96a6a571 | ||
|
|
ca22df2d6a | ||
|
|
e49b36e463 | ||
|
|
815739758e | ||
|
|
6f1cb53295 | ||
|
|
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 |
+22
-5
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
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.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## 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
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## 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://git.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 at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
---
|
||||
+107
-34
@@ -1,6 +1,6 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
"on":
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
@@ -11,37 +11,16 @@ jobs:
|
||||
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
|
||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
||||
INFRA_REPO: git@git.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
|
||||
@@ -62,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
|
||||
@@ -70,24 +71,96 @@ jobs:
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||
chmod 600 ~/.ssh/infra_deploy_key
|
||||
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||
|
||||
cd /tmp/infra-update
|
||||
|
||||
sed -i "s|gitea.d-ma.be/mathias/supervisor:.*|gitea.d-ma.be/mathias/supervisor:${IMAGE_TAG}|" \
|
||||
"k3s/apps/${SERVICE}/deployment.yaml"
|
||||
sed -i "s|git.d-ma.be/mathias/ingestion:.*|git.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||
"k3s/apps/supervisor/ingestion-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"
|
||||
sed -i "s|git.d-ma.be/mathias/routing:.*|git.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="git.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="git.d-ma.be/mathias/routing:${{ github.sha }}"
|
||||
for i in $(seq 1 60); do
|
||||
CURRENT=$(kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||
echo "✓ Flux applied routing image after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||
| grep -qx "$EXPECTED" \
|
||||
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
||||
|
||||
- name: Verify routing rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/routing \
|
||||
--namespace routing \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
echo "── pod status ──"
|
||||
kubectl get pods -n routing -o wide
|
||||
echo "── events ──"
|
||||
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
||||
echo "── describe ──"
|
||||
kubectl describe pods -n routing -l app=routing | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
"on":
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# 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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
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.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## 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
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## 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://git.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 at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
@@ -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
|
||||
+161
@@ -4,6 +4,74 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — three active harnesses: hyperguild, agentsquad, Crush (extends earlier boundary decision)
|
||||
|
||||
**Context:** After wiring Crush to LiteLLM in May 2026, there are now three active harnesses.
|
||||
The earlier boundary decision only covered hyperguild vs agentsquad. Crush's role was undefined.
|
||||
|
||||
**Decision:** Three harnesses, three distinct roles, shared skills layer.
|
||||
|
||||
| Harness | Engine | Primary use | Brain MCP? | Routing pod? | Skills? |
|
||||
|---------|--------|-------------|------------|--------------|---------|
|
||||
| **hyperguild** | Claude Code + MCP | Disciplined solo coding sessions, TDD/review/debug workflows | Yes | Yes | Yes (SKILL.md) |
|
||||
| **agentsquad** | OpenCode + LiteLLM | Multi-agent task execution, executor/reviewer pipelines | No | No (own routing) | Yes (SKILL.md) |
|
||||
| **Crush** | Charmbracelet TUI + LiteLLM | Interactive local coding, quick iterations on flamingo | No (not yet) | No (direct LiteLLM) | Yes (SKILL.md) |
|
||||
|
||||
**Crush specifics (as of 2026-05-28):**
|
||||
- Config: `~/.config/crush/crush.json` on flamingo (see brain: `homelab/facts/crush-litellm-wiring-2026-05`)
|
||||
- Connects directly to LiteLLM at `http://koala:4000/v1/` using `sk-local-123`
|
||||
- Auth type: `openai-compat` (not `openai`)
|
||||
- Does NOT go through the routing pod — model selection is manual in the Crush UI
|
||||
- Brain MCP not wired — Crush has no MCP client capability today; revisit if Crush adds MCP support
|
||||
|
||||
**Shared across all three:**
|
||||
- `mathias/skills` — any SKILL.md file works in all three harnesses
|
||||
- LiteLLM proxy on koala (`http://koala:4000/v1/`) — Crush and agentsquad both route through it; hyperguild does too for local model calls
|
||||
|
||||
**Consequences:** No consolidation needed. crush.json must be kept in sync when litellm_config.yaml model names change. The `crush.json` canonical location is `~/.config/crush/crush.json` on flamingo — not yet tracked in a dotfiles repo (track as tech debt).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — "field benchmark" for local models = pass-rate at scale (supersedes GOTTH eval suite)
|
||||
|
||||
**Context:** The GOTTH eval suite (45 offline prompts across 5 categories) was replaced by
|
||||
a "field benchmark" in May 2026, but the replacement was never defined concretely.
|
||||
|
||||
**Decision:** The field benchmark is per-skill pass rate over real routing pod usage,
|
||||
collected automatically by `internal/routing/passrate.go` and exposed at:
|
||||
|
||||
```
|
||||
GET /pass-rate?skill=<name>&window=<duration>
|
||||
```
|
||||
|
||||
No separate eval suite. No synthetic prompts. The benchmark runs itself once the routing
|
||||
pod receives real traffic. Target: 30-day rolling window per skill, reviewed monthly.
|
||||
|
||||
**Bootstrap note:** With no session history, `passrate.go` returns `nil` and the router
|
||||
defaults to the thinking model for every call. The fast-model path activates only after
|
||||
real pass-rate data accumulates. Seed with real usage — do not pre-populate.
|
||||
|
||||
**Consequences:** Zero maintenance overhead for the benchmark. The tradeoff is that results
|
||||
are only meaningful after ~2 weeks of real usage, and skills that are rarely invoked will
|
||||
have statistically thin pass-rate data. Revisit if a skill has fewer than 20 calls in 30 days.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — brain injection in skill handlers: review is done, others unverified
|
||||
|
||||
**Context:** The April 2026 scope reset listed "brain_query injection into skill handlers"
|
||||
as the top priority. As of 2026-05-28, `internal/skills/review/handlers.go` calls
|
||||
`brain.Query(ctx, ...)` before dispatching to the LLM — confirmed in code review.
|
||||
Status of debug, retrospective, and trainer handlers is unverified.
|
||||
|
||||
**Decision:** Treat review as the reference implementation. Verify debug, retrospective,
|
||||
trainer against the same pattern before shipping new skill work. Tracked in issue #32.
|
||||
|
||||
**Consequences:** The April concern may be stale for review. A one-pass audit of the other
|
||||
three skill handlers closes this fully.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-08 — AGENTS.md as cross-tool standard, not CLAUDE.md
|
||||
|
||||
**Context**: Multiple tools (Crush, Pi, Antigravity) read `AGENTS.md` natively. Claude Code reads `CLAUDE.md`. Building on `CLAUDE.md` as the primary format locks into one vendor.
|
||||
@@ -67,6 +135,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 +186,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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -5,47 +5,60 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||
into a searchable brain.
|
||||
|
||||
## Hypothesis
|
||||
|
||||
> We believe routing skill tasks through local models, backed by brain context,
|
||||
> produces measurably better outcomes than raw Claude Code alone —
|
||||
> measurable by per-skill pass rate over rolling 30-day windows
|
||||
> (available at `GET /pass-rate?skill=<name>&window=30d` on the brain pod).
|
||||
|
||||
This is the falsifiable claim the routing pod and pass-rate infrastructure exist to test.
|
||||
If per-skill pass rates don't improve over baseline (all-cloud) after 30 days of real
|
||||
usage, the fast-model routing path should be reconsidered.
|
||||
|
||||
## Harness
|
||||
|
||||
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||
orchestration model. Skills (mathias/skills) are shared between both.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
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)
|
||||
├──▶ routing :3210 (NodePort 30310 on koala) — 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/
|
||||
├── sessions/ — JSONL log, one file per session_id
|
||||
├── wiki/ — searchable knowledge (full-text)
|
||||
│ ├── concepts/
|
||||
│ ├── entities/
|
||||
│ └── sources/
|
||||
├── raw/ — retrospective output, staged for review
|
||||
└── training-data/ — SFT/DPO/RL data (Phase 2)
|
||||
├── wiki/ — searchable knowledge (wing/hall layout)
|
||||
│ ├── homelab/
|
||||
│ ├── claude-sessions/
|
||||
│ └── ...
|
||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||
```
|
||||
|
||||
## Phase 1 tools (available now)
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `tdd_red` | Writes a failing test for a spec, verifies it fails |
|
||||
| `tdd_green` | Writes the minimal implementation to make tests pass |
|
||||
| `tdd_refactor` | Cleans up implementation while keeping tests green |
|
||||
| `session_log` | Appends a structured entry to the session JSONL log |
|
||||
| `retrospective` | Reads the session log, identifies novel learnings, writes to brain/raw/ |
|
||||
| `retrospective` | Reads the session log, identifies novel learnings, writes to brain |
|
||||
| `review` | Structured code review via local model, brain-context injected |
|
||||
| `debug` | Hypothesis-driven debugging via local model |
|
||||
| `brain_query` | Full-text search over brain/wiki/ |
|
||||
| `brain_write` | Writes a note to brain/raw/ (with optional YAML frontmatter) |
|
||||
| `brain_write` | Writes a note to brain (with wing/hall routing) |
|
||||
| `brain_answer` | BM25 + LLM synthesis — Q&A over brain corpus |
|
||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||
|
||||
## Start the servers
|
||||
|
||||
```bash
|
||||
# Requires goreman: go install github.com/mattn/goreman@latest
|
||||
task start # starts ingestion (:3300) + supervisor (:3200) via goreman
|
||||
task stop # kills both by port
|
||||
```
|
||||
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
|
||||
|
||||
## Connect a project
|
||||
|
||||
@@ -54,35 +67,41 @@ Create `.mcp.json` in your project root:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supervisor": {
|
||||
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
|
||||
"env": {
|
||||
"SUPERVISOR_URL": "http://localhost:3200/mcp"
|
||||
}
|
||||
"routing": {
|
||||
"type": "http",
|
||||
"url": "http://koala:30310/mcp"
|
||||
},
|
||||
"brain": {
|
||||
"type": "http",
|
||||
"url": "http://koala:30330/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Build the bridge binary once: `task bridge:build`
|
||||
Two MCP servers are exposed, both reachable over Tailscale:
|
||||
|
||||
Then open Claude Code in your project — run `/mcp` to confirm `supervisor` is listed.
|
||||
- **`routing`** at `koala:30310` — skill workers (`review`, `debug`, `retrospective`, `trainer`).
|
||||
Routes each call to fast local model or thinking model based on per-skill pass rate.
|
||||
- **`brain`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
|
||||
`brain_ingest`, `brain_ingest_raw`, `brain_answer`, `brain_classify`) and `session_log`.
|
||||
|
||||
## A typical TDD session
|
||||
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 session
|
||||
|
||||
```
|
||||
1. Call tdd_red → spec in, failing test file out
|
||||
2. Call tdd_green → test path in, implementation out
|
||||
3. Call tdd_refactor → impl + test in, cleaned code out
|
||||
4. Call session_log → log each phase result
|
||||
5. Call retrospective → extracts learnings → brain/raw/
|
||||
6. Review brain/raw/, move worthy notes to brain/wiki/concepts/
|
||||
7. Future sessions: call brain_query to retrieve relevant context
|
||||
1. Call review → brain context injected + local model review → findings
|
||||
2. Call session_log → log each phase result
|
||||
3. Call retrospective → extracts learnings → brain
|
||||
4. Future sessions: call brain_query / brain_answer to retrieve relevant context
|
||||
```
|
||||
|
||||
## Tier detection
|
||||
|
||||
The supervisor probes connectivity at call time:
|
||||
The routing pod probes connectivity at call time:
|
||||
|
||||
| Tier | Label | Condition |
|
||||
|------|-------|-----------|
|
||||
@@ -90,20 +109,47 @@ The supervisor probes connectivity at call time:
|
||||
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
||||
| 3 | airplane | No external connectivity |
|
||||
|
||||
## Model routing
|
||||
|
||||
The routing pod selects models per skill call based on historical pass rate:
|
||||
|
||||
| Pass rate | Decision |
|
||||
|-----------|----------|
|
||||
| ≥ 0.90 (FLOOR) | Fast model (`HYPERGUILD_FAST_MODEL`) |
|
||||
| ≤ 0.70 (CEIL) | Thinking model (`HYPERGUILD_THINKING_MODEL`) |
|
||||
| between CEIL and FLOOR | Sample band — probabilistic routing |
|
||||
| nil (no history yet) | Defaults to thinking model |
|
||||
|
||||
> **Bootstrap note:** With no session history, all calls route to the thinking model.
|
||||
> The fast-model path activates only after real pass-rate data accumulates at `/pass-rate`.
|
||||
> Seed with real usage — don't try to pre-populate.
|
||||
|
||||
## Key env vars
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
||||
| `INGEST_PORT` | `3300` | Ingestion server port |
|
||||
| `SUPERVISOR_CONFIG_DIR` | `./config/supervisor` | Skill discipline files |
|
||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Routing pod → brain |
|
||||
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
||||
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
|
||||
| `ROUTING_MCP_TOKEN` | — | Optional bearer token; when empty, no auth enforced |
|
||||
| `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` | Fast model threshold |
|
||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Thinking model threshold |
|
||||
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
|
||||
|
||||
## Phase 2 (planned)
|
||||
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL`
|
||||
> and `HYPERGUILD_THINKING_MODEL`. If a model is missing, the fail-open retry also fails and
|
||||
> the only signal is `final_status: "fail"` on `_routing` entries in the brain.
|
||||
|
||||
- `review` skill — structured code review with iron law enforcement
|
||||
- `debug` skill — hypothesis-driven debugging sessions
|
||||
- `spec` skill — generates specs from conversations
|
||||
- `trainer` — extracts SFT/DPO pairs from session logs for fine-tuning
|
||||
## Open issues
|
||||
|
||||
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||
|
||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||
- **#24** — reduce context burn from skill listing
|
||||
- **#22** — migrate legacy brain notes to wing/hall layout (one-shot script, low risk)
|
||||
- **#31** — connect routing-mcp to claude.ai as custom connector
|
||||
|
||||
+34
-12
@@ -12,16 +12,11 @@ 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]
|
||||
context:sync:agents:
|
||||
cmds: [bash scripts/context-sync.sh agents]
|
||||
context:sync:cursor:
|
||||
cmds: [bash scripts/context-sync.sh cursor]
|
||||
|
||||
# ── Development ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,6 +37,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 +68,6 @@ tasks:
|
||||
desc: Build all binaries
|
||||
cmds:
|
||||
- task: supervisor:build
|
||||
- task: bridge:build
|
||||
- task: ingestion:build
|
||||
|
||||
supervisor:build:
|
||||
@@ -65,11 +75,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 +84,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 .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 .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 +126,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:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# baseline-pre-fix — 20 questions, k=5
|
||||
|
||||
top-1 hit rate: 4/20 = 20%
|
||||
top-3 hit rate: 13/20 = 65%
|
||||
|
||||
## per-question detail
|
||||
|
||||
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
q: how do I stop dex from logging users out on every pod restart?
|
||||
1. homelab-network-perimeter-model
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
|
||||
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. brain-mcp-activation-runbook
|
||||
4. extension-version-lags-platform-major-upgrade
|
||||
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||
|
||||
★ rank=1 expected=homelab-network-perimeter-model
|
||||
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||
1. homelab-network-perimeter-model <-- expected
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||
q: what does container exit code 255 with reason Unknown mean?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. exit-255-unknown-reason-not-oom <-- expected
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
q: can gitea push-mirror create the github repo automatically?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. Autoresearch
|
||||
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||
4. adr-new-project-gitea-first-github-mirror
|
||||
5. adr-github-as-primary-remote
|
||||
|
||||
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||
q: a flux kustomization is stuck after I removed a resource — why?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. homelab-architecture-principles-2026-05
|
||||
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||
3. homelab-security-chains-not-bugs
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. Hash Encoding
|
||||
|
||||
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||
q: what are the homelab architecture principles from may 2026?
|
||||
1. homelab-architecture-principles-2026-05 <-- expected
|
||||
2. homelab-network-perimeter-model
|
||||
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||
4. homelab-core-glossary
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||
q: where does the sops age private key live in the cluster?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-network-perimeter-model
|
||||
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
4. brain-mcp-activation-runbook
|
||||
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
|
||||
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
|
||||
q: why do my grafana dashboards disappear after a pod restart?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. brain-mcp-activation-runbook
|
||||
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
|
||||
· rank=2 expected=double-diamond-methodology
|
||||
q: what is the double diamond methodology?
|
||||
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
|
||||
2. double-diamond-methodology <-- expected
|
||||
3. unified-methodology-diamond-futures-autoresearch
|
||||
4. futures-thinking-extended-double-diamond
|
||||
5. insight-exploration-as-diamond-1
|
||||
|
||||
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. finding-github-mcp-claudeai-vs-claudecode
|
||||
|
||||
· rank=2 expected=homelab-security-chains-not-bugs
|
||||
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||
1. homelab-network-perimeter-model
|
||||
2. homelab-security-chains-not-bugs <-- expected
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. policy-audit-mode-blocks-nothing
|
||||
5. homelab-document-accepted-risk-to-break-audit-cycle
|
||||
|
||||
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||
q: how should canonical context files relate to derived adapter files?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=homelab-core-glossary
|
||||
q: what is the homelab core vocabulary glossary?
|
||||
1. homelab-architecture-principles-2026-05
|
||||
2. homelab-core-glossary <-- expected
|
||||
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. Autoresearch
|
||||
|
||||
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. infra-litellm-absorption-2026-05-16
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. qwen3-thinking-model-empty-content-trap
|
||||
|
||||
✗ rank=0 expected=qwen35-9b-fast
|
||||
q: what is qwen35-9b-fast and what's it used for?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. Qwen35-9b-fast
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. homelab-network-perimeter-model
|
||||
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. brain-mcp-activation-runbook
|
||||
|
||||
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-core-glossary
|
||||
3. brain-mcp-activation-runbook
|
||||
4. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
5. infra-litellm-absorption-2026-05-16
|
||||
|
||||
? rank=4 expected=adr-new-project-gitea-first-github-mirror
|
||||
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||
3. mcp-tool-design-get-needs-list-partner
|
||||
4. adr-new-project-gitea-first-github-mirror <-- expected
|
||||
5. 2026-05-04-gitea-mcp-build-session
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# post-fix — 20 questions, k=5
|
||||
|
||||
top-1 hit rate: 4/20 = 20%
|
||||
top-3 hit rate: 14/20 = 70%
|
||||
|
||||
## per-question detail
|
||||
|
||||
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
q: how do I stop dex from logging users out on every pod restart?
|
||||
1. homelab-network-perimeter-model
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
|
||||
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. brain-mcp-activation-runbook
|
||||
4. extension-version-lags-platform-major-upgrade
|
||||
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||
|
||||
★ rank=1 expected=homelab-network-perimeter-model
|
||||
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||
1. homelab-network-perimeter-model <-- expected
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||
q: what does container exit code 255 with reason Unknown mean?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. exit-255-unknown-reason-not-oom <-- expected
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
q: can gitea push-mirror create the github repo automatically?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. Autoresearch
|
||||
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||
4. adr-new-project-gitea-first-github-mirror
|
||||
5. adr-github-as-primary-remote
|
||||
|
||||
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||
q: a flux kustomization is stuck after I removed a resource — why?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. homelab-architecture-principles-2026-05
|
||||
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||
3. homelab-security-chains-not-bugs
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. Hash Encoding
|
||||
|
||||
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||
q: what are the homelab architecture principles from may 2026?
|
||||
1. homelab-architecture-principles-2026-05 <-- expected
|
||||
2. homelab-network-perimeter-model
|
||||
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||
4. homelab-core-glossary
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||
q: where does the sops age private key live in the cluster?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-network-perimeter-model
|
||||
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
4. brain-mcp-activation-runbook
|
||||
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
|
||||
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
|
||||
q: why do my grafana dashboards disappear after a pod restart?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. brain-mcp-activation-runbook
|
||||
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
|
||||
· rank=2 expected=double-diamond-methodology
|
||||
q: what is the double diamond methodology?
|
||||
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
|
||||
2. double-diamond-methodology <-- expected
|
||||
3. unified-methodology-diamond-futures-autoresearch
|
||||
4. futures-thinking-extended-double-diamond
|
||||
5. insight-exploration-as-diamond-1
|
||||
|
||||
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. finding-github-mcp-claudeai-vs-claudecode
|
||||
|
||||
· rank=2 expected=homelab-security-chains-not-bugs
|
||||
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||
1. homelab-network-perimeter-model
|
||||
2. homelab-security-chains-not-bugs <-- expected
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. policy-audit-mode-blocks-nothing
|
||||
5. homelab-document-accepted-risk-to-break-audit-cycle
|
||||
|
||||
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||
q: how should canonical context files relate to derived adapter files?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=homelab-core-glossary
|
||||
q: what is the homelab core vocabulary glossary?
|
||||
1. homelab-architecture-principles-2026-05
|
||||
2. homelab-core-glossary <-- expected
|
||||
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. Autoresearch
|
||||
|
||||
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. infra-litellm-absorption-2026-05-16
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. qwen3-thinking-model-empty-content-trap
|
||||
|
||||
· rank=2 expected=qwen35-9b-fast
|
||||
q: what is qwen35-9b-fast and what's it used for?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
2. qwen35-9b-fast <-- expected
|
||||
3. qwen3-thinking-model-empty-content-trap
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. homelab-network-perimeter-model
|
||||
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. brain-mcp-activation-runbook
|
||||
|
||||
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-core-glossary
|
||||
3. brain-mcp-activation-runbook
|
||||
4. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
5. infra-litellm-absorption-2026-05-16
|
||||
|
||||
? rank=4 expected=adr-new-project-gitea-first-github-mirror
|
||||
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||
3. mcp-tool-design-get-needs-list-partner
|
||||
4. adr-new-project-gitea-first-github-mirror <-- expected
|
||||
5. 2026-05-04-gitea-mcp-build-session
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# post-m4-tier-weighting — 20 questions, k=5
|
||||
|
||||
top-1 hit rate: 6/20 = 30%
|
||||
top-3 hit rate: 15/20 = 75%
|
||||
|
||||
## per-question detail
|
||||
|
||||
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
q: how do I stop dex from logging users out on every pod restart?
|
||||
1. homelab-network-perimeter-model
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||
3. extension-version-lags-platform-major-upgrade
|
||||
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
|
||||
★ rank=1 expected=homelab-network-perimeter-model
|
||||
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||
1. homelab-network-perimeter-model <-- expected
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||
q: what does container exit code 255 with reason Unknown mean?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. exit-255-unknown-reason-not-oom <-- expected
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
q: can gitea push-mirror create the github repo automatically?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||
3. adr-new-project-gitea-first-github-mirror
|
||||
4. adr-github-as-primary-remote
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||
q: a flux kustomization is stuck after I removed a resource — why?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. homelab-architecture-principles-2026-05
|
||||
4. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
5. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
|
||||
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||
2. homelab-security-chains-not-bugs
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. flux-healthcheck-stale-on-resource-removal
|
||||
|
||||
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||
q: what are the homelab architecture principles from may 2026?
|
||||
1. homelab-architecture-principles-2026-05 <-- expected
|
||||
2. homelab-network-perimeter-model
|
||||
3. homelab-core-glossary
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. pattern-reddit-tmux-multiagent-conductor
|
||||
|
||||
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||
q: where does the sops age private key live in the cluster?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-network-perimeter-model
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
|
||||
5. homelab-security-chains-not-bugs
|
||||
|
||||
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
|
||||
q: why do my grafana dashboards disappear after a pod restart?
|
||||
1. grafana-dashboards-as-code-not-ui-state <-- expected
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
★ rank=1 expected=double-diamond-methodology
|
||||
q: what is the double diamond methodology?
|
||||
1. double-diamond-methodology <-- expected
|
||||
2. unified-methodology-diamond-futures-autoresearch
|
||||
3. futures-thinking-extended-double-diamond
|
||||
4. insight-exploration-as-diamond-1
|
||||
5. workflow-idea-to-running-service
|
||||
|
||||
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. finding-github-mcp-claudeai-vs-claudecode
|
||||
|
||||
· rank=2 expected=homelab-security-chains-not-bugs
|
||||
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||
1. homelab-network-perimeter-model
|
||||
2. homelab-security-chains-not-bugs <-- expected
|
||||
3. policy-audit-mode-blocks-nothing
|
||||
4. homelab-document-accepted-risk-to-break-audit-cycle
|
||||
5. audit-shortcut-tls-blocks-zero-equals-edge-only
|
||||
|
||||
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||
q: how should canonical context files relate to derived adapter files?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=homelab-core-glossary
|
||||
q: what is the homelab core vocabulary glossary?
|
||||
1. homelab-architecture-principles-2026-05
|
||||
2. homelab-core-glossary <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. flux-kustomization-depends-on-bootstrap-ordering
|
||||
5. brain-ingest-ntfy-service
|
||||
|
||||
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. infra-litellm-absorption-2026-05-16
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. qwen3-thinking-model-empty-content-trap
|
||||
|
||||
✗ rank=0 expected=qwen35-9b-fast
|
||||
q: what is qwen35-9b-fast and what's it used for?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. infra-litellm-absorption-2026-05-16
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. index
|
||||
|
||||
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||
1. homelab-network-perimeter-model
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-core-glossary
|
||||
3. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. homelab-architecture-principles-2026-05
|
||||
|
||||
· rank=3 expected=adr-new-project-gitea-first-github-mirror
|
||||
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
2. mcp-tool-design-get-needs-list-partner
|
||||
3. adr-new-project-gitea-first-github-mirror <-- expected
|
||||
4. 2026-05-04-gitea-mcp-build-session
|
||||
5. adr-local-dev-vs-hyperguild-new-project
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# post-m4b-entities-promoted — 20 questions, k=5
|
||||
|
||||
top-1 hit rate: 7/20 = 35%
|
||||
top-3 hit rate: 16/20 = 80%
|
||||
|
||||
## per-question detail
|
||||
|
||||
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
q: how do I stop dex from logging users out on every pod restart?
|
||||
1. homelab-network-perimeter-model
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||
3. extension-version-lags-platform-major-upgrade
|
||||
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
|
||||
★ rank=1 expected=homelab-network-perimeter-model
|
||||
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||
1. homelab-network-perimeter-model <-- expected
|
||||
2. qwen3-thinking-model-empty-content-trap
|
||||
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||
q: what does container exit code 255 with reason Unknown mean?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. exit-255-unknown-reason-not-oom <-- expected
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
q: can gitea push-mirror create the github repo automatically?
|
||||
1. infra-litellm-absorption-2026-05-16
|
||||
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||
3. adr-new-project-gitea-first-github-mirror
|
||||
4. adr-github-as-primary-remote
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||
q: a flux kustomization is stuck after I removed a resource — why?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. homelab-architecture-principles-2026-05
|
||||
4. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
5. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
|
||||
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||
2. homelab-security-chains-not-bugs
|
||||
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. flux-healthcheck-stale-on-resource-removal
|
||||
|
||||
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||
q: what are the homelab architecture principles from may 2026?
|
||||
1. homelab-architecture-principles-2026-05 <-- expected
|
||||
2. homelab-network-perimeter-model
|
||||
3. homelab-core-glossary
|
||||
4. 2026-05-12-koala-machine-state
|
||||
5. pattern-reddit-tmux-multiagent-conductor
|
||||
|
||||
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||
q: where does the sops age private key live in the cluster?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-network-perimeter-model
|
||||
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
|
||||
5. homelab-security-chains-not-bugs
|
||||
|
||||
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
|
||||
q: why do my grafana dashboards disappear after a pod restart?
|
||||
1. grafana-dashboards-as-code-not-ui-state <-- expected
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||
|
||||
★ rank=1 expected=double-diamond-methodology
|
||||
q: what is the double diamond methodology?
|
||||
1. double-diamond-methodology <-- expected
|
||||
2. unified-methodology-diamond-futures-autoresearch
|
||||
3. futures-thinking-extended-double-diamond
|
||||
4. insight-exploration-as-diamond-1
|
||||
5. workflow-idea-to-running-service
|
||||
|
||||
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. finding-github-mcp-claudeai-vs-claudecode
|
||||
|
||||
· rank=2 expected=homelab-security-chains-not-bugs
|
||||
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||
1. homelab-network-perimeter-model
|
||||
2. homelab-security-chains-not-bugs <-- expected
|
||||
3. policy-audit-mode-blocks-nothing
|
||||
4. homelab-document-accepted-risk-to-break-audit-cycle
|
||||
5. audit-shortcut-tls-blocks-zero-equals-edge-only
|
||||
|
||||
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||
q: how should canonical context files relate to derived adapter files?
|
||||
1. qwen3-thinking-model-empty-content-trap
|
||||
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
· rank=2 expected=homelab-core-glossary
|
||||
q: what is the homelab core vocabulary glossary?
|
||||
1. homelab-architecture-principles-2026-05
|
||||
2. homelab-core-glossary <-- expected
|
||||
3. 2026-05-12-koala-machine-state
|
||||
4. qwen35-9b-fast
|
||||
5. flux-kustomization-depends-on-bootstrap-ordering
|
||||
|
||||
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||
2. 2026-05-12-koala-machine-state
|
||||
3. infra-litellm-absorption-2026-05-16
|
||||
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||
5. qwen3-thinking-model-empty-content-trap
|
||||
|
||||
★ rank=1 expected=qwen35-9b-fast
|
||||
q: what is qwen35-9b-fast and what's it used for?
|
||||
1. qwen35-9b-fast <-- expected
|
||||
2. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
3. qwen3-thinking-model-empty-content-trap
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. 2026-05-12-koala-machine-state
|
||||
|
||||
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||
1. homelab-network-perimeter-model
|
||||
2. infra-litellm-absorption-2026-05-16
|
||||
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||
1. 2026-05-12-koala-machine-state
|
||||
2. homelab-core-glossary
|
||||
3. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
4. infra-litellm-absorption-2026-05-16
|
||||
5. homelab-architecture-principles-2026-05
|
||||
|
||||
· rank=3 expected=adr-new-project-gitea-first-github-mirror
|
||||
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
2. mcp-tool-design-get-needs-list-partner
|
||||
3. adr-new-project-gitea-first-github-mirror <-- expected
|
||||
4. 2026-05-04-gitea-mcp-build-session
|
||||
5. adr-local-dev-vs-hyperguild-new-project
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Brain retrieval eval set — 2026-05-24
|
||||
|
||||
20 hand-authored Q→expected-top-1-slug pairs. Used by `score.sh` to
|
||||
measure brain_query top-1 + top-3 hit rate against the live brain.
|
||||
|
||||
Authoring rules:
|
||||
- Each question maps to **one** clear-best entry. Avoid ambiguous
|
||||
questions where multiple slugs could be the right answer.
|
||||
- Questions are phrased the way a future-me would actually ask, not
|
||||
the way the entry's title reads. Some lexical distance is the point.
|
||||
- `expected` is the slug as stored in `brain_entities.slug`. Update
|
||||
if the slug renames.
|
||||
|
||||
## Pairs
|
||||
|
||||
```
|
||||
q: how do I stop dex from logging users out on every pod restart?
|
||||
expected: dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||
|
||||
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||
expected: postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||
|
||||
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||
expected: homelab-network-perimeter-model
|
||||
|
||||
q: what does container exit code 255 with reason Unknown mean?
|
||||
expected: exit-255-unknown-reason-not-oom
|
||||
|
||||
q: can gitea push-mirror create the github repo automatically?
|
||||
expected: gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||
|
||||
q: a flux kustomization is stuck after I removed a resource — why?
|
||||
expected: flux-healthcheck-stale-on-resource-removal
|
||||
|
||||
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||
expected: go-bytes-buffer-bytes-reset-aliasing-trap
|
||||
|
||||
q: what are the homelab architecture principles from may 2026?
|
||||
expected: homelab-architecture-principles-2026-05
|
||||
|
||||
q: where does the sops age private key live in the cluster?
|
||||
expected: 2026-05-04-sops-age-key-from-flux-cluster
|
||||
|
||||
q: why do my grafana dashboards disappear after a pod restart?
|
||||
expected: grafana-dashboards-as-code-not-ui-state
|
||||
|
||||
q: what is the double diamond methodology?
|
||||
expected: double-diamond-methodology
|
||||
|
||||
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||
expected: 2026-05-04-mcp-transport-version-claude-ai-strict
|
||||
|
||||
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||
expected: homelab-security-chains-not-bugs
|
||||
|
||||
q: how should canonical context files relate to derived adapter files?
|
||||
expected: 2026-05-03-canonical-vs-derived-context-flow
|
||||
|
||||
q: what is the homelab core vocabulary glossary?
|
||||
expected: homelab-core-glossary
|
||||
|
||||
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||
expected: koala-llama-swap-native-tool-calls-survey-2026-05
|
||||
|
||||
q: what is qwen35-9b-fast and what's it used for?
|
||||
expected: qwen35-9b-fast
|
||||
|
||||
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||
expected: go-defer-errcheck-body-close
|
||||
|
||||
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||
expected: hyperguild-level3-pipeline-rewrite
|
||||
|
||||
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||
expected: adr-new-project-gitea-first-github-mirror
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score brain_query against the qa-2026-05.md eval set.
|
||||
|
||||
Reads `q:` / `expected:` pairs, calls brain_query MCP for each, records
|
||||
top-1 + top-3 hit rate. Run:
|
||||
|
||||
BRAIN_MCP_TOKEN=$(grep '^export BRAIN_MCP_TOKEN=' ~/.llmkeys | cut -d= -f2-) \\
|
||||
python3 score.py qa-2026-05.md
|
||||
|
||||
Optionally pass --baseline <name> to save the result as a labeled run.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ENDPOINT = "https://brain-mcp.d-ma.be/mcp"
|
||||
|
||||
|
||||
def load_pairs(path):
|
||||
pairs = []
|
||||
q = None
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.rstrip()
|
||||
if line.startswith("q:"):
|
||||
q = line[2:].strip()
|
||||
elif line.startswith("expected:") and q is not None:
|
||||
expected = line[len("expected:"):].strip()
|
||||
pairs.append((q, expected))
|
||||
q = None
|
||||
return pairs
|
||||
|
||||
|
||||
def brain_query(token, query, k=5):
|
||||
body = json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "brain_query", "arguments": {"query": query, "k": k}},
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
ENDPOINT,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read().decode()
|
||||
for line in raw.splitlines():
|
||||
if line.startswith("data:"):
|
||||
raw = line[5:].strip()
|
||||
break
|
||||
d = json.loads(raw)
|
||||
if "error" in d:
|
||||
raise RuntimeError(d["error"])
|
||||
text = d["result"]["content"][0]["text"]
|
||||
return json.loads(text).get("results", [])
|
||||
|
||||
|
||||
def slug_of(result):
|
||||
# `title` mirrors the slug in brain_entities for normal entries.
|
||||
# Fall back to basename(path) if title is missing.
|
||||
t = result.get("title", "")
|
||||
if t:
|
||||
return t
|
||||
p = result.get("path", "")
|
||||
return re.sub(r"\.md$", "", os.path.basename(p))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("evalset")
|
||||
ap.add_argument("--baseline", default="run")
|
||||
ap.add_argument("--k", type=int, default=5)
|
||||
args = ap.parse_args()
|
||||
|
||||
token = os.environ.get("BRAIN_MCP_TOKEN")
|
||||
if not token:
|
||||
sys.exit("BRAIN_MCP_TOKEN not set")
|
||||
|
||||
pairs = load_pairs(args.evalset)
|
||||
if not pairs:
|
||||
sys.exit(f"no pairs in {args.evalset}")
|
||||
|
||||
print(f"# {args.baseline} — {len(pairs)} questions, k={args.k}")
|
||||
print()
|
||||
hits1 = 0
|
||||
hits3 = 0
|
||||
detail = []
|
||||
for q, expected in pairs:
|
||||
try:
|
||||
results = brain_query(token, q, k=args.k)
|
||||
except Exception as e:
|
||||
detail.append((q, expected, [], f"ERR {e}"))
|
||||
continue
|
||||
slugs = [slug_of(r) for r in results]
|
||||
rank = slugs.index(expected) + 1 if expected in slugs else 0
|
||||
h1 = 1 if rank == 1 else 0
|
||||
h3 = 1 if 0 < rank <= 3 else 0
|
||||
hits1 += h1
|
||||
hits3 += h3
|
||||
detail.append((q, expected, slugs, rank))
|
||||
|
||||
total = len(pairs)
|
||||
print(f"top-1 hit rate: {hits1}/{total} = {100*hits1/total:.0f}%")
|
||||
print(f"top-3 hit rate: {hits3}/{total} = {100*hits3/total:.0f}%")
|
||||
print()
|
||||
print("## per-question detail")
|
||||
print()
|
||||
for q, expected, slugs, rank in detail:
|
||||
marker = {0: "✗", 1: "★", 2: "·", 3: "·"}.get(rank, "?")
|
||||
if isinstance(rank, str):
|
||||
marker = "!"
|
||||
print(f"{marker} rank={rank} expected={expected}")
|
||||
print(f" q: {q}")
|
||||
for i, s in enumerate(slugs[:args.k], 1):
|
||||
mark = " <-- expected" if s == expected else ""
|
||||
print(f" {i}. {s}{mark}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
{"_meta":true,"note":"Agent-consumer column of brain-MCP intent analysis. consumer_type fixed=autonomous_agent. CAVEAT: canonical schema file brain-intent-extraction.md is NOT present on this host (koala) — only this session's own task prompt references it. The closed intent vocabulary below was RECONSTRUCTED from the task prompt's framing + brain/schema.md. Re-map intent labels if the canonical vocab differs. schema_source=reconstructed on every row.","closed_intent_vocab":["semantic_retrieval","lexical_lookup","check_prior_art","synthesized_answer","store_new_knowledge","update_or_supersede","ingest_raw_source","verify_write_landed","discover_capability","intent_unclear"],"intent_tool_match_values":["match","mismatch","partial"],"corpus":"~/.claude/projects/*/*.jsonl (Claude Code agent transcripts on koala). brain/sessions/*.jsonl empty. agentsquad docs/eval/*.jsonl are code-review eval results, NOT brain calls. No separate Crush logs found. Zero brain calls appear under any mcp__ name with a human typing the call — all brain acts are agent-initiated (CLAUDE.md reflex), so all qualify as autonomous_agent."}
|
||||
{"id":"a01","session":"tapir-c","ts":"2026-06-?T15:01:51","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"single pre-task query 'YouTube Data API captions download ownership limitation timedtext adapter Go' — named-entity lexical lookup, fit BM25 well, no reformulation."}
|
||||
{"id":"a02","session":"tapir","ts":"2026-06-05T21:44:10","tool":"brain_ingest","intent":"ingest_raw_source","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_ingest 14s earlier — tool not ambient, had to be discovered/loaded first.","evidence":"source=tapir-scheduled-discovery-session-2026-06-05, a session learnings dump."}
|
||||
{"id":"a03","session":"tapir","ts":"2026-06-?T14:00:59","tool":"brain_ingest","intent":"ingest_raw_source","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"source=tapir-rls-identity-bootstrapping, first write of RLS lesson."}
|
||||
{"id":"a04","session":"tapir","ts":"2026-06-?T14:02:08","tool":"brain_ingest","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-INGESTED same source name 'tapir-rls-identity-bootstrapping' 69s later with edited/condensed body. No update/patch/supersede verb exists, so the agent overwrote-by-re-ingest. Whether this dedups or creates a v2 duplicate is opaque to the agent.","observed_friction":"agent revised content within 70s of first write — classic edit-after-write with no edit primitive.","evidence":"two brain_ingest, identical source string, divergent content."}
|
||||
{"id":"a05","session":"tapir","ts":"2026-06-?T14:56:27","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"postgres-cascade-skips-tables-without-fk.md — distinct new lesson."}
|
||||
{"id":"a06","session":"tapir","ts":"2026-06-?T21:08:57","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_query — discovery tax again.","evidence":"'Dex passwords.dex.coreos.com CRD ...' keyword-rich, single shot."}
|
||||
{"id":"a07","session":"AI-infra","ts":"2026-05-?T15:38:03","tool":"brain_query(HTTP-curl)","intent":"discover_capability","intent_tool_match":"mismatch","workaround":"raw `curl -X POST` to brain-mcp endpoint instead of MCP tool. Preceded by two ToolSearch ('brain knowledge memory' then 'brain') that did not yield a usable loaded tool, so agent fell back to HTTP.","observed_friction":"3-step ladder: ToolSearch 'brain knowledge memory' -> ToolSearch 'brain' -> curl. Agent did not know which act maps to which tool name.","evidence":"curl -s -o /tmp/brain-init.txt -w code:%{http_code} -X POST ..."}
|
||||
{"id":"a08","session":"AI-infra","ts":"2026-05-?T04:46:13","tool":"brain_query(HTTP-curl)","intent":"discover_capability","intent_tool_match":"mismatch","workaround":"hand-set TOKEN=... then curl brain-test endpoint — probing whether the HTTP brain path is reachable/authed at all. MCP path not used.","observed_friction":"agent testing connectivity by hand; MCP auth/availability not trusted.","evidence":"TOKEN=...; curl -s -o /tmp/brain-test ..."}
|
||||
{"id":"a09","session":"AI-infra","ts":"2026-05-?T05:23:35","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_query (after an earlier 05:03 ToolSearch 'brain ingestion knowledge wiki' that explored layers).","evidence":"'koala machine state RTX 5070 llama-swap' — named-entity recall, fits lexical."}
|
||||
{"id":"a10","session":"AI-infra","ts":"2026-05-?T05:27:36","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 3 in ~1s (k3s/flux gitops; llama-swap ai-stack GPU; MCP Dex OAuth claude.ai) — parallel prior-art sweep, all named-entity."}
|
||||
{"id":"a11","session":"AI-infra","ts":"2026-05-?T07:13:50","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 4 in ~2s before a debugging session (flux healthCheck; exit 255 restart loop; NVML mismatch; mirror rebase). Named symptoms, lexical fit OK on first pass."}
|
||||
{"id":"a12","session":"AI-infra","ts":"2026-05-?T07:14:26","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"4 writes in ~35s (flux-healthcheck-stale; exit-255-unknown-reason-not-oom; nvidia-nvml-mismatch; mcp-static-bearer) — answers to the 4 queries just run, captured as lessons. Healthy query->fix->write loop."}
|
||||
{"id":"a13","session":"AI-infra","ts":"2026-05-?T07:15:25","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"25s after writing exit-255-unknown-reason-not-oom.md, re-queried 'exit 255 unknown SIGKILL containerd' — reformulated terms (SIGKILL/containerd not in original query 'exit 255 unknown reason restart loop diagnosis'). Either confirming the fresh write is retrievable or re-searching because first lexical query missed. No read-after-write / get-by-id act exists.","observed_friction":"reformulation chain: 'exit 255 unknown reason restart loop diagnosis' -> 'exit 255 unknown SIGKILL containerd'. Same need, different keywords.","evidence":"query at 07:13:51 vs 07:15:25 bracketing the 07:14:37 write."}
|
||||
{"id":"a14","session":"AI-infra","ts":"2026-05-?T07:22:55","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 5 in ~20s before a homelab security audit (piguard/iguana tailscale; unifi UCG firewall; SOPS age; ingress TLS cert-manager; koala UFW iptables). Named-entity sweep."}
|
||||
{"id":"a15","session":"AI-infra","ts":"2026-05-?T09:27:53","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"audit-shortcut-tls-blocks-zero; policy-audit-mode-blocks-nothing — distinct new audit lessons."}
|
||||
{"id":"a16","session":"AI-infra","ts":"2026-05-?T09:28:22","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"homelab-security-chains-not-bugs.md FIRST write (worked example: koala 2026-05-13)."}
|
||||
{"id":"a17","session":"AI-infra","ts":"2026-05-?T10:32:49","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE homelab-security-chains-not-bugs.md ~64min later with a different/expanded worked example (host-user dotfile, over-broad ClusterRole). Same filename, additive revision, no patch/append/supersede verb — agent overwrites and hopes the index replaces rather than duplicates.","observed_friction":"the in-between hour of audit work produced a better example; only way to fold it in was a full re-write of the same slug.","evidence":"two brain_write same filename at 09:28:22 and 10:32:49, divergent worked examples."}
|
||||
{"id":"a18","session":"AI-infra","ts":"2026-05-?T10:18:25","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"homelab-document-accepted-risk-to-break-audit-cycle.md — distinct."}
|
||||
{"id":"a19","session":"AI-infra","ts":"2026-05-?T10:32:55","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"6s after the homelab-chains re-write, queried 'RBAC MCP cluster pods log chain' — checking the chain reasoning is retrievable / finding the related entry. Read-after-write done via lexical search.","observed_friction":null,"evidence":"query immediately follows the 10:32:49 write."}
|
||||
{"id":"a20","session":"AI-infra","ts":"2026-05-?T18:57:34","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_write,brain_query — re-discovered tools this session.","evidence":"'extension build pinned version major version upgrade postgres pgvector'."}
|
||||
{"id":"a21","session":"AI-infra","ts":"2026-05-?T18:57:58","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"extension-version-lags-platform-major-upgrade.md."}
|
||||
{"id":"a22","session":"AI-infra","ts":"2026-05-?T18:58:06","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"8s after writing extension-version-lags, re-queried 'pgvector postgres extension version compile error bump' — reformulated from the 18:57:34 query ('extension build pinned version...'). Lexical re-search to confirm the just-written lesson is findable, with different keyword guess.","observed_friction":"reformulation: 'extension build pinned version major version upgrade postgres pgvector' -> 'pgvector postgres extension version compile error bump'.","evidence":"write at 18:57:58 bracketed by queries 18:57:34 and 18:58:06."}
|
||||
{"id":"a23","session":"AI-infra","ts":"2026-05-?T18:31:23","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"webfetch-readme-when-image-or-flag-uncertain.md FIRST write."}
|
||||
{"id":"a24","session":"AI-infra","ts":"2026-05-?T18:34:23","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE webfetch-readme-when-image-or-flag-uncertain.md 3min later, near-identical body. Looks like a retry/overwrite (uncertain the first landed, or minor edit). No idempotent upsert with confirmation, so agent re-fires the write.","observed_friction":"followed 7s later by a brain_query on the same topic ('OSS tool image registry CLI flag webhook path schema drift README pre-flight') — write-write-query, i.e. overwrite then verify-by-search.","evidence":"two brain_write same filename 18:31:23 / 18:34:23, then query 18:34:31."}
|
||||
{"id":"a25","session":"AI-infra","ts":"2026-05-?T18:34:31","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"keyword-stuffed lexical query 'OSS tool image registry CLI flag webhook path schema drift README pre-flight' fired right after the webfetch-readme write — agent dumps every concept token hoping BM25 surfaces its own fresh note. This is semantic intent (find that conceptual lesson) coerced into a bag-of-keywords.","observed_friction":"query is a concatenation of the note's section headings — a tell that the agent is groping lexically for content it knows by meaning.","evidence":"query text mirrors the just-written note's bullet topics."}
|
||||
{"id":"a26","session":"dev","ts":"2026-06-?T21:18:26","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_answer.","evidence":"'tapir transcript persistence shared cross-user dedup table RLS isolation ADR-021 ...' -> 22min later a brain_write (acted on the answer). Answer consumed, not re-queried. Healthy."}
|
||||
{"id":"a27","session":"dev","ts":"2026-06-?T21:40:20","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"tapir-migration-and-rls-test-infra-gotchas, with wing/hall absent here (flat) — see schema-confusion note a40."}
|
||||
{"id":"a28","session":"dev","ts":"2026-06-?T05:35:04","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"partial","workaround":"asked 'gitea MCP not working workaround file issue via API which token ... how to authenticate gitea API' — a how-do-I question. Next brain act (05:39 query) is a different topic (tapir transcript), so the answer was apparently sufficient OR abandoned; ambiguous.","observed_friction":null,"evidence":"brain_answer then unrelated brain_query 4min later."}
|
||||
{"id":"a29","session":"dev","ts":"2026-06-?T05:39:54","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'tapir transcript persistence ADR-021 shared non-RLS'."}
|
||||
{"id":"a30","session":"dev","ts":"2026-06-?T05:57:37","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"gitea-mcp-per-repo-tools-404-and-rest-fallback FIRST write."}
|
||||
{"id":"a31","session":"dev","ts":"2026-06-?T05:57:55","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE gitea-mcp-per-repo-tools-404-and-rest-fallback 18s later — overwrite/retry of same slug, no upsert confirmation.","observed_friction":"sub-20s gap = almost certainly a content tweak the agent could not express as an edit.","evidence":"two brain_write same filename 05:57:37 / 05:57:55."}
|
||||
{"id":"a32","session":"dev","ts":"2026-05-?T11:51:47","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"infra-litellm-absorption-2026-05-16.md."}
|
||||
{"id":"a33","session":"dev","ts":"2026-05-?T12:07:04","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 3 (litellm rebuild time piguard; docker compose orphaned volumes; prometheus_client ModuleNotFoundError) — lexical, error-string driven."}
|
||||
{"id":"a34","session":"dev","ts":"2026-05-?T15:08:15","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"mismatch","workaround":"THREE brain_answer at 15:08 (moved compose volumes? / pi rebuild time? / litellm ModuleNotFound prometheus) — the SAME three topics queried lexically an hour earlier (12:07) — were IMMEDIATELY followed at 15:09 by THREE brain_query on the same three topics. The agent asked the synthesizer, was unsatisfied, and fell straight back to raw lexical search. Strongest answer->query fallback in the corpus.","observed_friction":"answer/query duplication across one intent: agent hedges by firing both interfaces, trusting neither.","evidence":"15:08 answers vs 15:09 queries, topic-for-topic aligned."}
|
||||
{"id":"a35","session":"dev","ts":"2026-05-?T15:09:16","tool":"brain_query","intent":"semantic_retrieval","intent_tool_match":"mismatch","workaround":"after the 3 brain_answer calls failed to satisfy, re-issued as lexical brain_query ('moved compose stack to new directory volumes disappeared empty'; 'raspberry pi docker build time arm slow'; 'how to enable prometheus metrics on litellm proxy callback'). The want is meaning-based ('did my volumes move?') but the only retrieval that 'worked' was keyword search — and these are full natural-language sentences crammed into a BM25 box.","observed_friction":"natural-language questions ('how to enable...', 'moved ... disappeared') passed to a lexical query tool — semantic intent, lexical interface.","evidence":"3 queries at 15:09 mirror the 3 answers at 15:08."}
|
||||
{"id":"a36","session":"dev","ts":"2026-05-?T20:31:50","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'What happened with the litellm migration on 2026-05-16?' — episodic recall question, answer fit; no re-query followed."}
|
||||
{"id":"a37","session":"dev","ts":"2026-05-?T21:07:17","tool":"brain_query","intent":"semantic_retrieval","intent_tool_match":"mismatch","workaround":"FOUR-step reformulation chain over one Go bug: 'bytes.Buffer Bytes Reset aliasing slice sharing' -> 'go buffer reuse map backing array bug' -> [write go-bytes-buffer-bytes-reset-aliasing-trap.md] -> 'go map values all show same content after loop' -> 'bytes.Buffer Bytes returns same data every iteration'. The agent knows the SYMPTOM (all map values identical) and the CAUSE (Bytes() aliasing) but cannot phrase a single lexical query that bridges them — it wants concept retrieval and is forced to brute-force keyword variants.","observed_friction":"4 distinct phrasings of the same bug, two before and two after writing the lesson — also doubles as verify_write_landed on the trailing queries.","evidence":"21:07:17, 21:07:17, (write 21:08:01), 21:08:10, 21:08:19."}
|
||||
{"id":"a38","session":"dev","ts":"2026-05-?T21:08:01","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"go-bytes-buffer-bytes-reset-aliasing-trap.md."}
|
||||
{"id":"a39","session":"dev","ts":"2026-05-?T07:54:26","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"mcp-tool-design-get-needs-list-partner.md — a design principle."}
|
||||
{"id":"a40","session":"dev","ts":"2026-06-?T06:25:00","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'Dex to Authentik migration auth.d-ma.be issuer cutover OIDC subject ...'."}
|
||||
{"id":"a41","session":"dev","ts":"2026-06-?T06:25:08","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"mismatch","workaround":"brain_query (a40) and brain_answer (a41) fired ~8s apart on the SAME intent (Dex->Authentik subject-keyed token orphan). Agent runs lexical search AND synthesized answer in parallel for one question rather than choosing — it cannot predict which interface will return usable knowledge, so it pays both.","observed_friction":"query+answer doublet on one need.","evidence":"06:25:00 query then 06:25:08 answer, same topic."}
|
||||
{"id":"a42","session":"dev","ts":"2026-06-?T13:48:49","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"'authentik cutover validation probe' then 6min later 'authentik cutover post-flip validation' — reformulated pair, likely searching for the agent's own earlier cutover notes / confirming validation steps are recorded. Lexical re-search standing in for recall-my-recent-context.","observed_friction":"reformulation: 'validation probe' -> 'post-flip validation'.","evidence":"13:48:49 and 13:54:56."}
|
||||
{"id":"a43","session":"dev","ts":"2026-06-?T13:59:17","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_write.","evidence":"oidc-issuer-host-change-vs-idp-swap-subject."}
|
||||
{"id":"a44","session":"dev","ts":"2026-06-?T13:59:50","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"cannot-move-ingress-host-across-namespaces-flux-dryrun FIRST write."}
|
||||
{"id":"a45","session":"dev","ts":"2026-06-?T14:00:13","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE cannot-move-ingress-host-across-namespaces-flux-dryrun 23s later — overwrite of same slug, no edit/upsert primitive.","observed_friction":"sub-30s gap = content correction expressed as a full re-write.","evidence":"two brain_write same filename 13:59:50 / 14:00:13."}
|
||||
{"id":"a46","session":"dev","ts":"2026-06-?T13:53:54","tool":"brain_write(HTTP-staged)","intent":"store_new_knowledge","intent_tool_match":"mismatch","workaround":"after `ToolSearch select:mcp__claude_ai_brain__authenticate` (MCP auth flow), the agent staged the entry as `cat > /tmp/brain_entry.json` ({filename:'postgres-force-rls-cross-u...', content}) for a curl write rather than calling brain_write directly — MCP write path was not usable (auth/loading), so it dropped to the HTTP bodge.","observed_friction":"reached for an 'authenticate' tool, then abandoned MCP for hand-built JSON + curl. Matches known pattern: brain/op MCP auth lapses often.","evidence":"ToolSearch authenticate 13:53:27 -> cat /tmp/brain_entry.json 13:53:54."}
|
||||
{"id":"a47","session":"template-go-agent","ts":"2026-05-?T18:46:26","tool":"BASH(not-a-brain-act)","intent":"intent_unclear","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'brain_' substring was inside a git commit message body ('agent boundaries, network policy, agent s...'), NOT a brain call. Excluded from knowledge-act analysis; logged for audit completeness."}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Agent-Consumer Brain Intent Analysis — koala column
|
||||
|
||||
**Consumer:** `autonomous_agent` (all rows). **Host:** koala. **Date:** 2026-06-15.
|
||||
**Raw rows:** `agent-intent-column.jsonl` (46 real knowledge-acts + 1 excluded false-positive).
|
||||
|
||||
## Caveat — canonical schema not on this host
|
||||
|
||||
The shared closed-vocabulary file `brain-intent-extraction.md` **does not exist on
|
||||
koala** — the only reference to it is inside *this task's own prompt*. The intent
|
||||
vocabulary below was **reconstructed** from the prompt's framing + `brain/schema.md`.
|
||||
Every row carries `schema_source: reconstructed`. If the canonical vocab differs,
|
||||
re-map the `intent` field; the `intent_tool_match` / `workaround` / `observed_friction`
|
||||
evidence stands regardless of label names.
|
||||
|
||||
**Reconstructed closed vocab:** `semantic_retrieval`, `lexical_lookup`,
|
||||
`check_prior_art`, `synthesized_answer`, `store_new_knowledge`, `update_or_supersede`,
|
||||
`ingest_raw_source`, `verify_write_landed`, `discover_capability`, `intent_unclear`.
|
||||
|
||||
## Corpus
|
||||
|
||||
- `~/.claude/projects/*/*.jsonl` — Claude Code agent transcripts (98 files). **The only
|
||||
source with brain calls.**
|
||||
- `brain/sessions/*.jsonl` — empty (only `.gitkeep`).
|
||||
- `agentsquad docs/eval/*.jsonl` — code-review eval results, **not** brain calls.
|
||||
- No separate Crush session logs on this host.
|
||||
- **Zero** brain calls were human-typed. Every brain act is agent-initiated (the
|
||||
CLAUDE.md "query as reflex / close-the-loop write" behaviour), so all qualify as
|
||||
`autonomous_agent`. The human gave the top-level task; the agent chose every brain act.
|
||||
|
||||
## 1. Intent histogram, split by `intent_tool_match`
|
||||
|
||||
| intent | match | mismatch | partial | total |
|
||||
|---|---|---|---|---|
|
||||
| check_prior_art | 10 | 0 | 0 | 10 |
|
||||
| store_new_knowledge | 14 | 1 | 0 | 15 |
|
||||
| update_or_supersede | 0 | 5 | 0 | 5 |
|
||||
| synthesized_answer | 2 | 2 | 1 | 5 |
|
||||
| verify_write_landed | 0 | 4 | 0 | 4 |
|
||||
| semantic_retrieval | 0 | 3 | 0 | 3 |
|
||||
| ingest_raw_source | 2 | 0 | 0 | 2 |
|
||||
| discover_capability | 0 | 2 | 0 | 2 |
|
||||
| **total** | **28** | **17** | **1** | **46** |
|
||||
|
||||
> Batch note: several rows collapse a same-second fan-out of identical-intent calls
|
||||
> (a10=3, a11=4, a14=5, a33=3, a34=3, a35=3). Call-level the corpus is ~62 brain calls;
|
||||
> the table counts the 46 distinct knowledge-acts. Frequency is deliberately *not* the
|
||||
> point — the mismatch column is.
|
||||
|
||||
**37% of agent knowledge-acts (17/46) are interface mismatches.** Every mismatch falls
|
||||
into one of four intents: `update_or_supersede`, `verify_write_landed`,
|
||||
`semantic_retrieval`, `discover_capability` — plus one `store` that had to use HTTP.
|
||||
|
||||
## 2. Mismatch list, grouped by intent (primary deliverable)
|
||||
|
||||
### update_or_supersede → re-write same slug (5/5 mismatch) — HIGHEST VALUE
|
||||
There is **no update / patch / append / supersede verb**. When an agent improves a note
|
||||
it already wrote, the only move is to call `brain_write`/`brain_ingest` **again with the
|
||||
same filename/source** and hope the index replaces rather than duplicates. Observed:
|
||||
|
||||
| slug | 1st write | 2nd write | gap | what changed |
|
||||
|---|---|---|---|---|
|
||||
| `tapir-rls-identity-bootstrapping` (ingest) | 14:00:59 | 14:02:08 | 69s | condensed body |
|
||||
| `homelab-security-chains-not-bugs.md` | 09:28:22 | 10:32:49 | 64m | new worked example |
|
||||
| `webfetch-readme-when-image-or-flag-uncertain.md` | 18:31:23 | 18:34:23 | 3m | near-identical (retry) |
|
||||
| `gitea-mcp-per-repo-tools-404-and-rest-fallback` | 05:57:37 | 05:57:55 | 18s | content tweak |
|
||||
| `cannot-move-ingress-host-across-namespaces-flux-dryrun` | 13:59:50 | 14:00:13 | 23s | content tweak |
|
||||
|
||||
Sub-30s gaps (3 of 5) read as "I wanted to edit but can only overwrite." The agent has
|
||||
no way to know whether the second write deduped or created a contradictory v2 — opacity
|
||||
the brain's own design principle (`mcp-tool-design-get-needs-list-partner.md`, written
|
||||
*by one of these very agents*) would flag: every `_write` needs a `_get`/`_update` partner.
|
||||
|
||||
### verify_write_landed → lexical re-query (4/4 mismatch)
|
||||
No read-after-write / get-by-id confirmation. After every substantive write, agents
|
||||
re-query lexically to check the note is retrievable — and *reformulate the keywords*
|
||||
because they can't predict what BM25 indexed:
|
||||
- `exit-255` lesson: query `exit 255 unknown reason restart loop diagnosis` → write →
|
||||
query `exit 255 unknown SIGKILL containerd`.
|
||||
- `extension-version-lags`: query `extension build pinned version...pgvector` → write →
|
||||
query `pgvector postgres extension version compile error bump`.
|
||||
- `webfetch-readme`: write → write → query stuffed with the note's own section headings.
|
||||
|
||||
### semantic_retrieval → BM25 keyword-stuffing (3/3 mismatch)
|
||||
Agent knows the *meaning* but not the *indexed words*, so it brute-forces phrasings of
|
||||
one need against a lexical tool:
|
||||
- **4-step chain on one Go bug:** `bytes.Buffer Bytes Reset aliasing slice sharing` →
|
||||
`go buffer reuse map backing array bug` → (write) → `go map values all show same
|
||||
content after loop` → `bytes.Buffer Bytes returns same data every iteration`. Symptom
|
||||
and cause both known; no single lexical query bridges them.
|
||||
- Natural-language questions (`how to enable prometheus metrics on litellm proxy
|
||||
callback`, `moved compose stack to new directory volumes disappeared empty`) shoved
|
||||
into `brain_query`.
|
||||
|
||||
### synthesized_answer → fall back to / hedge with brain_query (2 mismatch + 1 partial)
|
||||
`brain_answer` is frequently **not trusted as terminal**:
|
||||
- **Strongest signal:** 3× `brain_answer` at 15:08 (compose volumes / pi rebuild time /
|
||||
litellm ModuleNotFound) → 3× `brain_query` at 15:09 on the *same three topics*. The
|
||||
agent asked the synthesizer, was unsatisfied, and immediately re-ran raw search.
|
||||
- Dex→Authentik: `brain_query` and `brain_answer` fired **8s apart on one question** —
|
||||
the agent pays both interfaces because it can't predict which returns usable knowledge.
|
||||
- (Counter-examples exist: `brain_answer` for episodic recall — "what happened with the
|
||||
litellm migration on 2026-05-16?" — was consumed and not re-queried. So `answer`
|
||||
works for *episodic/temporal* recall, fails for *how-do-I / does-X-hold* reasoning.)
|
||||
|
||||
### discover_capability + store-via-HTTP (3 mismatch)
|
||||
brain tools are **not ambient** — they are deferred and must be `ToolSearch`-loaded each
|
||||
session. Agents fumble the discovery (`ToolSearch 'brain knowledge memory'` →
|
||||
`'brain'` → `'brain ingestion knowledge wiki'`) and, when MCP load/auth fails, drop to
|
||||
**raw `curl` against `brain-mcp` / hand-built `/tmp/brain_entry.json`**. One agent even
|
||||
`ToolSearch`-ed an `authenticate` tool, then abandoned MCP for the HTTP bodge — matching
|
||||
the known "brain/op MCP auth lapses too often" footgun.
|
||||
|
||||
### Write-interface / layer schema confusion (cross-cutting)
|
||||
`brain_write` was called with **three different param shapes** in the same corpus:
|
||||
`{filename, type:"lesson", content}`, `{filename, content}` (no type), and
|
||||
`{wing:"tapir", hall:"failures", filename, content}` — plus `brain_ingest {source,
|
||||
content}`. Agents are unsure which verb and which layer (flat slug vs `wing`/`hall`
|
||||
knowledge routing vs raw ingest) a given knowledge-act maps to. This is the
|
||||
`knowledge/ vs wiki/` confusion expressed at the parameter level.
|
||||
|
||||
## 3. `intent_unclear` rate
|
||||
|
||||
**0 / 46 genuine brain acts (0%).** Agent intent is unusually legible because these are
|
||||
Claude Code transcripts: the surrounding task, the query/filename strings, and the
|
||||
write content all disambiguate. One row (`a47`) was tagged `intent_unclear` and
|
||||
**excluded** — its `brain_` substring was inside a git commit message, not a brain call.
|
||||
Example of the only ambiguity that arose: a `brain_answer` on "gitea MCP not working...
|
||||
how to authenticate" followed by an unrelated query — can't tell if the answer satisfied
|
||||
or was abandoned (`partial`, row a28).
|
||||
|
||||
## 4. The single biggest intent↔interface gap
|
||||
|
||||
**The brain offers one write verb and one lexical read verb, but autonomous agents
|
||||
perform four distinct knowledge-acts against them — and three of the four have no fitting
|
||||
interface.** The deepest gap is the **missing update/supersede path**: agents close every
|
||||
task by writing a lesson (the CLAUDE.md ritual), routinely improve it minutes-to-an-hour
|
||||
later, and — having no edit primitive — re-write the same slug blind, unable to tell
|
||||
whether they corrected the entry or forked a contradiction into the index. This compounds
|
||||
with the lexical-only read side: because there is no `get-by-id` or semantic retrieval,
|
||||
agents can't even reliably *find their own just-written note* to check it, so they
|
||||
keyword-stuff reformulated queries and hedge `brain_answer` with parallel `brain_query`.
|
||||
The interface is built for *append-and-keyword-search*; the agents are trying to
|
||||
*curate a living, deduplicated knowledge base*, and the seam between those two shows up
|
||||
as the 5 blind re-writes, 4 read-after-write re-queries, and 3 semantic-as-lexical chains
|
||||
that dominate the mismatch column.
|
||||
|
||||
---
|
||||
*Evidence-only per task scope — no redesign proposed.*
|
||||
@@ -0,0 +1,140 @@
|
||||
# Brain-MCP Intent↔Interface Findings — Unified (two-column merge)
|
||||
|
||||
**Status — 2026-06-16**
|
||||
- ✅ **Agent column** filled from `agent-intent-column.jsonl` (46 acts, koala).
|
||||
- ⏳ **Human column** = `PENDING`. Drop the Claude.ai-history analysis into
|
||||
`human-intent-column.jsonl` (same dir, schema below), then fill the `PENDING`
|
||||
cells and the synthesis blocks marked `<<SYNTH>>`.
|
||||
- ⚠️ Canonical `brain-intent-extraction.md` still absent on koala. Vocab below is
|
||||
the **reconstructed** lock both columns must share. If the real file surfaces,
|
||||
re-map `intent` labels in *both* columns identically before merging.
|
||||
|
||||
---
|
||||
|
||||
## Shared schema (LOCKED — both columns conform)
|
||||
|
||||
Per-call row, JSONL:
|
||||
|
||||
| field | values / form | notes |
|
||||
|---|---|---|
|
||||
| `id` | `a01..` (agent) / `h01..` (human) | column prefix kept distinct |
|
||||
| `session` | string | source session/conversation id |
|
||||
| `ts` | ISO-8601 | best-effort |
|
||||
| `tool` | brain tool name (+ `(HTTP-curl)` / `(HTTP-staged)` suffix for bodges) | |
|
||||
| `intent` | closed vocab ↓ | the knowledge-act WANTED |
|
||||
| `intent_tool_match` | `match` \| `mismatch` \| `partial` | does the called tool fit the want |
|
||||
| `consumer_type` | `autonomous_agent` \| `human_interactive` | fixed per column |
|
||||
| `workaround` | string \| null | the bodge when mismatch — **primary signal** |
|
||||
| `observed_friction` | string \| null | reformulation chains, discovery tax, hedging |
|
||||
| `evidence` | string | excerpt anchoring the classification |
|
||||
| `schema_source` | `reconstructed` | flip to `canonical` if real vocab lands |
|
||||
|
||||
### Closed intent vocab (LOCKED)
|
||||
`semantic_retrieval`, `lexical_lookup`, `check_prior_art`, `synthesized_answer`,
|
||||
`store_new_knowledge`, `update_or_supersede`, `ingest_raw_source`,
|
||||
`verify_write_landed`, `discover_capability`, `intent_unclear`.
|
||||
|
||||
---
|
||||
|
||||
## Master comparison — by intent
|
||||
|
||||
| intent | agent acts | agent mismatch | human acts | human mismatch | shared gap |
|
||||
|---|---|---|---|---|---|
|
||||
| check_prior_art | 10 | 0% | `PENDING` | `PENDING` | — |
|
||||
| store_new_knowledge | 15 | 7% (1/15) | `PENDING` | `PENDING` | `<<SYNTH>>` |
|
||||
| update_or_supersede | 5 | **100%** (5/5) | `PENDING` | `PENDING` | `<<SYNTH>>` no edit verb |
|
||||
| synthesized_answer | 5 | 40% (2/5)+1 partial | `PENDING` | `PENDING` | `<<SYNTH>>` |
|
||||
| verify_write_landed | 4 | **100%** (4/4) | `PENDING` | `PENDING` | `<<SYNTH>>` no read-after-write |
|
||||
| semantic_retrieval | 3 | **100%** (3/3) | `PENDING` | `PENDING` | `<<SYNTH>>` lexical-only read |
|
||||
| ingest_raw_source | 2 | 0% | `PENDING` | `PENDING` | — |
|
||||
| discover_capability | 2 | **100%** (2/2) | `PENDING` | `PENDING` | agent-specific (ToolSearch/auth)? |
|
||||
| intent_unclear | 0 | — | `PENDING` | `PENDING` | divergence expected ↓ |
|
||||
| **TOTAL** | **46** | **37% (17)** | `PENDING` | `PENDING` | |
|
||||
|
||||
---
|
||||
|
||||
## Per-intent merged findings
|
||||
|
||||
### update_or_supersede — agent: 5/5 mismatch (highest value)
|
||||
**Agent:** no edit/patch/append verb. Agents re-write same slug blind:
|
||||
`homelab-security-chains-not-bugs.md` (+64m), `tapir-rls-identity-bootstrapping`,
|
||||
`webfetch-readme...`, `gitea-mcp-per-repo-tools-404...`,
|
||||
`cannot-move-ingress-host...` — 3 of 5 sub-30s ("wanted edit, got overwrite").
|
||||
Cannot tell if write deduped or forked a contradiction.
|
||||
**Human:** `PENDING` — *look for: user editing a prior note, asking "update what I
|
||||
saved about X", or expressing frustration that an old fact is stale/duplicated.*
|
||||
**<<SYNTH>>** shared verdict once both filled.
|
||||
|
||||
### verify_write_landed — agent: 4/4 mismatch
|
||||
**Agent:** no `get-by-id`/read-after-write. Agents lexically re-query their own
|
||||
fresh note with reformulated keywords (`exit 255 unknown reason` → `...SIGKILL
|
||||
containerd`; `extension build pinned...` → `pgvector ...compile error bump`).
|
||||
**Human:** `PENDING` — *humans may not exhibit this (they trust the write UI
|
||||
confirmation). If absent in human column, it's an agent-specific gap → flag.*
|
||||
**<<SYNTH>>**.
|
||||
|
||||
### semantic_retrieval — agent: 3/3 mismatch
|
||||
**Agent:** meaning known, indexed words unknown → BM25 keyword-stuffing. 4-step
|
||||
chain on one Go `bytes.Buffer` bug; NL questions shoved into `brain_query`.
|
||||
**Human:** `PENDING` — *humans likely hit this HARDER (they phrase conversationally).
|
||||
Compare reformulation-chain length agent vs human.*
|
||||
**<<SYNTH>>** — likely the strongest cross-consumer overlap.
|
||||
|
||||
### synthesized_answer — agent: 2 mismatch + 1 partial
|
||||
**Agent:** `brain_answer` not trusted terminal — 3 answers → 3 same-topic queries
|
||||
1min later; query+answer fired 8s apart hedging one need. Works for *episodic*
|
||||
recall, fails for *how-do-I / does-X-hold*.
|
||||
**Human:** `PENDING` — *humans may prefer `brain_answer` as primary (chat-native).
|
||||
If human match-rate >> agent, the tool fits humans not agents → key divergence.*
|
||||
**<<SYNTH>>**.
|
||||
|
||||
### store_new_knowledge — agent: 14/15 match
|
||||
**Agent:** healthy, except 1 HTTP-staged bodge when MCP auth lapsed. Also surfaced
|
||||
write-schema confusion: 3 param shapes (`{filename,type}` / `{filename}` /
|
||||
`{wing,hall,filename}`) + `ingest{source}`.
|
||||
**Human:** `PENDING` — *humans rarely write directly; expect low volume.*
|
||||
**<<SYNTH>>**.
|
||||
|
||||
### check_prior_art / ingest_raw_source — agent: 0% mismatch
|
||||
Lexical fits named-entity recall and raw-source capture. **Human:** `PENDING`.
|
||||
|
||||
### discover_capability — agent: 2/2 mismatch (agent-specific)
|
||||
Brain tools deferred → `ToolSearch`-load each session; auth lapse → `curl` bodge.
|
||||
**Likely has NO human analog** (humans get ambient connectors). Candidate for
|
||||
"agent-only gap" bucket. **Human:** `PENDING` to confirm absent.
|
||||
|
||||
---
|
||||
|
||||
## Cross-consumer divergence — questions to resolve at merge
|
||||
|
||||
1. **intent_unclear rate.** Agent = 0% (transcripts self-document). Human expected
|
||||
higher (conversational, implicit). Big delta = the columns measure legibility
|
||||
differently, not just intent.
|
||||
2. **Where does each consumer's mismatch concentrate?** Agent mismatch is
|
||||
write-side-heavy (supersede + verify-landed = 9/17). Hypothesis: human mismatch
|
||||
is read-side-heavy (semantic + answer). If true → **the interface fails the two
|
||||
consumers at opposite ends.**
|
||||
3. **Agent-only gaps** (`discover_capability`, `verify_write_landed`) vs
|
||||
**shared gaps** (`semantic_retrieval`, `update_or_supersede`). Shared gaps =
|
||||
highest-priority evidence; agent-only = harness/auth issues.
|
||||
|
||||
---
|
||||
|
||||
## Combined headline — `<<SYNTH>>` (fill when human column lands)
|
||||
|
||||
> Agent-side draft (to be reconciled with human-side):
|
||||
> Brain = append + keyword-search; agents want a curated, dedup'd, self-verifying KB.
|
||||
> Missing update/supersede path + lexical-only reads are the seam. **Open question
|
||||
> for the merge: do humans hit the same read-side wall, making semantic-retrieval the
|
||||
> universal gap — or do agents uniquely suffer the write-side (supersede / verify)
|
||||
> wall that humans sidestep via the chat UI?**
|
||||
|
||||
---
|
||||
|
||||
## Drop-in checklist (when human column arrives)
|
||||
1. Place `human-intent-column.jsonl` in this dir; conform to LOCKED schema.
|
||||
2. Fill every `PENDING` cell in master table + per-intent blocks.
|
||||
3. Resolve the 3 divergence questions with evidence.
|
||||
4. Replace each `<<SYNTH>>` with the reconciled verdict; write the combined headline.
|
||||
5. If canonical vocab surfaced: re-map both columns' `intent`, flip `schema_source`.
|
||||
6. Commit as `docs(brain): merge human+agent intent columns`.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -5,6 +5,15 @@ FROM golang:1.26-bookworm AS builder
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
|
||||
# Fetch internal gitea-hosted Go modules (mcp-chassis) without going through
|
||||
# proxy.golang.org and without HTTP→HTTPS surprises. The Gitea server returns
|
||||
# http:// in its go-import meta tag (config-level limitation), so rewrite to
|
||||
# https here and bypass the module proxy + sumdb.
|
||||
RUN git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
||||
ENV GOPRIVATE=gitea.d-ma.be
|
||||
ENV GOPROXY=direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
|
||||
@@ -6,16 +6,102 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
|
||||
"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"
|
||||
)
|
||||
|
||||
// claudeSink converts each claudewatcher.Batch into one wiki note under
|
||||
// brain/wiki/claude-sessions/facts/. v1 emits one note per session
|
||||
// keyed by host + session id; classifier-driven hall routing is a
|
||||
// follow-up (hyperguild#27 v2).
|
||||
type claudeSink struct {
|
||||
brainDir string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
||||
if len(b.Turns) == 0 {
|
||||
return nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "# Claude session %s (%s)\n\n", b.SessionID, b.Host)
|
||||
fmt.Fprintf(&sb, "_Project: `%s`. File: `%s`. Turns: %d._\n\n", b.ProjectID, b.FilePath, len(b.Turns))
|
||||
for _, t := range b.Turns {
|
||||
fmt.Fprintf(&sb, "## %s — %s\n\n", t.Type, t.Timestamp.UTC().Format(time.RFC3339))
|
||||
if t.ToolName != "" {
|
||||
fmt.Fprintf(&sb, "_tool: `%s`_\n\n", t.ToolName)
|
||||
}
|
||||
// Cap per-turn excerpt to keep page size bounded; the full
|
||||
// transcript lives on disk under ~/.claude/projects/ already.
|
||||
content := t.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:2000] + "…"
|
||||
}
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
slug := "session-" + b.Host + "-" + b.SessionID
|
||||
if _, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Filename: slug,
|
||||
Wing: "claude-sessions",
|
||||
Hall: "facts",
|
||||
Type: "source",
|
||||
Domain: b.ProjectID,
|
||||
Content: sb.String(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write claude session note: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -32,6 +118,16 @@ func envInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||
// caller never has to handle the rare error path.
|
||||
func systemHostname() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil || h == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
@@ -54,6 +150,94 @@ 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)
|
||||
|
||||
// Graph store shares the same postgres18 DSN as the vector
|
||||
// store and is opt-in via BRAIN_GRAPH_ENABLED=true. Defaults
|
||||
// to off so first rollout doesn't surprise — flip on after
|
||||
// the migration completes and the backfill finishes.
|
||||
if envOr("BRAIN_GRAPH_ENABLED", "false") == "true" {
|
||||
gstore, gerr := graphstore.New(context.Background(), pgDSN)
|
||||
if gerr != nil {
|
||||
logger.Error("graph store init", "err", gerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gerr := gstore.Init(context.Background()); gerr != nil {
|
||||
logger.Error("graph store migrate", "err", gerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
mcpSrv = mcpSrv.WithGraph(gstore)
|
||||
if envOr("BRAIN_GRAPH_BACKFILL", "false") == "true" {
|
||||
n, berr := graphsync.BackfillFromBrainDir(context.Background(), gstore, brainDir)
|
||||
if berr != nil {
|
||||
logger.Warn("graph backfill incomplete", "indexed", n, "err", berr)
|
||||
} else {
|
||||
logger.Info("graph backfill complete", "indexed", n)
|
||||
}
|
||||
}
|
||||
logger.Info("brain graph enabled", "pg", redactDSN(pgDSN))
|
||||
}
|
||||
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{
|
||||
@@ -63,12 +247,134 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
// Claude Code session ingestion (hyperguild#27 / infra#73 Track E.1).
|
||||
// Off by default — explicitly opt in by setting CLAUDE_SESSIONS_DIR
|
||||
// to the ~/.claude/projects path. Requires BRAIN_PG_DSN for the
|
||||
// cursor table (resumable offsets across restarts).
|
||||
if claudeDir := os.Getenv("CLAUDE_SESSIONS_DIR"); claudeDir != "" {
|
||||
if pgDSN == "" {
|
||||
logger.Error("CLAUDE_SESSIONS_DIR set but BRAIN_PG_DSN missing — claudewatcher needs the cursor table")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Client-name guard. The env value is a regex alternation
|
||||
// (e.g. "SEB|Mastercard"); we wrap it with word boundaries
|
||||
// and case-insensitive flag so substrings inside longer
|
||||
// identifiers don't false-match. Sourced from a SOPS secret
|
||||
// so client identities never live in source.
|
||||
if clientBlock := os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK"); clientBlock != "" {
|
||||
pattern := `(?i)\b(` + clientBlock + `)\b`
|
||||
if err := claudewatcher.RegisterRule("client-name", pattern); err != nil {
|
||||
logger.Error("claudewatcher client-block rule invalid", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("claudewatcher client-block guard registered")
|
||||
}
|
||||
cursorStore, cerr := claudewatcher.NewCursorStore(ctx, pgDSN)
|
||||
if cerr != nil {
|
||||
logger.Error("claudewatcher cursor init", "err", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cerr := cursorStore.Init(ctx); cerr != nil {
|
||||
logger.Error("claudewatcher cursor migrate", "err", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||
go func() {
|
||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||
SessionsDir: claudeDir,
|
||||
Host: host,
|
||||
Interval: interval,
|
||||
Sink: sink,
|
||||
Cursors: cursorStore,
|
||||
Logger: logger,
|
||||
}); err != nil && err != context.Canceled {
|
||||
logger.Error("claudewatcher exited", "err", err)
|
||||
}
|
||||
}()
|
||||
logger.Info("claudewatcher started",
|
||||
"sessions_dir", claudeDir, "host", host, "interval", interval)
|
||||
}
|
||||
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)
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||
if err != nil {
|
||||
logger.Error("build jwt validator", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if jwtValidator != nil {
|
||||
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||
}
|
||||
|
||||
// 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",
|
||||
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||
if resourceURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
}
|
||||
|
||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", 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)
|
||||
}
|
||||
|
||||
// /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack
|
||||
// scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics
|
||||
// middleware below wraps every other registered handler so it observes
|
||||
// real request latency. /metrics itself is excluded from its own
|
||||
// observation by registering it on the outer mux (post-wrap).
|
||||
reg := metrics.New()
|
||||
mux.HandleFunc("GET /metrics", reg.Handler())
|
||||
logger.Info("metrics endpoint registered", "path", "/metrics")
|
||||
|
||||
addr := ":" + port
|
||||
watchIntervalLog := "disabled"
|
||||
@@ -82,8 +388,9 @@ func main() {
|
||||
"llm_model", llmModel,
|
||||
"chunk_size", chunkSize,
|
||||
"watch_interval", watchIntervalLog,
|
||||
"mcp_enabled", true,
|
||||
)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil {
|
||||
logger.Error("server stopped", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+21
-1
@@ -2,10 +2,30 @@ 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 (
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/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,54 @@
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
// frontmatter is an ordered, line-preserving view of a note's YAML
|
||||
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
||||
// brain writes flat `key: value` frontmatter by hand, and a yaml.v3
|
||||
// re-marshal would reorder keys and strip comments. Preserving the
|
||||
// original lines verbatim keeps brain_update a surgical edit — only the
|
||||
// keys it manages (updated_at, supersedes, supersede_reason) change.
|
||||
type frontmatter struct {
|
||||
lines []fmLine
|
||||
}
|
||||
|
||||
// fmLine is one frontmatter line. For `key: value` lines, key and value
|
||||
// are populated; for blank lines, comments, or anything that isn't a
|
||||
// simple scalar pair, key is empty and raw holds the line verbatim.
|
||||
type fmLine struct {
|
||||
key string
|
||||
value string
|
||||
raw string
|
||||
}
|
||||
|
||||
// parseFrontmatter splits src into its frontmatter block and body. A
|
||||
// frontmatter block is recognised only when the file opens with a `---`
|
||||
// fence and a closing `---` fence follows. Otherwise the whole input is
|
||||
// the body and the returned frontmatter is empty.
|
||||
func parseFrontmatter(src string) (frontmatter, string) {
|
||||
var fm frontmatter
|
||||
if !strings.HasPrefix(src, "---\n") {
|
||||
return fm, src
|
||||
}
|
||||
rest := src[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
// Opening fence with no closing fence — treat as bodyless content.
|
||||
return fm, src
|
||||
}
|
||||
block := rest[:end]
|
||||
body := rest[end+len("\n---\n"):]
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
key = strings.TrimSpace(key)
|
||||
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
fm.lines = append(fm.lines, fmLine{raw: line})
|
||||
continue
|
||||
}
|
||||
fm.lines = append(fm.lines, fmLine{key: key, value: strings.TrimSpace(val)})
|
||||
}
|
||||
return fm, body
|
||||
}
|
||||
|
||||
// get returns the value for key, or "" if absent.
|
||||
func (f *frontmatter) get(key string) string {
|
||||
for _, l := range f.lines {
|
||||
if l.key == key {
|
||||
return l.value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// set overrides the value for an existing key in place, or appends a new
|
||||
// `key: value` line when the key is absent.
|
||||
func (f *frontmatter) set(key, value string) {
|
||||
for i := range f.lines {
|
||||
if f.lines[i].key == key {
|
||||
f.lines[i].value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
f.lines = append(f.lines, fmLine{key: key, value: value})
|
||||
}
|
||||
|
||||
// render serialises the frontmatter back into a `---`-fenced block. An
|
||||
// empty frontmatter renders to the empty string so bodies without a
|
||||
// header stay header-less.
|
||||
func (f *frontmatter) render() string {
|
||||
if len(f.lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
for _, l := range f.lines {
|
||||
if l.key == "" {
|
||||
b.WriteString(l.raw)
|
||||
} else {
|
||||
b.WriteString(l.key)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(l.value)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("---\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseFrontmatterSplitsHeaderAndBody(t *testing.T) {
|
||||
src := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Title\n\nbody text\n"
|
||||
fm, body := parseFrontmatter(src)
|
||||
|
||||
assert.Equal(t, "jepa-fx", fm.get("wing"))
|
||||
assert.Equal(t, "facts", fm.get("hall"))
|
||||
assert.Equal(t, "2026-01-01T00:00:00Z", fm.get("created_at"))
|
||||
assert.Equal(t, "# Title\n\nbody text\n", body)
|
||||
}
|
||||
|
||||
func TestParseFrontmatterNoHeader(t *testing.T) {
|
||||
src := "# Just a body\n\nno frontmatter here\n"
|
||||
fm, body := parseFrontmatter(src)
|
||||
|
||||
assert.Empty(t, fm.lines)
|
||||
assert.Equal(t, src, body)
|
||||
}
|
||||
|
||||
func TestFrontmatterSetOverridesExistingKey(t *testing.T) {
|
||||
fm, _ := parseFrontmatter("---\nwing: a\nupdated_at: old\n---\nbody\n")
|
||||
fm.set("updated_at", "new")
|
||||
|
||||
assert.Equal(t, "new", fm.get("updated_at"))
|
||||
// No duplicate key.
|
||||
assert.Equal(t, 1, strings.Count(fm.render(), "updated_at:"))
|
||||
}
|
||||
|
||||
func TestFrontmatterSetAppendsNewKey(t *testing.T) {
|
||||
fm, _ := parseFrontmatter("---\nwing: a\n---\nbody\n")
|
||||
fm.set("supersedes", "abc123")
|
||||
|
||||
out := fm.render()
|
||||
assert.Contains(t, out, "wing: a")
|
||||
assert.Contains(t, out, "supersedes: abc123")
|
||||
}
|
||||
|
||||
func TestFrontmatterRenderPreservesCustomFields(t *testing.T) {
|
||||
src := "---\nwing: a\nhall: facts\ncustom_field: keep-me\ntags: [x, y]\n---\nbody\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
fm.set("updated_at", "2026-06-22T00:00:00Z")
|
||||
|
||||
out := fm.render()
|
||||
assert.Contains(t, out, "custom_field: keep-me")
|
||||
assert.Contains(t, out, "tags: [x, y]")
|
||||
assert.Contains(t, out, "updated_at: 2026-06-22T00:00:00Z")
|
||||
}
|
||||
|
||||
func TestFrontmatterRenderRoundTrips(t *testing.T) {
|
||||
src := "---\nwing: a\nhall: facts\n---\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
assert.Equal(t, src, fm.render())
|
||||
}
|
||||
@@ -11,9 +11,11 @@ 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.
|
||||
@@ -21,6 +23,8 @@ type Handler struct {
|
||||
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/.
|
||||
@@ -31,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 {
|
||||
@@ -41,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 {
|
||||
@@ -75,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")
|
||||
@@ -85,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
|
||||
@@ -92,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.
|
||||
@@ -272,6 +429,48 @@ 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
)
|
||||
|
||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||
// content_hash handle: brain_write / brain_update return it, brain_get
|
||||
// recomputes it from the file on disk, and brain_update stamps the prior
|
||||
// note's hash into the new note's `supersedes` frontmatter.
|
||||
func ContentHash(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// resolveWithin maps a brainDir-relative path to an absolute path and
|
||||
// guarantees it does not escape brainDir. Returns the cleaned relPath
|
||||
// (forward-slashed) and the absolute path.
|
||||
func resolveWithin(brainDir, relPath string) (rel, abs string, err error) {
|
||||
clean := filepath.Clean("/" + filepath.ToSlash(relPath))
|
||||
rel = strings.TrimPrefix(clean, "/")
|
||||
abs = filepath.Join(brainDir, filepath.FromSlash(rel))
|
||||
check, err := filepath.Rel(brainDir, abs)
|
||||
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
|
||||
return "", "", fmt.Errorf("path %q escapes brain dir", relPath)
|
||||
}
|
||||
return rel, abs, nil
|
||||
}
|
||||
|
||||
// UpdateNoteOptions identifies the note to supersede and supplies its new
|
||||
// body. Path takes precedence; otherwise the target is resolved from
|
||||
// Wing/Hall/Slug via brain.NotePath.
|
||||
type UpdateNoteOptions struct {
|
||||
Path string // brainDir-relative path; takes precedence over wing/hall/slug
|
||||
Wing string
|
||||
Hall string
|
||||
Slug string
|
||||
Content string // new full body (whole-note replace)
|
||||
Reason string // optional; stamped as supersede_reason
|
||||
}
|
||||
|
||||
// UpdateNote supersedes an existing note in place. It replaces the body
|
||||
// with opts.Content, preserves the existing frontmatter (created_at,
|
||||
// wing, hall, and any custom fields), and stamps updated_at, supersedes
|
||||
// (the prior content hash), and supersede_reason (when given).
|
||||
//
|
||||
// It never creates: if the target does not exist, it returns an error so
|
||||
// the caller can fall back to brain_write. Returns the note's relPath,
|
||||
// the new content hash, and the prior content hash.
|
||||
//
|
||||
// Embeddings are NOT refreshed here. The rewritten file's mtime advances,
|
||||
// which the mtime-driven vectorstore.Sync ticker uses to re-embed it on
|
||||
// its next pass — the same out-of-band mechanism brain_write relies on.
|
||||
func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) {
|
||||
if opts.Content == "" {
|
||||
return "", "", "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
var rel string
|
||||
if opts.Path != "" {
|
||||
rel = opts.Path
|
||||
} else {
|
||||
full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug)
|
||||
if perr != nil {
|
||||
return "", "", "", perr
|
||||
}
|
||||
rel, _ = filepath.Rel(brainDir, full)
|
||||
rel = filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
rel, abs, err := resolveWithin(brainDir, rel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
prior, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
||||
}
|
||||
return "", "", "", fmt.Errorf("read target: %w", err)
|
||||
}
|
||||
priorHash = ContentHash(prior)
|
||||
|
||||
fm, _ := parseFrontmatter(string(prior))
|
||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||
fm.set("supersedes", priorHash)
|
||||
if opts.Reason != "" {
|
||||
fm.set("supersede_reason", opts.Reason)
|
||||
}
|
||||
|
||||
out := []byte(fm.render() + opts.Content)
|
||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||
return "", "", "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
return rel, ContentHash(out), priorHash, nil
|
||||
}
|
||||
|
||||
// ReadNote reads the note at the brainDir-relative relPath and returns
|
||||
// its parsed frontmatter, body, and content hash. It is the read-after-
|
||||
// write primitive behind brain_get: the hash it returns equals the hash
|
||||
// brain_write / brain_update returned for the same bytes.
|
||||
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
|
||||
_, abs, err := resolveWithin(brainDir, relPath)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
raw, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
|
||||
}
|
||||
return nil, "", "", fmt.Errorf("read note: %w", err)
|
||||
}
|
||||
parsed, body := parseFrontmatter(string(raw))
|
||||
fm = make(map[string]string, len(parsed.lines))
|
||||
for _, l := range parsed.lines {
|
||||
if l.key != "" {
|
||||
fm[l.key] = l.value
|
||||
}
|
||||
}
|
||||
return fm, body, ContentHash(raw), nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// seedNote writes a note directly to disk and returns its relPath.
|
||||
func seedNote(t *testing.T, brainDir, rel, content string) string {
|
||||
t.Helper()
|
||||
full := filepath.Join(brainDir, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
||||
return rel
|
||||
}
|
||||
|
||||
func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
||||
|
||||
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "# New\n\nnew body\n",
|
||||
Reason: "facts changed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rel, relPath)
|
||||
assert.NotEmpty(t, hash)
|
||||
assert.NotEmpty(t, priorHash)
|
||||
assert.NotEqual(t, hash, priorHash)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
// Body replaced.
|
||||
assert.Contains(t, s, "# New")
|
||||
assert.NotContains(t, s, "old body")
|
||||
// Prior fields preserved.
|
||||
assert.Contains(t, s, "wing: jepa-fx")
|
||||
assert.Contains(t, s, "hall: facts")
|
||||
assert.Contains(t, s, "created_at: 2026-01-01T00:00:00Z")
|
||||
assert.Contains(t, s, "custom: keep-me")
|
||||
// Supersession stamped.
|
||||
assert.Contains(t, s, "updated_at:")
|
||||
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||
}
|
||||
|
||||
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||
"---\nwing: jepa-fx\nhall: facts\n---\nold\n")
|
||||
|
||||
relPath, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Wing: "jepa-fx", Hall: "facts", Slug: "val-vol",
|
||||
Content: "new\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", relPath)
|
||||
}
|
||||
|
||||
func TestUpdateNoteErrorsOnMissingAndDoesNotCreate(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Wing: "jepa-fx", Hall: "facts", Slug: "ghost",
|
||||
Content: "x\n",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not exist")
|
||||
|
||||
// No file created.
|
||||
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "missing-target update must not create a note")
|
||||
}
|
||||
|
||||
func TestUpdateNoteRejectsTraversal(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: "../escape.md",
|
||||
Content: "x\n",
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadNoteReturnsFrontmatterBodyHash(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/n.md",
|
||||
"---\nwing: jepa-fx\nhall: facts\n---\n# Body\n\ntext\n")
|
||||
|
||||
fm, body, hash, err := ReadNote(brainDir, rel)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jepa-fx", fm["wing"])
|
||||
assert.Equal(t, "facts", fm["hall"])
|
||||
assert.Equal(t, "# Body\n\ntext\n", body)
|
||||
|
||||
// Hash matches ContentHash of the raw bytes on disk (round-trip).
|
||||
raw, _ := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
assert.Equal(t, ContentHash(raw), hash)
|
||||
}
|
||||
|
||||
func TestReadNoteRejectsTraversal(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
_, _, _, err := ReadNote(brainDir, "../../etc/passwd")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||
|
||||
_, hash, _, err := UpdateNote(brainDir, UpdateNoteOptions{Path: rel, Content: "new\n"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, readHash, err := ReadNote(brainDir, rel)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
||||
}
|
||||
|
||||
func TestContentHashStable(t *testing.T) {
|
||||
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
||||
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
||||
assert.True(t, strings.HasPrefix(ContentHash([]byte("")), "")) // hex, non-panicking
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "-")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]]")
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package classification defines the data-sensitivity taxonomy and the
|
||||
// per-wing / per-repo tagging the capture server reads to enforce the I1
|
||||
// sovereignty gate (issue #50, capture spec §4.1).
|
||||
//
|
||||
// The single load-bearing property is fail-safe-to-strictest: a target
|
||||
// with no explicit tag and no known default classifies as Confidential,
|
||||
// never as something more permissive. A missing tag must never silently
|
||||
// downgrade — that would turn the I1 gate into theatre.
|
||||
//
|
||||
// Classification is read from an optional classification.yaml at the
|
||||
// brain root. A central, Flux-reconcilable file is deliberate: it is
|
||||
// auditable in one place (I2/I5), it does not require a live Gitea client
|
||||
// to classify a repo (so this package has no dependency on the gitea
|
||||
// tracker work), and it avoids tagging a wing's _index.md frontmatter —
|
||||
// which BuildWingIndex regenerates and would clobber.
|
||||
package classification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Level is a data-sensitivity tier. Higher is stricter, so the "stricter
|
||||
// wins" rule (spec §4.1 model C) is a plain max.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
Public Level = iota
|
||||
Internal
|
||||
Confidential
|
||||
)
|
||||
|
||||
// String returns the canonical lowercase token for a level.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case Public:
|
||||
return "public"
|
||||
case Internal:
|
||||
return "internal"
|
||||
case Confidential:
|
||||
return "confidential"
|
||||
default:
|
||||
return fmt.Sprintf("level(%d)", int(l))
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel parses a level token (case-insensitive, surrounding space
|
||||
// tolerated). An unknown token is an error — callers must decide what to
|
||||
// do with bad input rather than have it silently coerced.
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "public":
|
||||
return Public, nil
|
||||
case "internal":
|
||||
return Internal, nil
|
||||
case "confidential":
|
||||
return Confidential, nil
|
||||
default:
|
||||
return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stricter returns the more restrictive of two levels.
|
||||
func Stricter(a, b Level) Level {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TargetKind distinguishes the two kinds of capture destination.
|
||||
type TargetKind int
|
||||
|
||||
const (
|
||||
WingTarget TargetKind = iota // a brain wing (insights land here)
|
||||
RepoTarget // a Gitea repo (tickets / summaries land here)
|
||||
)
|
||||
|
||||
// Target names a capture destination to classify.
|
||||
type Target struct {
|
||||
Kind TargetKind
|
||||
Name string
|
||||
}
|
||||
|
||||
// Config holds the explicit per-wing / per-repo classification tags read
|
||||
// from classification.yaml. Absent entries fall through to the built-in
|
||||
// defaults in defaultFor. The zero value (no file) is valid and applies
|
||||
// defaults to everything.
|
||||
type Config struct {
|
||||
wings map[string]Level
|
||||
repos map[string]Level
|
||||
}
|
||||
|
||||
// rawConfig is the on-disk YAML shape: string→string maps, parsed into
|
||||
// validated levels by Load.
|
||||
type rawConfig struct {
|
||||
Wings map[string]string `yaml:"wings"`
|
||||
Repos map[string]string `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Load reads classification.yaml from brainDir. An absent file is not an
|
||||
// error — it yields an empty config where every target classifies by the
|
||||
// built-in defaults. A malformed file, or any unparseable level token in
|
||||
// it, is a hard error: a classification source the server cannot trust
|
||||
// must fail loud, not degrade silently.
|
||||
func Load(brainDir string) (*Config, error) {
|
||||
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||
}
|
||||
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse classification.yaml: %w", err)
|
||||
}
|
||||
for name, lvl := range raw.Wings {
|
||||
parsed, perr := ParseLevel(lvl)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("wing %q: %w", name, perr)
|
||||
}
|
||||
cfg.wings[normalise(name)] = parsed
|
||||
}
|
||||
for name, lvl := range raw.Repos {
|
||||
parsed, perr := ParseLevel(lvl)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("repo %q: %w", name, perr)
|
||||
}
|
||||
cfg.repos[normalise(name)] = parsed
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Derive returns the classification for any target — the function the
|
||||
// capture use-case calls per item.
|
||||
func (c *Config) Derive(t Target) Level {
|
||||
if t.Kind == RepoTarget {
|
||||
return c.Repo(t.Name)
|
||||
}
|
||||
return c.Wing(t.Name)
|
||||
}
|
||||
|
||||
// Wing classifies a brain wing: an explicit tag wins, else defaults.
|
||||
func (c *Config) Wing(name string) Level {
|
||||
if lvl, ok := c.wings[normalise(name)]; ok {
|
||||
return lvl
|
||||
}
|
||||
return defaultFor(name)
|
||||
}
|
||||
|
||||
// Repo classifies a Gitea repo: an explicit tag wins, else defaults.
|
||||
func (c *Config) Repo(name string) Level {
|
||||
if lvl, ok := c.repos[normalise(name)]; ok {
|
||||
return lvl
|
||||
}
|
||||
return defaultFor(name)
|
||||
}
|
||||
|
||||
// defaultFor applies the built-in defaulting rules when a target has no
|
||||
// explicit tag:
|
||||
// - client-* → Confidential (client work is confidential by default)
|
||||
// - hyperguild / homelab → Internal (the operator's own infra)
|
||||
// - everything else → Confidential (fail safe to strictest)
|
||||
func defaultFor(name string) Level {
|
||||
n := normalise(name)
|
||||
if strings.HasPrefix(n, "client-") {
|
||||
return Confidential
|
||||
}
|
||||
switch n {
|
||||
case "hyperguild", "homelab":
|
||||
return Internal
|
||||
default:
|
||||
return Confidential
|
||||
}
|
||||
}
|
||||
|
||||
// normalise lowercases and trims a wing/repo name so matching and the
|
||||
// client-* prefix check are case-insensitive.
|
||||
func normalise(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package classification
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLevelOrderingAndString(t *testing.T) {
|
||||
assert.True(t, Public < Internal)
|
||||
assert.True(t, Internal < Confidential)
|
||||
assert.Equal(t, "public", Public.String())
|
||||
assert.Equal(t, "internal", Internal.String())
|
||||
assert.Equal(t, "confidential", Confidential.String())
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
for s, want := range map[string]Level{
|
||||
"public": Public, "internal": Internal, "confidential": Confidential,
|
||||
"PUBLIC": Public, " Confidential ": Confidential,
|
||||
} {
|
||||
got, err := ParseLevel(s)
|
||||
require.NoError(t, err, s)
|
||||
assert.Equal(t, want, got, s)
|
||||
}
|
||||
_, err := ParseLevel("secret")
|
||||
require.Error(t, err, "unknown level must error, not silently default")
|
||||
_, err = ParseLevel("")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStricterReturnsMax(t *testing.T) {
|
||||
assert.Equal(t, Confidential, Stricter(Internal, Confidential))
|
||||
assert.Equal(t, Confidential, Stricter(Confidential, Public))
|
||||
assert.Equal(t, Internal, Stricter(Public, Internal))
|
||||
assert.Equal(t, Public, Stricter(Public, Public))
|
||||
}
|
||||
|
||||
func TestLoadAbsentFileIsDefaultsOnly(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
require.NoError(t, err, "absent classification.yaml must not be an error — defaults apply")
|
||||
require.NotNil(t, cfg)
|
||||
// Pure defaulting still works.
|
||||
assert.Equal(t, Internal, cfg.Wing("hyperguild"))
|
||||
assert.Equal(t, Confidential, cfg.Wing("anything-unknown"))
|
||||
}
|
||||
|
||||
func TestLoadParsesExplicitTags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), []byte(
|
||||
"wings:\n research-public: public\n hyperguild: confidential\nrepos:\n infra: internal\n research-public: public\n",
|
||||
), 0o644))
|
||||
|
||||
cfg, err := Load(dir)
|
||||
require.NoError(t, err)
|
||||
// Explicit tag wins over the built-in default (hyperguild default is internal).
|
||||
assert.Equal(t, Confidential, cfg.Wing("hyperguild"))
|
||||
// Explicit public is honoured.
|
||||
assert.Equal(t, Public, cfg.Wing("research-public"))
|
||||
assert.Equal(t, Internal, cfg.Repo("infra"))
|
||||
assert.Equal(t, Public, cfg.Repo("research-public"))
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownLevelInFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"),
|
||||
[]byte("wings:\n x: top-secret\n"), 0o644))
|
||||
_, err := Load(dir)
|
||||
require.Error(t, err, "an unparseable level in the config must fail loud, not be ignored")
|
||||
}
|
||||
|
||||
func TestWingDefaulting(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
cases := map[string]Level{
|
||||
"client-seb": Confidential, // client-* → confidential
|
||||
"client-mastercard": Confidential,
|
||||
"hyperguild": Internal,
|
||||
"homelab": Internal,
|
||||
"jepa-fx": Confidential, // unknown → fail safe to strictest
|
||||
"": Confidential, // empty → fail safe
|
||||
}
|
||||
for wing, want := range cases {
|
||||
assert.Equal(t, want, cfg.Wing(wing), "wing %q", wing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoDefaulting(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Confidential, cfg.Repo("client-seb-pipeline"))
|
||||
assert.Equal(t, Internal, cfg.Repo("hyperguild"))
|
||||
assert.Equal(t, Confidential, cfg.Repo("some-unknown-repo"), "untagged repo → confidential (fail safe)")
|
||||
}
|
||||
|
||||
func TestDeriveUnifiedTarget(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Internal, cfg.Derive(Target{Kind: WingTarget, Name: "homelab"}))
|
||||
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: RepoTarget, Name: "client-x"}))
|
||||
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: WingTarget, Name: "untagged"}))
|
||||
}
|
||||
|
||||
func TestCaseInsensitiveMatching(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Confidential, cfg.Wing("Client-SEB"), "client- prefix match is case-insensitive")
|
||||
assert.Equal(t, Internal, cfg.Wing("HyperGuild"))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// CursorStore tracks how far the watcher has ingested into each
|
||||
// session JSONL file. Keyed by (host, file_path) so the same `~/.claude`
|
||||
// path on different hosts doesn't collide and resumability survives
|
||||
// pod restarts. Idempotent Init lives alongside the rest of the
|
||||
// claudewatcher schema; no separate migration framework.
|
||||
type CursorStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewCursorStore opens a pool against dsn. Caller closes the store.
|
||||
func NewCursorStore(ctx context.Context, dsn string) (*CursorStore, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &CursorStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// NewCursorStoreFromPool wraps an existing pool (so the watcher can
|
||||
// share the brain DSN pool with vectorstore/graphstore without a
|
||||
// second connection set). Caller must NOT close the wrapped pool via
|
||||
// the store — close the pool directly.
|
||||
func NewCursorStoreFromPool(pool *pgxpool.Pool) *CursorStore {
|
||||
return &CursorStore{pool: pool}
|
||||
}
|
||||
|
||||
// Close releases the underlying connection pool when this store owns
|
||||
// it. No-op when the pool was injected via NewCursorStoreFromPool —
|
||||
// pgxpool.Close is idempotent so we lean on that.
|
||||
func (s *CursorStore) Close() {
|
||||
if s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Init creates the claude_session_cursors table when missing.
|
||||
func (s *CursorStore) Init(ctx context.Context) error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS claude_session_cursors (
|
||||
host TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
byte_offset BIGINT NOT NULL DEFAULT 0,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (host, file_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS claude_session_cursors_host_idx
|
||||
ON claude_session_cursors (host);
|
||||
`
|
||||
_, err := s.pool.Exec(ctx, ddl)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOffset returns the last recorded byte offset for (host, filePath).
|
||||
// Missing rows are reported as offset=0, ok=false so the caller can
|
||||
// distinguish "never ingested" from "ingested at the start of the
|
||||
// file" (both produce identical behaviour but the metric is useful).
|
||||
func (s *CursorStore) GetOffset(ctx context.Context, host, filePath string) (int64, bool, error) {
|
||||
if host == "" || filePath == "" {
|
||||
return 0, false, errors.New("host and file_path are required")
|
||||
}
|
||||
var offset int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT byte_offset FROM claude_session_cursors WHERE host = $1 AND file_path = $2
|
||||
`, host, filePath).Scan(&offset)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("query: %w", err)
|
||||
}
|
||||
return offset, true, nil
|
||||
}
|
||||
|
||||
// SetOffset writes the new offset for (host, filePath). Used after
|
||||
// every successful parse + ingest batch so a crash mid-file rewinds
|
||||
// only to the last committed checkpoint.
|
||||
func (s *CursorStore) SetOffset(ctx context.Context, host, filePath string, offset int64) error {
|
||||
if host == "" || filePath == "" {
|
||||
return errors.New("host and file_path are required")
|
||||
}
|
||||
if offset < 0 {
|
||||
return errors.New("offset must be >= 0")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO claude_session_cursors (host, file_path, byte_offset, last_seen_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (host, file_path) DO UPDATE
|
||||
SET byte_offset = EXCLUDED.byte_offset,
|
||||
last_seen_at = now()
|
||||
`, host, filePath, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert offset: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package claudewatcher ingests Claude Code session transcripts
|
||||
// (`~/.claude/projects/*/<uuid>.jsonl`) into the brain corpus.
|
||||
//
|
||||
// Schema (observed 2026-05-25 across ~30 session files on koala):
|
||||
//
|
||||
// type=user — user prompts + tool results
|
||||
// type=assistant — model turns; tool_use blocks live in message.content
|
||||
// type=attachment — hook outputs, ingested files
|
||||
// type=system — turn-boundary metadata
|
||||
// type=file-history-snapshot — git-style snapshot of edited files
|
||||
// type=queue-operation, last-prompt, permission-mode, ai-title,
|
||||
// bridge-session — internal bookkeeping, ignored
|
||||
//
|
||||
// The parser is intentionally tolerant: malformed lines are skipped
|
||||
// (caller logs and advances), missing optional fields default to "",
|
||||
// and unknown `type` values are returned as Turn entries with
|
||||
// `Skip=true` so callers can filter cheaply.
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Turn is one parsed JSONL entry from a Claude Code session log.
|
||||
//
|
||||
// Skip is true for entry types we never want to ingest (queue
|
||||
// bookkeeping, snapshots, etc.). Callers fast-path these without
|
||||
// running the scrubber or classifier.
|
||||
type Turn struct {
|
||||
SessionID string
|
||||
Type string
|
||||
ParentUUID string
|
||||
Timestamp time.Time
|
||||
Cwd string
|
||||
GitBranch string
|
||||
Content string // plain-text projection of the entry, ready for the scrubber/classifier
|
||||
ToolName string // populated when an assistant turn invokes a tool
|
||||
OffsetAfter int64 // byte offset in the file just past this entry
|
||||
Skip bool
|
||||
ParseWarning string // non-empty when the entry parsed but had a sub-field we couldn't normalise
|
||||
}
|
||||
|
||||
// ParseStream reads JSONL lines from r starting at startOffset and
|
||||
// invokes emit for each parsed entry. emit may return ErrStop to
|
||||
// terminate the scan cleanly. Other emit errors propagate.
|
||||
//
|
||||
// startOffset is informational — the caller is expected to have already
|
||||
// seeked the underlying reader to that offset. ParseStream adds the
|
||||
// number of bytes consumed per line to it to compute Turn.OffsetAfter.
|
||||
//
|
||||
// Lines that fail to unmarshal are logged via warnf and skipped; they
|
||||
// do NOT advance OffsetAfter past the malformed line by themselves,
|
||||
// but the next valid line resumes correctly because bufio.Scanner
|
||||
// preserves stream position.
|
||||
func ParseStream(
|
||||
r io.Reader,
|
||||
startOffset int64,
|
||||
warnf func(format string, args ...any),
|
||||
emit func(Turn) error,
|
||||
) (int64, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // some lines are big (tool outputs)
|
||||
|
||||
offset := startOffset
|
||||
for scanner.Scan() {
|
||||
raw := scanner.Bytes()
|
||||
lineLen := int64(len(raw)) + 1 // +1 for the newline
|
||||
t, err := parseTurn(raw)
|
||||
if err != nil {
|
||||
if warnf != nil {
|
||||
warnf("parse: %v (%d bytes)", err, len(raw))
|
||||
}
|
||||
offset += lineLen
|
||||
continue
|
||||
}
|
||||
t.OffsetAfter = offset + lineLen
|
||||
if err := emit(t); err != nil {
|
||||
if errors.Is(err, ErrStop) {
|
||||
return t.OffsetAfter, nil
|
||||
}
|
||||
return offset, fmt.Errorf("emit: %w", err)
|
||||
}
|
||||
offset = t.OffsetAfter
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return offset, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
// ErrStop terminates a ParseStream loop without surfacing an error.
|
||||
var ErrStop = errors.New("claudewatcher: stop")
|
||||
|
||||
// rawEntry is a permissive shape that covers every type observed in
|
||||
// the JSONL files. Fields we don't care about are intentionally
|
||||
// omitted to keep the unmarshal cheap.
|
||||
type rawEntry struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ParentUUID string `json:"parentUuid"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Attachment json.RawMessage `json:"attachment"`
|
||||
Content string `json:"content"` // queue-operation
|
||||
LastPrompt string `json:"lastPrompt"` // last-prompt
|
||||
Subtype string `json:"subtype"` // system
|
||||
}
|
||||
|
||||
// skipTypes lists every entry type we want to never ingest. Marked Skip
|
||||
// at parse time so the caller's filter is a single boolean check.
|
||||
var skipTypes = map[string]struct{}{
|
||||
"queue-operation": {},
|
||||
"last-prompt": {},
|
||||
"permission-mode": {},
|
||||
"ai-title": {},
|
||||
"bridge-session": {},
|
||||
"file-history-snapshot": {},
|
||||
}
|
||||
|
||||
func parseTurn(raw []byte) (Turn, error) {
|
||||
var e rawEntry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
return Turn{}, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
t := Turn{
|
||||
Type: e.Type,
|
||||
SessionID: e.SessionID,
|
||||
ParentUUID: e.ParentUUID,
|
||||
Cwd: e.Cwd,
|
||||
GitBranch: e.GitBranch,
|
||||
}
|
||||
if _, skip := skipTypes[e.Type]; skip {
|
||||
t.Skip = true
|
||||
return t, nil
|
||||
}
|
||||
if e.Timestamp != "" {
|
||||
if ts, err := time.Parse(time.RFC3339Nano, e.Timestamp); err == nil {
|
||||
t.Timestamp = ts
|
||||
} else {
|
||||
t.ParseWarning = "timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
switch e.Type {
|
||||
case "user":
|
||||
t.Content = extractMessageText(e.Message)
|
||||
case "assistant":
|
||||
t.Content, t.ToolName = extractAssistantTurn(e.Message)
|
||||
case "attachment":
|
||||
t.Content = extractAttachmentText(e.Attachment)
|
||||
case "system":
|
||||
t.Content = "[system " + e.Subtype + "]"
|
||||
default:
|
||||
// Unknown type — keep the row but mark Skip so callers ignore.
|
||||
t.Skip = true
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// extractMessageText pulls the textual projection out of a user/assistant
|
||||
// message field. The shape is the Anthropic Messages API content-block
|
||||
// array (an array of {type, text|tool_use|tool_result, ...}). We
|
||||
// concatenate every text-bearing block and ignore the rest.
|
||||
func extractMessageText(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Stop string `json:"stop_reason"`
|
||||
Model string `json:"model"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
Meta map[string]string `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
// Some user turns have message as plain string.
|
||||
var s string
|
||||
if err2 := json.Unmarshal(raw, &s); err2 == nil {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// Content can be a string OR an array.
|
||||
var asString string
|
||||
if err := json.Unmarshal(msg.Content, &asString); err == nil {
|
||||
return asString
|
||||
}
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
sb.WriteString(b.Text)
|
||||
sb.WriteByte('\n')
|
||||
case "tool_result":
|
||||
// Tool result content may itself be a string or array of blocks.
|
||||
var s string
|
||||
if err := json.Unmarshal(b.Content, &s); err == nil {
|
||||
sb.WriteString("[tool_result] ")
|
||||
sb.WriteString(s)
|
||||
sb.WriteByte('\n')
|
||||
continue
|
||||
}
|
||||
var sub []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(b.Content, &sub); err == nil {
|
||||
for _, s := range sub {
|
||||
if s.Type == "text" {
|
||||
sb.WriteString("[tool_result] ")
|
||||
sb.WriteString(s.Text)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// extractAssistantTurn pulls text + the first tool name (if any) from
|
||||
// an assistant content-block array. Multi-tool turns lose the second
|
||||
// name; the goal is signal for classification, not perfect fidelity.
|
||||
func extractAssistantTurn(raw json.RawMessage) (string, string) {
|
||||
if len(raw) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
var msg struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Tool json.RawMessage `json:"input"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
var firstTool string
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
sb.WriteString(b.Text)
|
||||
sb.WriteByte('\n')
|
||||
case "tool_use":
|
||||
if firstTool == "" {
|
||||
firstTool = b.Name
|
||||
}
|
||||
sb.WriteString("[tool_use:")
|
||||
sb.WriteString(b.Name)
|
||||
sb.WriteString("]\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), firstTool
|
||||
}
|
||||
|
||||
// extractAttachmentText pulls text content from an attachment payload,
|
||||
// or returns a short tag when the attachment is a hook event.
|
||||
func extractAttachmentText(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var a struct {
|
||||
Type string `json:"type"`
|
||||
HookName string `json:"hookName"`
|
||||
HookEvent string `json:"hookEvent"`
|
||||
Content string `json:"content"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return ""
|
||||
}
|
||||
if a.Content != "" {
|
||||
return a.Content
|
||||
}
|
||||
if a.Text != "" {
|
||||
return a.Text
|
||||
}
|
||||
if a.HookName != "" {
|
||||
return "[hook " + a.HookEvent + ":" + a.HookName + "]"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func collect(t *testing.T, body string) ([]Turn, int64, error) {
|
||||
t.Helper()
|
||||
var out []Turn
|
||||
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||
out = append(out, tr)
|
||||
return nil
|
||||
})
|
||||
return out, end, err
|
||||
}
|
||||
|
||||
func TestParseStream_UserTurnStringContent(t *testing.T) {
|
||||
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":"hello world"}
|
||||
`
|
||||
turns, end, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.Equal(t, "user", turns[0].Type)
|
||||
assert.Equal(t, "S", turns[0].SessionID)
|
||||
assert.Equal(t, "hello world", turns[0].Content)
|
||||
assert.False(t, turns[0].Skip)
|
||||
assert.Equal(t, int64(len(body)), end)
|
||||
}
|
||||
|
||||
func TestParseStream_UserTurnContentBlocks(t *testing.T) {
|
||||
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"role":"user","content":[{"type":"text","text":"line 1"},{"type":"text","text":"line 2"}]}}
|
||||
`
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.Equal(t, "line 1\nline 2", turns[0].Content)
|
||||
}
|
||||
|
||||
func TestParseStream_AssistantToolUse(t *testing.T) {
|
||||
body := `{"type":"assistant","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"text","text":"calling now"},{"type":"tool_use","name":"Edit","input":{}}]}}
|
||||
`
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.Equal(t, "Edit", turns[0].ToolName)
|
||||
assert.Contains(t, turns[0].Content, "calling now")
|
||||
assert.Contains(t, turns[0].Content, "[tool_use:Edit]")
|
||||
}
|
||||
|
||||
func TestParseStream_AssistantToolResult(t *testing.T) {
|
||||
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"tool_result","content":"output of cmd"}]}}
|
||||
`
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.Contains(t, turns[0].Content, "[tool_result] output of cmd")
|
||||
}
|
||||
|
||||
func TestParseStream_SkipsBookkeepingTypes(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`{"type":"queue-operation","sessionId":"S","content":"x"}`,
|
||||
`{"type":"last-prompt","sessionId":"S","lastPrompt":"y"}`,
|
||||
`{"type":"permission-mode","sessionId":"S","permissionMode":"auto"}`,
|
||||
`{"type":"ai-title","sessionId":"S","aiTitle":"My session"}`,
|
||||
`{"type":"file-history-snapshot","messageId":"abc"}`,
|
||||
}, "\n") + "\n"
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 5)
|
||||
for _, tr := range turns {
|
||||
assert.True(t, tr.Skip, "expected Skip=true for %q", tr.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStream_UnknownTypeIsSkip(t *testing.T) {
|
||||
body := `{"type":"future-thing","sessionId":"S"}` + "\n"
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.True(t, turns[0].Skip)
|
||||
}
|
||||
|
||||
func TestParseStream_MalformedLineIsSkippedNotFatal(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`{"type":"user","sessionId":"S","message":"first"}`,
|
||||
`{not valid json`,
|
||||
`{"type":"user","sessionId":"S","message":"third"}`,
|
||||
}, "\n") + "\n"
|
||||
var warnings int
|
||||
var turns []Turn
|
||||
_, err := ParseStream(strings.NewReader(body), 0, func(format string, args ...any) {
|
||||
warnings++
|
||||
}, func(tr Turn) error {
|
||||
turns = append(turns, tr)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 2, "first + third should make it through")
|
||||
assert.Equal(t, 1, warnings)
|
||||
}
|
||||
|
||||
func TestParseStream_EmitErrStopHaltsCleanly(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`{"type":"user","sessionId":"S","message":"a"}`,
|
||||
`{"type":"user","sessionId":"S","message":"b"}`,
|
||||
`{"type":"user","sessionId":"S","message":"c"}`,
|
||||
}, "\n") + "\n"
|
||||
count := 0
|
||||
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||
count++
|
||||
if count == 2 {
|
||||
return ErrStop
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Greater(t, end, int64(0))
|
||||
}
|
||||
|
||||
func TestParseStream_EmitOtherErrorPropagates(t *testing.T) {
|
||||
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n"
|
||||
want := errors.New("boom")
|
||||
_, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||
return want
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "boom")
|
||||
}
|
||||
|
||||
func TestParseStream_AttachmentHookEvent(t *testing.T) {
|
||||
body := `{"type":"attachment","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"hook body"}}
|
||||
`
|
||||
turns, _, err := collect(t, body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 1)
|
||||
assert.Equal(t, "hook body", turns[0].Content)
|
||||
}
|
||||
|
||||
func TestParseStream_OffsetAdvances(t *testing.T) {
|
||||
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n" +
|
||||
`{"type":"user","sessionId":"S","message":"b"}` + "\n"
|
||||
var offsets []int64
|
||||
_, err := ParseStream(strings.NewReader(body), 100, nil, func(tr Turn) error {
|
||||
offsets = append(offsets, tr.OffsetAfter)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, offsets, 2)
|
||||
assert.Greater(t, offsets[0], int64(100))
|
||||
assert.Greater(t, offsets[1], offsets[0])
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Scrubber drops any turn whose content matches a known-bad pattern.
|
||||
// Fail-closed by design: we'd rather lose signal than ingest credentials
|
||||
// into a public-readable brain. The caller logs the drop reason.
|
||||
//
|
||||
// Rules cover the credential shapes most common to leak through Claude
|
||||
// Code sessions: bearer tokens, postgres URIs with embedded auth, OAuth
|
||||
// secret values, SOPS-encrypted secret blobs (we don't want the
|
||||
// ciphertext either — it's a marker that the original message contained
|
||||
// secret state), PEM-encoded private keys, and the explicit env-var
|
||||
// naming conventions used in the homelab.
|
||||
//
|
||||
// Pattern philosophy: match by shape, not by content. A 40-char hex
|
||||
// string in isolation is fine; the same string after `Authorization:
|
||||
// Bearer ` is not. Tuned to catch known leak vectors from prior
|
||||
// secret-hygiene incidents (POSTGRES_PASSWORD via kubectl exec env,
|
||||
// INFRA_MCP_TOKEN via sops -d output) without dropping every Edit on a
|
||||
// config file.
|
||||
|
||||
// Rule is a single named regex with a redact hint shown in the warn log.
|
||||
type Rule struct {
|
||||
Name string
|
||||
RE *regexp.Regexp
|
||||
}
|
||||
|
||||
// DefaultRules is the regex set applied by Scrub. Mutable for tests but
|
||||
// callers should treat it as read-only at runtime.
|
||||
var DefaultRules = []Rule{
|
||||
// authorization-header is checked before the bare bearer rule so
|
||||
// contextual hits ("Authorization: Bearer X") report the more
|
||||
// specific match name in logs.
|
||||
{Name: "authorization-header", RE: regexp.MustCompile(`(?i)Authorization\s*:\s*[A-Za-z]+\s+\S{8,}`)},
|
||||
{Name: "bearer-token", RE: regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]{16,}`)},
|
||||
// JWT (header.payload.sig), e.g. a Dex/OAuth token dumped to stdout
|
||||
// without a "Bearer " prefix. Both header and payload base64url-encode
|
||||
// JSON, so both segments begin with "eyJ".
|
||||
{Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)},
|
||||
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
|
||||
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
|
||||
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
|
||||
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
||||
// 1Password service-account token (ops_<base64url>). Long, high-value root
|
||||
// credential; guard the bare value (the _TOKEN= form also hits homelab-env-token).
|
||||
{Name: "op-service-account", RE: regexp.MustCompile(`\bops_[A-Za-z0-9_\-]{40,}`)},
|
||||
// No leading \b: a shell mangle can glue the key to a preceding word
|
||||
// ("yes"+"sk-...") which has no word boundary, and that exact case
|
||||
// leaked a LiteLLM master key past this rule (2026-06-11). Match the
|
||||
// sk- shape wherever it appears; the {32,} length floor keeps short
|
||||
// "task-"/"disk-" words from tripping it.
|
||||
{Name: "openai-sk", RE: regexp.MustCompile(`sk-(?:proj-)?[A-Za-z0-9]{32,}`)},
|
||||
{Name: "anthropic-sk", RE: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_\-]{32,}\b`)},
|
||||
{Name: "aws-access-key", RE: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
|
||||
{Name: "homelab-env-token", RE: regexp.MustCompile(`(?i)(?:_TOKEN|_PASSWORD|_API_KEY|_SECRET)\s*[:=]\s*['"]?[A-Za-z0-9._/+\-]{12,}`)},
|
||||
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
|
||||
}
|
||||
|
||||
// extraRules is appended to DefaultRules at process startup via
|
||||
// RegisterRule. The mutex guards concurrent RegisterRule calls (rare)
|
||||
// against concurrent Scrub reads (hot path). Scrub takes a read lock
|
||||
// only when extraRules is non-empty, so steady-state cost is zero
|
||||
// when no client-name guard is configured.
|
||||
var (
|
||||
extraRulesMu sync.RWMutex
|
||||
extraRules []Rule
|
||||
)
|
||||
|
||||
// RegisterRule appends a runtime-configured regex to the scrubber's
|
||||
// rule set. Used by main to inject client-name guards from
|
||||
// CLAUDE_INGEST_CLIENT_BLOCK env var (or equivalent SOPS-encrypted
|
||||
// secret) without baking client identities into source code.
|
||||
//
|
||||
// pattern is compiled as-is — callers wrap with `\b...\b` and case
|
||||
// flags as needed. Duplicate names are accepted (rules are positional);
|
||||
// the second registration just fires after the first.
|
||||
func RegisterRule(name, pattern string) error {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile rule %q: %w", name, err)
|
||||
}
|
||||
extraRulesMu.Lock()
|
||||
extraRules = append(extraRules, Rule{Name: name, RE: re})
|
||||
extraRulesMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetExtraRules clears every RegisterRule-added rule. Test-only.
|
||||
func ResetExtraRules() {
|
||||
extraRulesMu.Lock()
|
||||
extraRules = nil
|
||||
extraRulesMu.Unlock()
|
||||
}
|
||||
|
||||
// Scrub reports the first matching rule, or empty when content is clean.
|
||||
// Empty string is treated as clean. Caller decides what to do on a hit;
|
||||
// the convention in claudewatcher is to drop the turn entirely and emit
|
||||
// a slog.Warn naming the rule.
|
||||
//
|
||||
// Rule order: DefaultRules first (credential shapes), then runtime
|
||||
// RegisterRule additions (client-name guards). Credential leaks
|
||||
// outrank client-name hits in the log because they're strictly more
|
||||
// dangerous.
|
||||
func Scrub(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range DefaultRules {
|
||||
if r.RE.MatchString(content) {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
extraRulesMu.RLock()
|
||||
defer extraRulesMu.RUnlock()
|
||||
for _, r := range extraRules {
|
||||
if r.RE.MatchString(content) {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScrub_PoisonedFixtures(t *testing.T) {
|
||||
// One representative bad-string per rule. If a rule fires for the
|
||||
// wrong content shape later, this table localises the regression.
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{"bearer-token", "curl -H 'Authorization: Bearer abcdef1234567890ghijklmnop'", "authorization-header"},
|
||||
{"bearer-no-header", "header = Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig", "bearer-token"},
|
||||
{"postgres-uri", "DATABASE_URL=postgres://user:s3cret@10.0.1.20:5432/brain", "postgres-uri-with-password"},
|
||||
{"private-key", "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA", "private-key"},
|
||||
{"ssh-public", "deploy: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK1234567890abcdefghij user@host", "ssh-key"},
|
||||
{"github-pat-classic", "GH_TOKEN=ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt", "github-pat"},
|
||||
{"openai-key", "OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII", "openai-sk"},
|
||||
{"anthropic-key", "ANTHROPIC_API_KEY=sk-ant-api03-aaaaBBBBccccDDDDeeeeFFFFggggHHHHiiiiJJJJkkkk", "anthropic-sk"},
|
||||
{"aws-access-key", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "aws-access-key"},
|
||||
{"homelab-env", "POSTGRES_PASSWORD=hunter2supersecretvalue", "homelab-env-token"},
|
||||
{"sops-marker", "value: ENC[AES256_GCM,data:abc123def456,iv:zzz]", "sops-encrypted-marker"},
|
||||
// Regression: a shell mangle glued the key to a preceding word
|
||||
// ("yes"+"sk-..."), defeating the leading \b in the sk- rule and
|
||||
// leaking a LiteLLM master key past the scrubber (2026-06-11).
|
||||
{"sk-glued-to-word", "master key resolved: yessk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"},
|
||||
{"sk-standalone-hex", "sk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"},
|
||||
// Bare JWT not preceded by "Bearer" (e.g. a Dex token dumped to stdout).
|
||||
{"jwt-bare", "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQabcdef", "jwt"},
|
||||
// 1Password service-account token (ops_<base64url>), env-assigned and bare.
|
||||
// Both hit the dedicated op-service-account rule (ordered before the
|
||||
// generic homelab-env-token). Guards ~/.zshrc reads etc. (2026-06-14).
|
||||
{"op-sa-env", "export OP_SERVICE_ACCOUNT_TOKEN=ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSJ9", "op-service-account"},
|
||||
{"op-sa-bare", "ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSIsInVzZXJBdXRoIjp7fX0aGVsbG8", "op-service-account"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Scrub(tc.content)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"plain text with no credentials",
|
||||
"a 40 char hex string aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is fine in isolation",
|
||||
"`Bearer` token mentioned in docs without an actual value",
|
||||
"file at ~/.ssh/id_ed25519",
|
||||
"the function Authorization() takes no args",
|
||||
"comment: see API key in 1Password",
|
||||
// loosened sk- rule must not trip on short "task-"/"disk-" words
|
||||
"run task-build then task-test in the pipeline",
|
||||
"mounted /dev/disk-by-id/wwn-0x5000",
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Empty(t, Scrub(c), "expected clean for %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrub_FirstMatchWins(t *testing.T) {
|
||||
// Content matching multiple rules: report the first rule order in
|
||||
// DefaultRules. Stability matters for log triage.
|
||||
content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt"
|
||||
assert.Equal(t, "authorization-header", Scrub(content))
|
||||
}
|
||||
|
||||
func TestRegisterRule_ClientNameGuard(t *testing.T) {
|
||||
t.Cleanup(ResetExtraRules)
|
||||
require := func(err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
require(RegisterRule("client-name", `(?i)\b(SEB|Mastercard)\b`))
|
||||
|
||||
// Hits — case variations + word-boundary respect.
|
||||
for _, hit := range []string{
|
||||
"mentioned SEB in this commit",
|
||||
"the Mastercard project deadline",
|
||||
"working on mastercard scope",
|
||||
"SEB internal review",
|
||||
} {
|
||||
assert.Equal(t, "client-name", Scrub(hit), "should match %q", hit)
|
||||
}
|
||||
|
||||
// Misses — substring within a longer word should NOT match
|
||||
// thanks to \b. "Sebastian" contains "seb" but \b prevents hit.
|
||||
for _, miss := range []string{
|
||||
"Sebastian wrote the docs",
|
||||
"unrelated text",
|
||||
"researcher",
|
||||
"https://example.com/search?seb=1", // 'seb' bounded by ?=, still matches \b
|
||||
} {
|
||||
got := Scrub(miss)
|
||||
if miss == "https://example.com/search?seb=1" {
|
||||
// `seb=` has word-boundary at '='; this DOES match \bseb\b.
|
||||
// Accept either outcome; document the tradeoff.
|
||||
assert.Contains(t, []string{"", "client-name"}, got)
|
||||
continue
|
||||
}
|
||||
assert.Empty(t, got, "should NOT match %q", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRule_CredentialsTakePrecedence(t *testing.T) {
|
||||
t.Cleanup(ResetExtraRules)
|
||||
require := func(err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
require(RegisterRule("client-name", `\b(SEB)\b`))
|
||||
|
||||
// Content matches both a credential rule AND a client rule —
|
||||
// credential rule wins by ordering, so log triage points at the
|
||||
// strictly more dangerous leak.
|
||||
content := "SEB project uses OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII"
|
||||
assert.Equal(t, "openai-sk", Scrub(content))
|
||||
}
|
||||
|
||||
func TestRegisterRule_RejectsInvalidPattern(t *testing.T) {
|
||||
t.Cleanup(ResetExtraRules)
|
||||
err := RegisterRule("bad", "[unclosed")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sink consumes batches of ingest-ready turns from the watcher. The
|
||||
// production implementation builds wiki pages and calls pipeline.RunRaw
|
||||
// against the brain. Tests substitute a counter.
|
||||
//
|
||||
// A Batch represents the turns ingested from one session file between
|
||||
// two cursor checkpoints. Implementations must be idempotent — the
|
||||
// watcher only advances the cursor on a nil return.
|
||||
type Sink interface {
|
||||
Ingest(ctx context.Context, b Batch) error
|
||||
}
|
||||
|
||||
// Batch is a per-file slice of turns plus identifying metadata.
|
||||
type Batch struct {
|
||||
Host string // origin host, e.g. "koala"
|
||||
FilePath string // absolute path to the source .jsonl file
|
||||
SessionID string // first session_id seen in the batch
|
||||
ProjectID string // basename of the parent dir, e.g. "-home-mathias-dev"
|
||||
Turns []Turn // never empty; caller filters Skip + scrubber matches
|
||||
}
|
||||
|
||||
// Config drives one Watch loop. SessionsDir is the absolute path to the
|
||||
// Claude Code projects directory (~/.claude/projects). Host is the
|
||||
// label written into cursors and ingested page frontmatter. Interval
|
||||
// is the poll cadence; a zero or negative value disables the loop.
|
||||
//
|
||||
// Sink is required. Cursors is optional — when nil the watcher
|
||||
// re-reads from byte 0 on every tick (useful for first-run testing
|
||||
// without a postgres dependency).
|
||||
type Config struct {
|
||||
SessionsDir string
|
||||
Host string
|
||||
Interval time.Duration
|
||||
Sink Sink
|
||||
Cursors *CursorStore
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||
// on shutdown. Each tick walks SessionsDir for *.jsonl files, advances
|
||||
// each file's cursor, and emits one Batch per file with new turns.
|
||||
// Errors during a single file's parse or ingest are logged but do not
|
||||
// abort the loop — a single bad file shouldn't block the others.
|
||||
func Watch(ctx context.Context, cfg Config) error {
|
||||
if cfg.SessionsDir == "" {
|
||||
return fmt.Errorf("sessions dir is required")
|
||||
}
|
||||
if cfg.Sink == nil {
|
||||
return fmt.Errorf("sink is required")
|
||||
}
|
||||
if cfg.Interval <= 0 {
|
||||
return fmt.Errorf("interval must be positive")
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "unknown"
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
cfg.Logger.Info("claudewatcher: started",
|
||||
"sessions_dir", cfg.SessionsDir,
|
||||
"host", cfg.Host,
|
||||
"interval", cfg.Interval)
|
||||
|
||||
ticker := time.NewTicker(cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
// Run an immediate first sweep so first-launch users don't wait one
|
||||
// tick before anything happens.
|
||||
runTick(ctx, cfg)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
runTick(ctx, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||
// TickOnce.
|
||||
func runTick(ctx context.Context, cfg Config) {
|
||||
files, err := listSessionFiles(cfg.SessionsDir)
|
||||
if err != nil {
|
||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := processFile(ctx, cfg, f); err != nil {
|
||||
cfg.Logger.Warn("claudewatcher: file failed",
|
||||
"path", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TickOnce runs one sweep synchronously and returns. Used by tests +
|
||||
// by ad-hoc CLI invocations.
|
||||
func TickOnce(ctx context.Context, cfg Config) error {
|
||||
if cfg.SessionsDir == "" || cfg.Sink == nil {
|
||||
return fmt.Errorf("config invalid")
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "unknown"
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
runTick(ctx, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listSessionFiles(root string) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".jsonl") {
|
||||
return nil
|
||||
}
|
||||
out = append(out, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk %s: %w", root, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func processFile(ctx context.Context, cfg Config, path string) error {
|
||||
startOffset := int64(0)
|
||||
if cfg.Cursors != nil {
|
||||
off, _, err := cfg.Cursors.GetOffset(ctx, cfg.Host, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get cursor: %w", err)
|
||||
}
|
||||
startOffset = off
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if stat.Size() <= startOffset {
|
||||
return nil // nothing new
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if _, err := f.Seek(startOffset, 0); err != nil {
|
||||
return fmt.Errorf("seek: %w", err)
|
||||
}
|
||||
|
||||
var keep []Turn
|
||||
var sessionID string
|
||||
var droppedScrub int
|
||||
endOffset, err := ParseStream(f, startOffset,
|
||||
func(format string, args ...any) {
|
||||
cfg.Logger.Warn(fmt.Sprintf("claudewatcher: parse: "+format, args...))
|
||||
},
|
||||
func(t Turn) error {
|
||||
if t.Skip || t.Content == "" {
|
||||
return nil
|
||||
}
|
||||
if rule := Scrub(t.Content); rule != "" {
|
||||
droppedScrub++
|
||||
cfg.Logger.Warn("claudewatcher: turn dropped by scrubber",
|
||||
"rule", rule, "path", path, "session_id", t.SessionID)
|
||||
return nil
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = t.SessionID
|
||||
}
|
||||
keep = append(keep, t)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse stream: %w", err)
|
||||
}
|
||||
|
||||
if len(keep) == 0 {
|
||||
if cfg.Cursors != nil {
|
||||
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
|
||||
return fmt.Errorf("advance cursor (no-turns): %w", err)
|
||||
}
|
||||
}
|
||||
if droppedScrub > 0 {
|
||||
cfg.Logger.Info("claudewatcher: only scrubbed turns this tick",
|
||||
"path", path, "dropped", droppedScrub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
batch := Batch{
|
||||
Host: cfg.Host,
|
||||
FilePath: path,
|
||||
SessionID: sessionID,
|
||||
ProjectID: filepath.Base(filepath.Dir(path)),
|
||||
Turns: keep,
|
||||
}
|
||||
if err := cfg.Sink.Ingest(ctx, batch); err != nil {
|
||||
return fmt.Errorf("sink ingest: %w", err)
|
||||
}
|
||||
if cfg.Cursors != nil {
|
||||
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
|
||||
return fmt.Errorf("advance cursor: %w", err)
|
||||
}
|
||||
}
|
||||
cfg.Logger.Info("claudewatcher: ingested batch",
|
||||
"path", path, "session_id", sessionID,
|
||||
"turns_kept", len(keep), "dropped_scrub", droppedScrub,
|
||||
"new_offset", endOffset)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// memSink captures batches without touching postgres. Thread-safe so
|
||||
// TickOnce can run from any goroutine in concurrent tests.
|
||||
type memSink struct {
|
||||
mu sync.Mutex
|
||||
batches []Batch
|
||||
failOn string // file basename to error on
|
||||
}
|
||||
|
||||
func (m *memSink) Ingest(_ context.Context, b Batch) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.failOn != "" && strings.Contains(b.FilePath, m.failOn) {
|
||||
return assert.AnError
|
||||
}
|
||||
m.batches = append(m.batches, b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSession(t *testing.T, dir, sessionID string, lines []string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, sessionID+".jsonl")
|
||||
body := strings.Join(lines, "\n") + "\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestTickOnce_NoCursorReingestsEverythingEveryTick(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
projectDir := filepath.Join(tmp, "-home-mathias-dev")
|
||||
require.NoError(t, os.MkdirAll(projectDir, 0o755))
|
||||
writeSession(t, projectDir, "sess1", []string{
|
||||
`{"type":"user","sessionId":"sess1","message":"first prompt"}`,
|
||||
`{"type":"assistant","sessionId":"sess1","message":{"content":[{"type":"text","text":"first answer"}]}}`,
|
||||
})
|
||||
|
||||
sink := &memSink{}
|
||||
cfg := Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
}
|
||||
require.NoError(t, TickOnce(context.Background(), cfg))
|
||||
require.NoError(t, TickOnce(context.Background(), cfg))
|
||||
|
||||
require.Len(t, sink.batches, 2, "no cursor => re-emits same batch every tick")
|
||||
assert.Equal(t, "sess1", sink.batches[0].SessionID)
|
||||
assert.Equal(t, "koala", sink.batches[0].Host)
|
||||
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||
assert.Len(t, sink.batches[0].Turns, 2)
|
||||
}
|
||||
|
||||
func TestTickOnce_FiltersSkipTurnsAndScrubberMatches(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||
writeSession(t, proj, "sess-scrub", []string{
|
||||
`{"type":"queue-operation","sessionId":"sess-scrub","content":"x"}`, // Skip
|
||||
`{"type":"user","sessionId":"sess-scrub","message":"normal prompt"}`,
|
||||
`{"type":"assistant","sessionId":"sess-scrub","message":{"content":[{"type":"text","text":"value POSTGRES_PASSWORD=hunter2supersecretvalue"}]}}`, // scrubbed
|
||||
})
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||
}))
|
||||
require.Len(t, sink.batches, 1)
|
||||
turns := sink.batches[0].Turns
|
||||
require.Len(t, turns, 1, "skip + scrubbed turns must not reach the sink")
|
||||
assert.Equal(t, "user", turns[0].Type)
|
||||
}
|
||||
|
||||
func TestTickOnce_AllScrubbedNoBatchEmitted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||
writeSession(t, proj, "all-bad", []string{
|
||||
`{"type":"user","sessionId":"all-bad","message":"Authorization: Bearer abcdef1234567890ghijklmnop"}`,
|
||||
})
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||
}))
|
||||
assert.Empty(t, sink.batches, "no usable turns => no batch")
|
||||
}
|
||||
|
||||
func TestTickOnce_IgnoresNonJsonlFiles(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(proj, "README.md"), []byte("ignore me"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(proj, "config.json"), []byte("{}"), 0o644))
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||
}))
|
||||
assert.Empty(t, sink.batches)
|
||||
}
|
||||
|
||||
func TestTickOnce_HandlesMultipleProjectsAndSessions(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
projA := filepath.Join(tmp, "-home-mathias-dev")
|
||||
projB := filepath.Join(tmp, "-home-mathias-AI-infra")
|
||||
require.NoError(t, os.MkdirAll(projA, 0o755))
|
||||
require.NoError(t, os.MkdirAll(projB, 0o755))
|
||||
writeSession(t, projA, "a1", []string{`{"type":"user","sessionId":"a1","message":"q1"}`})
|
||||
writeSession(t, projA, "a2", []string{`{"type":"user","sessionId":"a2","message":"q2"}`})
|
||||
writeSession(t, projB, "b1", []string{`{"type":"user","sessionId":"b1","message":"q3"}`})
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||
}))
|
||||
require.Len(t, sink.batches, 3)
|
||||
|
||||
projects := map[string]int{}
|
||||
for _, b := range sink.batches {
|
||||
projects[b.ProjectID]++
|
||||
}
|
||||
assert.Equal(t, 2, projects["-home-mathias-dev"])
|
||||
assert.Equal(t, 1, projects["-home-mathias-AI-infra"])
|
||||
}
|
||||
|
||||
func TestTickOnce_SinkErrorDoesNotKillOtherFiles(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||
writeSession(t, proj, "good", []string{`{"type":"user","sessionId":"good","message":"q"}`})
|
||||
writeSession(t, proj, "bad-session", []string{`{"type":"user","sessionId":"bad-session","message":"q"}`})
|
||||
|
||||
sink := &memSink{failOn: "bad-session"}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||
}))
|
||||
require.Len(t, sink.batches, 1, "good session still ingested")
|
||||
assert.Equal(t, "good", sink.batches[0].SessionID)
|
||||
}
|
||||
|
||||
func TestWatch_RespectsContextCancel(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "-home-mathias-dev"), 0o755))
|
||||
sink := &memSink{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Watch(ctx, Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Interval: 10 * time.Millisecond,
|
||||
Sink: sink,
|
||||
})
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Watch did not return after cancel")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Package graph extracts entity + edge records from brain markdown
|
||||
// documents for the brain_entities / brain_edges relational graph.
|
||||
//
|
||||
// The extractor is pure: it takes markdown bytes and a document path and
|
||||
// returns the entity (one per doc) and the wikilink edges (zero or more)
|
||||
// it found, with source line numbers so the graph store can record
|
||||
// provenance.
|
||||
//
|
||||
// Edge types in v1: only "wikilink" — derived from [[slug]] and
|
||||
// [[slug|Display]] occurrences in the body. Section-header edges are
|
||||
// deferred (see infra#62 grill addendum).
|
||||
package graph
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Entity represents one brain document for graph indexing.
|
||||
//
|
||||
// Slug is the basename without ".md" — the same identity used by
|
||||
// wiki canonicalization and the wikilink target syntax.
|
||||
//
|
||||
// Type categorises the doc into a coarse bucket so callers can filter
|
||||
// graph traversals (e.g. "only entity nodes"). When the doc lives
|
||||
// under brain/wiki/<wing>/<hall>/, Wing and Hall capture the
|
||||
// taxonomy; otherwise they're empty (legacy brain/knowledge/ docs).
|
||||
type Entity struct {
|
||||
DocPath string // forward-slash, relative to brainDir
|
||||
Slug string
|
||||
Type string // "concept" | "entity" | "source" | "hall" | "knowledge"
|
||||
Wing string // optional; from frontmatter or path
|
||||
Hall string // optional; from frontmatter or path
|
||||
Title string // optional; from frontmatter
|
||||
// DIKW tier — infra#72. Empty until M3 migration writes `tier:`
|
||||
// frontmatter to every entry. Path-inferred tier kicks in as a
|
||||
// fallback so the column populates immediately on backfill even
|
||||
// for entries that haven't had their frontmatter rewritten yet.
|
||||
Tier string // "inbox" | "note" | "knowledge"
|
||||
Topic string // kebab-slug; the thing the entry is about
|
||||
}
|
||||
|
||||
// Edge represents a directed relationship between two slugs.
|
||||
//
|
||||
// SrcLine is the 1-indexed line in the source document where the link
|
||||
// was found, so callers can re-find the linking text after an edit.
|
||||
type Edge struct {
|
||||
SrcDoc string // forward-slash, relative to brainDir
|
||||
SrcSlug string // == Entity.Slug for SrcDoc
|
||||
DstSlug string
|
||||
EdgeType string // "wikilink" in v1
|
||||
SrcLine int // 1-indexed
|
||||
}
|
||||
|
||||
// linkRE matches both [[slug]] and [[slug|Display Name]] wikilinks.
|
||||
// Group 1 is the slug; group 2 (if present) is the display.
|
||||
var linkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
|
||||
|
||||
// Extract parses one markdown document and returns its Entity plus the
|
||||
// outgoing wikilink Edges. docPath is forward-slash, relative to
|
||||
// brainDir; content is the raw markdown bytes.
|
||||
//
|
||||
// Returns ok=false when docPath does not yield a usable slug (e.g.
|
||||
// non-markdown file slipped through).
|
||||
func Extract(docPath string, content []byte) (Entity, []Edge, bool) {
|
||||
slug := slugFromPath(docPath)
|
||||
if slug == "" {
|
||||
return Entity{}, nil, false
|
||||
}
|
||||
ent := Entity{DocPath: docPath, Slug: slug}
|
||||
classifyByPath(&ent, docPath)
|
||||
readFrontmatter(&ent, content)
|
||||
inferTierFromPath(&ent, docPath)
|
||||
|
||||
edges := extractEdges(docPath, slug, content)
|
||||
return ent, edges, true
|
||||
}
|
||||
|
||||
// inferTierFromPath fills Tier when frontmatter didn't already set it.
|
||||
// The new layout has dedicated subtrees per tier; pre-migration paths
|
||||
// (knowledge/, wiki/, raw/, sessions/) get their best-guess mapping so
|
||||
// the column populates on backfill before the M3 file moves run.
|
||||
func inferTierFromPath(e *Entity, docPath string) {
|
||||
if e.Tier != "" {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(docPath, "/")
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
switch parts[0] {
|
||||
case "inbox":
|
||||
e.Tier = "inbox"
|
||||
case "notes":
|
||||
e.Tier = "note"
|
||||
case "knowledge":
|
||||
e.Tier = "knowledge"
|
||||
case "wiki":
|
||||
// Pre-M3 wiki layout. Most subdirs are I-level:
|
||||
// wiki/sources/ — synth summaries of raw inbox material
|
||||
// wiki/concepts/ — definitions, not lessons
|
||||
// One exception: wiki/entities/ holds anchor facts about
|
||||
// concrete things (models, services, people) that the eval
|
||||
// expects to surface when queried directly. Those map to K
|
||||
// to match the post-M3 layout target (knowledge/facts/).
|
||||
if len(parts) >= 2 && parts[1] == "entities" {
|
||||
e.Tier = "knowledge"
|
||||
} else {
|
||||
e.Tier = "note"
|
||||
}
|
||||
case "raw", "sessions", "clips":
|
||||
e.Tier = "inbox"
|
||||
}
|
||||
}
|
||||
|
||||
func slugFromPath(docPath string) string {
|
||||
base := filepath.Base(docPath)
|
||||
if !strings.HasSuffix(base, ".md") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(base, ".md")
|
||||
}
|
||||
|
||||
// classifyByPath fills Type / Wing / Hall from the path layout when the
|
||||
// doc lives under brain/wiki/. Layout: wiki/<wing>/<hall>/<slug>.md
|
||||
// or wiki/<bucket>/<slug>.md for the legacy concept/entity/source dirs.
|
||||
//
|
||||
// Files directly under wiki/ (no subdirectory — e.g. wiki/index.md) used
|
||||
// to incorrectly land Type="hall" Wing="index.md" because the path's
|
||||
// second segment was the file itself. Now they fall through to Type
|
||||
// "knowledge" and leave wing/hall to frontmatter.
|
||||
func classifyByPath(e *Entity, docPath string) {
|
||||
parts := strings.Split(docPath, "/")
|
||||
if len(parts) < 2 || parts[0] != "wiki" {
|
||||
e.Type = "knowledge"
|
||||
return
|
||||
}
|
||||
if len(parts) < 3 {
|
||||
// wiki/<slug>.md — no subdirectory. Treat as plain knowledge
|
||||
// and let frontmatter set wing/hall if they're present.
|
||||
e.Type = "knowledge"
|
||||
return
|
||||
}
|
||||
switch parts[1] {
|
||||
case "concepts":
|
||||
e.Type = "concept"
|
||||
case "entities":
|
||||
e.Type = "entity"
|
||||
case "sources":
|
||||
e.Type = "source"
|
||||
default:
|
||||
// wiki/<wing>/<hall>/<slug>.md
|
||||
e.Type = "hall"
|
||||
e.Wing = parts[1]
|
||||
if len(parts) >= 4 {
|
||||
e.Hall = parts[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readFrontmatter pulls title/wing/hall from a YAML frontmatter block.
|
||||
// Frontmatter is optional; missing fields leave the entity unchanged.
|
||||
func readFrontmatter(e *Entity, content []byte) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
inFM := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
if !inFM {
|
||||
inFM = true
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
if !inFM {
|
||||
return
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v := strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "title":
|
||||
if e.Title == "" {
|
||||
e.Title = v
|
||||
}
|
||||
case "wing":
|
||||
if e.Wing == "" {
|
||||
e.Wing = v
|
||||
}
|
||||
case "hall":
|
||||
if e.Hall == "" {
|
||||
e.Hall = v
|
||||
}
|
||||
case "tier":
|
||||
if e.Tier == "" {
|
||||
e.Tier = v
|
||||
}
|
||||
case "topic":
|
||||
if e.Topic == "" {
|
||||
e.Topic = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractEdges(docPath, srcSlug string, content []byte) []Edge {
|
||||
var edges []Edge
|
||||
seen := make(map[string]struct{}) // dedupe (dst, line)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
line := 0
|
||||
for scanner.Scan() {
|
||||
line++
|
||||
matches := linkRE.FindAllStringSubmatch(scanner.Text(), -1)
|
||||
for _, m := range matches {
|
||||
dst := strings.TrimSpace(m[1])
|
||||
if dst == "" || dst == srcSlug {
|
||||
continue
|
||||
}
|
||||
key := dst + "|" + itoa(line)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
edges = append(edges, Edge{
|
||||
SrcDoc: docPath,
|
||||
SrcSlug: srcSlug,
|
||||
DstSlug: dst,
|
||||
EdgeType: "wikilink",
|
||||
SrcLine: line,
|
||||
})
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
// itoa avoids the fmt dependency on a hot path. Single-digit fast path
|
||||
// keeps overhead negligible for typical line counts.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtract_HallDoc(t *testing.T) {
|
||||
content := []byte(`---
|
||||
wing: jepa-fx
|
||||
hall: decisions
|
||||
title: Val Vol Decision
|
||||
---
|
||||
# Val Vol
|
||||
|
||||
See also [[other-decision]] and [[parent-concept|Parent Concept]].
|
||||
|
||||
Linking to [[unrelated]].
|
||||
`)
|
||||
|
||||
ent, edges, ok := Extract("wiki/jepa-fx/decisions/val-vol.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "val-vol", ent.Slug)
|
||||
assert.Equal(t, "hall", ent.Type)
|
||||
assert.Equal(t, "jepa-fx", ent.Wing)
|
||||
assert.Equal(t, "decisions", ent.Hall)
|
||||
assert.Equal(t, "Val Vol Decision", ent.Title)
|
||||
|
||||
require.Len(t, edges, 3)
|
||||
assert.Equal(t, "other-decision", edges[0].DstSlug)
|
||||
assert.Equal(t, "parent-concept", edges[1].DstSlug)
|
||||
assert.Equal(t, "unrelated", edges[2].DstSlug)
|
||||
for _, e := range edges {
|
||||
assert.Equal(t, "wikilink", e.EdgeType)
|
||||
assert.Equal(t, "val-vol", e.SrcSlug)
|
||||
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", e.SrcDoc)
|
||||
assert.Greater(t, e.SrcLine, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_LegacyConceptDoc(t *testing.T) {
|
||||
content := []byte(`---
|
||||
title: Hash Encoding
|
||||
---
|
||||
# Hash Encoding
|
||||
|
||||
Linked to [[financial-sentiment-analysis|FSA]].
|
||||
`)
|
||||
ent, edges, ok := Extract("wiki/concepts/hash-encoding.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "hash-encoding", ent.Slug)
|
||||
assert.Equal(t, "concept", ent.Type)
|
||||
assert.Empty(t, ent.Wing)
|
||||
assert.Empty(t, ent.Hall)
|
||||
assert.Equal(t, "Hash Encoding", ent.Title)
|
||||
|
||||
require.Len(t, edges, 1)
|
||||
assert.Equal(t, "financial-sentiment-analysis", edges[0].DstSlug)
|
||||
}
|
||||
|
||||
func TestExtract_KnowledgeDoc(t *testing.T) {
|
||||
content := []byte("# No frontmatter, no links here.\n")
|
||||
ent, edges, ok := Extract("knowledge/some-note.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "some-note", ent.Slug)
|
||||
assert.Equal(t, "knowledge", ent.Type)
|
||||
assert.Empty(t, edges)
|
||||
}
|
||||
|
||||
func TestExtract_DedupesRepeatedLinkOnSameLine(t *testing.T) {
|
||||
content := []byte("See [[foo]] and [[foo]] again on the same line.\n")
|
||||
_, edges, ok := Extract("knowledge/dup.md", content)
|
||||
require.True(t, ok)
|
||||
require.Len(t, edges, 1)
|
||||
assert.Equal(t, "foo", edges[0].DstSlug)
|
||||
}
|
||||
|
||||
func TestExtract_KeepsMultipleEdgesOnDifferentLines(t *testing.T) {
|
||||
content := []byte("First mention [[foo]].\n\nSecond mention [[foo]].\n")
|
||||
_, edges, ok := Extract("knowledge/multi.md", content)
|
||||
require.True(t, ok)
|
||||
require.Len(t, edges, 2)
|
||||
assert.NotEqual(t, edges[0].SrcLine, edges[1].SrcLine)
|
||||
}
|
||||
|
||||
func TestExtract_IgnoresSelfLinks(t *testing.T) {
|
||||
content := []byte("Self-reference [[self]] should be ignored.\n")
|
||||
_, edges, ok := Extract("knowledge/self.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, edges)
|
||||
}
|
||||
|
||||
func TestExtract_RejectsNonMarkdown(t *testing.T) {
|
||||
_, _, ok := Extract("wiki/concepts/not-markdown.txt", []byte("anything"))
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestExtract_LineNumbersAre1Indexed(t *testing.T) {
|
||||
content := []byte("line 1\nline 2 [[bar]]\n")
|
||||
_, edges, ok := Extract("knowledge/lines.md", content)
|
||||
require.True(t, ok)
|
||||
require.Len(t, edges, 1)
|
||||
assert.Equal(t, 2, edges[0].SrcLine)
|
||||
}
|
||||
|
||||
// Files directly under wiki/ (no subdirectory) used to land
|
||||
// Type="hall" Wing="<filename>.md" because the path's second segment
|
||||
// was the file itself. The fix routes them to Type="knowledge" with
|
||||
// empty Wing/Hall and lets frontmatter set them if present.
|
||||
func TestExtract_WikiRootFileIsKnowledgeNotHall(t *testing.T) {
|
||||
content := []byte("# Index\n\n- [[foo]]\n")
|
||||
ent, _, ok := Extract("wiki/index.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "index", ent.Slug)
|
||||
assert.Equal(t, "knowledge", ent.Type)
|
||||
assert.Empty(t, ent.Wing)
|
||||
assert.Empty(t, ent.Hall)
|
||||
}
|
||||
|
||||
func TestExtract_TierFromFrontmatter(t *testing.T) {
|
||||
content := []byte(`---
|
||||
tier: knowledge
|
||||
topic: postgres-roles
|
||||
title: Least-privilege migration trap
|
||||
---
|
||||
# body
|
||||
`)
|
||||
ent, _, ok := Extract("knowledge/some-lesson.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "knowledge", ent.Tier)
|
||||
assert.Equal(t, "postgres-roles", ent.Topic)
|
||||
}
|
||||
|
||||
func TestExtract_TierInferredFromPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"knowledge/foo.md", "knowledge"},
|
||||
{"wiki/sources/x.md", "note"},
|
||||
{"wiki/concepts/x.md", "note"},
|
||||
{"wiki/x.md", "note"},
|
||||
{"inbox/clips/x.md", "inbox"},
|
||||
{"notes/x.md", "note"},
|
||||
{"raw/x.md", "inbox"},
|
||||
{"sessions/x.md", "inbox"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ent, _, ok := Extract(tc.path, []byte("# x\n"))
|
||||
require.True(t, ok, tc.path)
|
||||
assert.Equal(t, tc.want, ent.Tier, tc.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_FrontmatterTierBeatsPathInference(t *testing.T) {
|
||||
// A clip explicitly promoted via frontmatter wins over the path's
|
||||
// inbox inference. Catches the case where a file has been moved
|
||||
// to a new location but frontmatter hasn't been updated.
|
||||
content := []byte("---\ntier: knowledge\n---\n# x\n")
|
||||
ent, _, ok := Extract("inbox/clips/x.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "knowledge", ent.Tier)
|
||||
}
|
||||
|
||||
func TestExtract_WikiRootFileWithFrontmatterWingHall(t *testing.T) {
|
||||
content := []byte(`---
|
||||
wing: homelab
|
||||
hall: facts
|
||||
---
|
||||
# Some root note
|
||||
`)
|
||||
ent, _, ok := Extract("wiki/some-note.md", content)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "knowledge", ent.Type)
|
||||
assert.Equal(t, "homelab", ent.Wing)
|
||||
assert.Equal(t, "facts", ent.Hall)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Package graphstore stores the brain knowledge graph (entities +
|
||||
// directed edges) in PostgreSQL on the shared postgres18 instance,
|
||||
// alongside the pgvector embeddings in [vectorstore].
|
||||
//
|
||||
// Schema (created idempotently by Init):
|
||||
//
|
||||
// brain_entities(slug PK, type, wing, hall, doc_path, title, updated_at)
|
||||
// brain_edges(id PK, src_slug FK, dst_slug, edge_type, src_doc, src_line,
|
||||
// weight, updated_at)
|
||||
//
|
||||
// Edges fan-out from a source document; calling [PGStore.ReplaceEdgesForDoc]
|
||||
// replaces every edge previously emitted from that document so re-ingest is
|
||||
// idempotent without bookkeeping.
|
||||
//
|
||||
// All slug strings are stored verbatim — callers are expected to canonicalise
|
||||
// before persisting. Dst slugs may reference entities that don't yet exist
|
||||
// (dangling edges); resolution is deferred to query time so ingestion order
|
||||
// doesn't matter.
|
||||
package graphstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||
)
|
||||
|
||||
// PGStore is the postgres-backed brain knowledge-graph store. Construct
|
||||
// with New + call Init once to create tables and indexes. Use Close to
|
||||
// release the pool.
|
||||
type PGStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New opens a pgxpool against dsn and pings to verify connectivity. The
|
||||
// caller owns the resulting PGStore and must invoke Close.
|
||||
func New(ctx context.Context, dsn string) (*PGStore, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &PGStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying connection pool.
|
||||
func (s *PGStore) Close() {
|
||||
if s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Init creates brain_entities + brain_edges tables and their indexes if
|
||||
// they don't yet exist. Safe to call on every startup. No-op when the
|
||||
// schema already matches.
|
||||
func (s *PGStore) Init(ctx context.Context) error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS brain_entities (
|
||||
slug TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'knowledge',
|
||||
wing TEXT NOT NULL DEFAULT '',
|
||||
hall TEXT NOT NULL DEFAULT '',
|
||||
doc_path TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
tier TEXT NOT NULL DEFAULT '',
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Idempotent migration for clusters created before the DIKW tier
|
||||
-- redesign (infra#72). ADD COLUMN IF NOT EXISTS is safe across
|
||||
-- repeated startups.
|
||||
ALTER TABLE brain_entities
|
||||
ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS topic TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS brain_entities_wing_idx
|
||||
ON brain_entities (wing) WHERE wing <> '';
|
||||
CREATE INDEX IF NOT EXISTS brain_entities_type_idx
|
||||
ON brain_entities (type);
|
||||
CREATE INDEX IF NOT EXISTS brain_entities_tier_idx
|
||||
ON brain_entities (tier) WHERE tier <> '';
|
||||
CREATE INDEX IF NOT EXISTS brain_entities_topic_idx
|
||||
ON brain_entities (topic) WHERE topic <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brain_edges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
src_slug TEXT NOT NULL,
|
||||
dst_slug TEXT NOT NULL,
|
||||
edge_type TEXT NOT NULL DEFAULT 'wikilink',
|
||||
src_doc TEXT NOT NULL,
|
||||
src_line INTEGER NOT NULL DEFAULT 0,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS brain_edges_src_idx
|
||||
ON brain_edges (src_slug, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS brain_edges_dst_idx
|
||||
ON brain_edges (dst_slug, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS brain_edges_src_doc_idx
|
||||
ON brain_edges (src_doc);
|
||||
`
|
||||
_, err := s.pool.Exec(ctx, ddl)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertEntity inserts or updates one entity by slug.
|
||||
func (s *PGStore) UpsertEntity(ctx context.Context, e graph.Entity) error {
|
||||
if e.Slug == "" {
|
||||
return errors.New("entity slug is required")
|
||||
}
|
||||
if e.Type == "" {
|
||||
e.Type = "knowledge"
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO brain_entities (slug, type, wing, hall, doc_path, title, tier, topic, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (slug) DO UPDATE
|
||||
SET type = EXCLUDED.type,
|
||||
wing = EXCLUDED.wing,
|
||||
hall = EXCLUDED.hall,
|
||||
doc_path = EXCLUDED.doc_path,
|
||||
title = EXCLUDED.title,
|
||||
tier = EXCLUDED.tier,
|
||||
topic = EXCLUDED.topic,
|
||||
updated_at = now()
|
||||
`, e.Slug, e.Type, e.Wing, e.Hall, e.DocPath, e.Title, e.Tier, e.Topic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert entity %q: %w", e.Slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceEdgesForDoc deletes every edge previously emitted from docPath
|
||||
// and inserts the new set in one transaction. Caller should pass the
|
||||
// complete edge set for the doc — partial updates are not supported.
|
||||
func (s *PGStore) ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error {
|
||||
if docPath == "" {
|
||||
return errors.New("doc path is required")
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
|
||||
return fmt.Errorf("delete prior edges for %q: %w", docPath, err)
|
||||
}
|
||||
for _, e := range edges {
|
||||
if e.SrcSlug == "" || e.DstSlug == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO brain_edges (src_slug, dst_slug, edge_type, src_doc, src_line, weight)
|
||||
VALUES ($1, $2, $3, $4, $5, 1.0)
|
||||
`, e.SrcSlug, e.DstSlug, e.EdgeType, e.SrcDoc, e.SrcLine); err != nil {
|
||||
return fmt.Errorf("insert edge %s->%s: %w", e.SrcSlug, e.DstSlug, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByDoc removes the entity at docPath and every edge it sourced.
|
||||
// Use when a wiki page is deleted on disk.
|
||||
func (s *PGStore) DeleteByDoc(ctx context.Context, docPath string) error {
|
||||
if docPath == "" {
|
||||
return errors.New("doc path is required")
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
|
||||
return fmt.Errorf("delete edges: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM brain_entities WHERE doc_path = $1`, docPath); err != nil {
|
||||
return fmt.Errorf("delete entity: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Neighbor is one row in a Neighbors / Subgraph response.
|
||||
type Neighbor struct {
|
||||
Slug string
|
||||
Type string
|
||||
Wing string
|
||||
Hall string
|
||||
DocPath string
|
||||
Title string
|
||||
EdgeType string
|
||||
Distance int // hop count from origin; 1 for direct neighbors
|
||||
}
|
||||
|
||||
// Neighbors returns the direct (1-hop) outgoing neighbours of slug.
|
||||
// edgeType filters by relationship kind; "" returns all kinds.
|
||||
// limit defaults to 25 when <= 0.
|
||||
func (s *PGStore) Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]Neighbor, error) {
|
||||
if slug == "" {
|
||||
return nil, errors.New("slug is required")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
q := `
|
||||
SELECT e.dst_slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
|
||||
COALESCE(t.doc_path,''), COALESCE(t.title,''), e.edge_type, 1
|
||||
FROM brain_edges e
|
||||
LEFT JOIN brain_entities t ON t.slug = e.dst_slug
|
||||
WHERE e.src_slug = $1
|
||||
AND ($2 = '' OR e.edge_type = $2)
|
||||
ORDER BY e.updated_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, slug, edgeType, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query neighbors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNeighbors(rows)
|
||||
}
|
||||
|
||||
// Subgraph returns every distinct slug reachable from origin within
|
||||
// depth outgoing hops, annotated with the shortest hop distance. The
|
||||
// origin itself is omitted. depth defaults to 2 when <= 0; values
|
||||
// above 6 are clamped to 6 to bound traversal cost.
|
||||
func (s *PGStore) Subgraph(ctx context.Context, origin string, depth int) ([]Neighbor, error) {
|
||||
if origin == "" {
|
||||
return nil, errors.New("origin slug is required")
|
||||
}
|
||||
if depth <= 0 {
|
||||
depth = 2
|
||||
}
|
||||
if depth > 6 {
|
||||
depth = 6
|
||||
}
|
||||
q := `
|
||||
WITH RECURSIVE walk(slug, edge_type, distance) AS (
|
||||
SELECT e.dst_slug, e.edge_type, 1
|
||||
FROM brain_edges e
|
||||
WHERE e.src_slug = $1
|
||||
UNION
|
||||
SELECT e.dst_slug, e.edge_type, w.distance + 1
|
||||
FROM walk w
|
||||
JOIN brain_edges e ON e.src_slug = w.slug
|
||||
WHERE w.distance < $2
|
||||
)
|
||||
SELECT w.slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
|
||||
COALESCE(t.doc_path,''), COALESCE(t.title,''), w.edge_type, MIN(w.distance)
|
||||
FROM walk w
|
||||
LEFT JOIN brain_entities t ON t.slug = w.slug
|
||||
WHERE w.slug <> $1
|
||||
GROUP BY w.slug, t.type, t.wing, t.hall, t.doc_path, t.title, w.edge_type
|
||||
ORDER BY MIN(w.distance), w.slug
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, origin, depth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query subgraph: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNeighbors(rows)
|
||||
}
|
||||
|
||||
// PathStep is one hop in a Path response.
|
||||
type PathStep struct {
|
||||
FromSlug string
|
||||
ToSlug string
|
||||
EdgeType string
|
||||
}
|
||||
|
||||
// Path returns the shortest directed path from src to dst within
|
||||
// maxDepth hops, as an ordered list of edges. Empty slice means no
|
||||
// path exists. maxDepth defaults to 4 when <= 0; values above 8 are
|
||||
// clamped to 8.
|
||||
func (s *PGStore) Path(ctx context.Context, src, dst string, maxDepth int) ([]PathStep, error) {
|
||||
if src == "" || dst == "" {
|
||||
return nil, errors.New("src and dst are required")
|
||||
}
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 4
|
||||
}
|
||||
if maxDepth > 8 {
|
||||
maxDepth = 8
|
||||
}
|
||||
q := `
|
||||
WITH RECURSIVE walk(cur, path_slugs, path_edges, distance) AS (
|
||||
SELECT e.dst_slug,
|
||||
ARRAY[e.src_slug, e.dst_slug]::TEXT[],
|
||||
ARRAY[e.edge_type]::TEXT[],
|
||||
1
|
||||
FROM brain_edges e
|
||||
WHERE e.src_slug = $1
|
||||
UNION ALL
|
||||
SELECT e.dst_slug,
|
||||
w.path_slugs || e.dst_slug,
|
||||
w.path_edges || e.edge_type,
|
||||
w.distance + 1
|
||||
FROM walk w
|
||||
JOIN brain_edges e ON e.src_slug = w.cur
|
||||
WHERE w.distance < $3
|
||||
AND NOT (e.dst_slug = ANY(w.path_slugs))
|
||||
)
|
||||
SELECT path_slugs, path_edges
|
||||
FROM walk
|
||||
WHERE cur = $2
|
||||
ORDER BY distance ASC
|
||||
LIMIT 1
|
||||
`
|
||||
row := s.pool.QueryRow(ctx, q, src, dst, maxDepth)
|
||||
var (
|
||||
slugs []string
|
||||
kinds []string
|
||||
)
|
||||
if err := row.Scan(&slugs, &kinds); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("scan path: %w", err)
|
||||
}
|
||||
if len(slugs) < 2 || len(kinds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
steps := make([]PathStep, 0, len(kinds))
|
||||
for i := 0; i < len(kinds) && i+1 < len(slugs); i++ {
|
||||
steps = append(steps, PathStep{
|
||||
FromSlug: slugs[i],
|
||||
ToSlug: slugs[i+1],
|
||||
EdgeType: kinds[i],
|
||||
})
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// CountEdges is a debug helper — returns the total edges currently stored.
|
||||
// Used by tests and by the volume-gate diagnostic.
|
||||
func (s *PGStore) CountEdges(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM brain_edges`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func scanNeighbors(rows pgx.Rows) ([]Neighbor, error) {
|
||||
var out []Neighbor
|
||||
for rows.Next() {
|
||||
var n Neighbor
|
||||
if err := rows.Scan(
|
||||
&n.Slug, &n.Type, &n.Wing, &n.Hall,
|
||||
&n.DocPath, &n.Title, &n.EdgeType, &n.Distance,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package graphsync glues the disk-resident brain markdown documents to
|
||||
// the relational graph in [graphstore]. It is a tiny seam so that the
|
||||
// MCP handlers can call one function after every successful write or
|
||||
// ingest without having to know either the parser or the postgres
|
||||
// schema.
|
||||
//
|
||||
// Every operation is best-effort from the caller's perspective: if the
|
||||
// graph store is unconfigured or the doc parses to nothing usable, the
|
||||
// helpers return nil. Real database errors are surfaced so the caller
|
||||
// can log them.
|
||||
package graphsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
)
|
||||
|
||||
// Store is the subset of graphstore.PGStore that graphsync requires.
|
||||
// Tests can substitute a fake by satisfying this interface.
|
||||
type Store interface {
|
||||
UpsertEntity(ctx context.Context, e graph.Entity) error
|
||||
ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error
|
||||
DeleteByDoc(ctx context.Context, docPath string) error
|
||||
}
|
||||
|
||||
// Compile-time assertion that *graphstore.PGStore satisfies Store.
|
||||
var _ Store = (*graphstore.PGStore)(nil)
|
||||
|
||||
// IndexDoc reads docPath under brainDir and pushes one Entity + its
|
||||
// outgoing wikilink Edges into store. relPath must be the
|
||||
// forward-slash path relative to brainDir (the same shape returned by
|
||||
// api.WriteNote).
|
||||
//
|
||||
// nil store is a valid no-op so callers can wire the helper
|
||||
// unconditionally and let configuration decide whether the graph is
|
||||
// populated.
|
||||
func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if relPath == "" {
|
||||
return nil
|
||||
}
|
||||
abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
content, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %q: %w", relPath, err)
|
||||
}
|
||||
ent, edges, ok := graph.Extract(relPath, content)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := store.UpsertEntity(ctx, ent); err != nil {
|
||||
return fmt.Errorf("upsert entity: %w", err)
|
||||
}
|
||||
if err := store.ReplaceEdgesForDoc(ctx, relPath, edges); err != nil {
|
||||
return fmt.Errorf("replace edges: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillFromBrainDir walks every markdown file under brainDir/wiki/
|
||||
// and brainDir/knowledge/, parses each, and upserts the resulting
|
||||
// Entity + Edges. Existing rows are overwritten; orphan rows for
|
||||
// already-deleted files are NOT cleaned up — call this only on a
|
||||
// fresh store, or follow with a separate prune pass.
|
||||
//
|
||||
// Intended for one-shot startup runs against a populated brain dir.
|
||||
// Cost scales linearly with corpus size; ~30 wiki pages plus the
|
||||
// knowledge corpus is a few hundred ms.
|
||||
func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (indexed int, _ error) {
|
||||
if store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
roots := []string{"wiki", "knowledge"}
|
||||
for _, root := range roots {
|
||||
base := filepath.Join(brainDir, root)
|
||||
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(brainDir, path)
|
||||
if relErr != nil {
|
||||
return fmt.Errorf("rel %q: %w", path, relErr)
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if err := IndexDoc(ctx, store, brainDir, rel); err != nil {
|
||||
return fmt.Errorf("index %q: %w", rel, err)
|
||||
}
|
||||
indexed++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return indexed, fmt.Errorf("walk %s: %w", root, err)
|
||||
}
|
||||
}
|
||||
return indexed, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package graphsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||
)
|
||||
|
||||
// fakeStore captures the calls IndexDoc / BackfillFromBrainDir made.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
upserts []graph.Entity
|
||||
replaces map[string][]graph.Edge
|
||||
deletes []string
|
||||
failOn string // upsert fails when entity slug == failOn
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{replaces: make(map[string][]graph.Edge)}
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertEntity(_ context.Context, e graph.Entity) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failOn != "" && e.Slug == f.failOn {
|
||||
return errors.New("synthetic failure")
|
||||
}
|
||||
f.upserts = append(f.upserts, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ReplaceEdgesForDoc(_ context.Context, docPath string, edges []graph.Edge) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.replaces[docPath] = append([]graph.Edge(nil), edges...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteByDoc(_ context.Context, docPath string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.deletes = append(f.deletes, docPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeBrain(t *testing.T, brainDir, relPath, body string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
||||
}
|
||||
|
||||
func TestIndexDoc_UpsertsEntityAndEdges(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/foo.md", `---
|
||||
title: Foo
|
||||
---
|
||||
# Foo
|
||||
Linking to [[bar]] and [[baz|Baz]].
|
||||
`)
|
||||
fs := newFakeStore()
|
||||
require.NoError(t, IndexDoc(context.Background(), fs, tmp, "wiki/concepts/foo.md"))
|
||||
|
||||
require.Len(t, fs.upserts, 1)
|
||||
assert.Equal(t, "foo", fs.upserts[0].Slug)
|
||||
assert.Equal(t, "concept", fs.upserts[0].Type)
|
||||
|
||||
edges := fs.replaces["wiki/concepts/foo.md"]
|
||||
require.Len(t, edges, 2)
|
||||
assert.Equal(t, "bar", edges[0].DstSlug)
|
||||
assert.Equal(t, "baz", edges[1].DstSlug)
|
||||
}
|
||||
|
||||
func TestIndexDoc_NoopOnNilStore(t *testing.T) {
|
||||
require.NoError(t, IndexDoc(context.Background(), nil, "anywhere", "foo.md"))
|
||||
}
|
||||
|
||||
func TestIndexDoc_NoopOnEmptyRelPath(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
require.NoError(t, IndexDoc(context.Background(), fs, "anywhere", ""))
|
||||
assert.Empty(t, fs.upserts)
|
||||
}
|
||||
|
||||
func TestIndexDoc_ErrorsOnMissingFile(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
err := IndexDoc(context.Background(), fs, t.TempDir(), "wiki/nope.md")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIndexDoc_SurfacesStoreFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/boom.md", "# Boom\n")
|
||||
fs := newFakeStore()
|
||||
fs.failOn = "boom"
|
||||
err := IndexDoc(context.Background(), fs, tmp, "wiki/concepts/boom.md")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_WalksWikiAndKnowledge(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/foo.md", "# Foo\n[[bar]]\n")
|
||||
writeBrain(t, tmp, "wiki/entities/bar.md", "# Bar\n")
|
||||
writeBrain(t, tmp, "knowledge/legacy.md", "# Legacy [[foo]]\n")
|
||||
// non-markdown file should be skipped
|
||||
writeBrain(t, tmp, "wiki/concepts/skip.txt", "ignore me")
|
||||
|
||||
fs := newFakeStore()
|
||||
n, err := BackfillFromBrainDir(context.Background(), fs, tmp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, n)
|
||||
assert.Len(t, fs.upserts, 3)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_TolerantOfMissingDirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
fs := newFakeStore()
|
||||
n, err := BackfillFromBrainDir(context.Background(), fs, tmp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_NilStoreNoop(t *testing.T) {
|
||||
n, err := BackfillFromBrainDir(context.Background(), nil, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// callResult parses the JSON text payload of a successful tool call.
|
||||
func callResult(t *testing.T, resp map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
require.Nil(t, resp["error"], "tool returned error: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &out))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBrainUpdateSupersedesExisting(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
// Seed via brain_write so the note carries real frontmatter.
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Old\n\nold body\n", "filename": "val-vol",
|
||||
"wing": "jepa-fx", "hall": "facts",
|
||||
}))
|
||||
|
||||
out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "jepa-fx", "hall": "facts", "slug": "val-vol",
|
||||
"content": "# New\n\nnew body\n", "reason": "facts changed",
|
||||
}))
|
||||
assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", out["path"])
|
||||
assert.Equal(t, out["path"], out["id"])
|
||||
assert.NotEmpty(t, out["content_hash"])
|
||||
assert.Equal(t, true, out["superseded"])
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/facts/val-vol.md"))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "# New")
|
||||
assert.NotContains(t, s, "old body")
|
||||
assert.Contains(t, s, "wing: jepa-fx")
|
||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||
assert.Contains(t, s, "supersedes:")
|
||||
}
|
||||
|
||||
func TestBrainUpdateMissingTargetErrorsNoCreate(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "jepa-fx", "hall": "facts", "slug": "ghost",
|
||||
"content": "x\n",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "does not exist")
|
||||
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
func TestBrainUpdateByFullPath(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "old\n", "filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
|
||||
out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"slug": "wiki/a/facts/n.md", "content": "fresh\n",
|
||||
}))
|
||||
assert.Equal(t, "wiki/a/facts/n.md", out["path"])
|
||||
}
|
||||
|
||||
func TestBrainGetByIDAndPath(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
w := callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Body\n\ntext\n", "filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
id := w["id"].(string)
|
||||
hash := w["content_hash"].(string)
|
||||
require.NotEmpty(t, id)
|
||||
require.NotEmpty(t, hash)
|
||||
|
||||
// by id
|
||||
g1 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"id": id}))
|
||||
assert.Equal(t, id, g1["path"])
|
||||
assert.Equal(t, hash, g1["content_hash"], "content_hash must round-trip write→get")
|
||||
assert.Contains(t, g1["body"].(string), "# Body")
|
||||
fm := g1["frontmatter"].(map[string]any)
|
||||
assert.Equal(t, "a", fm["wing"])
|
||||
|
||||
// by path
|
||||
g2 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"path": id}))
|
||||
assert.Equal(t, hash, g2["content_hash"])
|
||||
}
|
||||
|
||||
func TestBrainGetMissingArgsErrors(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_get", map[string]any{})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainWriteReturnsHandle(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
out := callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# X\n\nbody\n", "filename": "x", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
assert.Equal(t, "wiki/a/facts/x.md", out["path"])
|
||||
assert.Equal(t, out["path"], out["id"])
|
||||
assert.NotEmpty(t, out["content_hash"])
|
||||
}
|
||||
|
||||
// --- retrieval-reflects-new-content: exercises the real mtime-driven Sync ---
|
||||
|
||||
type fakeVecStore struct {
|
||||
chunks map[string][]float32
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) KnownPathsWithTime(_ context.Context) (map[string]time.Time, error) {
|
||||
m := make(map[string]time.Time, len(f.chunks))
|
||||
for p := range f.chunks {
|
||||
m[p] = time.Unix(0, 0) // always stale → mtime(now) is always newer
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) Upsert(_ context.Context, path string, vec []float32) error {
|
||||
f.chunks[path] = vec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) Delete(_ context.Context, path string) error {
|
||||
delete(f.chunks, path)
|
||||
f.deleted = append(f.deleted, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEmbedder struct{ seen []string }
|
||||
|
||||
func (e *fakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
||||
e.seen = append(e.seen, text)
|
||||
return []float32{1, 0, 0}, nil
|
||||
}
|
||||
|
||||
// TestBrainUpdateReembedsNewContent proves the supersede contract end to
|
||||
// end against the actual embedding mechanism: brain_update rewrites the
|
||||
// file, advancing its mtime, and the next vectorstore.Sync pass re-embeds
|
||||
// the NEW body and drops the stale chunk. No stub of the re-index path.
|
||||
func TestBrainUpdateReembedsNewContent(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Note\n\nthe OLD distinctive payload\n",
|
||||
"filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
|
||||
store := &fakeVecStore{chunks: map[string][]float32{}}
|
||||
emb := &fakeEmbedder{}
|
||||
|
||||
// First sync embeds the original content.
|
||||
_, err := vectorstore.Sync(ctx, brainDir, store, emb)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, store.chunks)
|
||||
require.True(t, anyContains(emb.seen, "OLD distinctive payload"))
|
||||
|
||||
callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "a", "hall": "facts", "slug": "n",
|
||||
"content": "# Note\n\nthe NEW distinctive payload\n",
|
||||
}))
|
||||
|
||||
emb.seen = nil // only watch what the second pass embeds
|
||||
_, err = vectorstore.Sync(ctx, brainDir, store, emb)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, anyContains(emb.seen, "NEW distinctive payload"),
|
||||
"Sync must re-embed the superseded body; saw %v", emb.seen)
|
||||
assert.False(t, anyContains(emb.seen, "OLD distinctive payload"),
|
||||
"the old body must not be re-embedded")
|
||||
assert.NotEmpty(t, store.deleted, "stale chunks must be deleted before re-embed")
|
||||
}
|
||||
|
||||
func anyContains(ss []string, sub string) bool {
|
||||
for _, s := range ss {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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/graphsync"
|
||||
"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_update",
|
||||
"description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.",
|
||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||
"content": str("new full body (whole-note replace)"),
|
||||
"slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"),
|
||||
"wing": str("wing of the target (required unless slug/path is a full path)"),
|
||||
"hall": enum("hall of the target (required unless slug/path is a full path)", halls...),
|
||||
"path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"),
|
||||
"reason": str("optional short note on why superseded — stamped into frontmatter"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_get",
|
||||
"description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.",
|
||||
"inputSchema": schema([]string{}, map[string]any{
|
||||
"id": str("note id (brain-relative path) as returned by brain_write/brain_update"),
|
||||
"path": str("brain-relative path to the note; equivalent to id"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"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": "brain_graph",
|
||||
"description": "Query the brain knowledge graph (entities + wikilink edges). Op selects the traversal: neighbors (1-hop outgoing from slug), subgraph (every reachable slug within depth hops), or path (shortest directed path src→dst). Returns slug + entity metadata + edge_type + hop distance.",
|
||||
"inputSchema": schema([]string{"op"}, map[string]any{
|
||||
"op": enum("traversal kind", "neighbors", "subgraph", "path"),
|
||||
"slug": str("origin slug for op=neighbors or op=subgraph"),
|
||||
"src": str("source slug for op=path"),
|
||||
"dst": str("destination slug for op=path"),
|
||||
"edge_type": str("optional edge type filter for op=neighbors (e.g. wikilink); empty matches all"),
|
||||
"limit": int_("max neighbors to return for op=neighbors, default 25"),
|
||||
"depth": int_("max traversal depth for op=subgraph (default 2, clamped to 6) and op=path (default 4, clamped to 8)"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_context",
|
||||
"description": "Return top-N relevant brain entries for a project context. Use at session start or before a complex task to load prior decisions, corrections, and surprises.",
|
||||
"inputSchema": schema([]string{"project_root"}, map[string]any{
|
||||
"project_root": str("absolute path to the project root"),
|
||||
"recent_files": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
"description": "optional: recent file paths in the project to bias relevance",
|
||||
},
|
||||
"limit": int_("max entries to return, default 10"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
// Read-after-write handle: id == relPath, content_hash == sha256 of
|
||||
// the bytes just written. path is kept for backward compatibility.
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash})
|
||||
}
|
||||
|
||||
type brainUpdateArgs struct {
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// brainUpdate supersedes an existing note in place: whole-note body
|
||||
// replace, frontmatter re-stamp (updated_at/supersedes/supersede_reason),
|
||||
// graph re-index, and wing _index rebuild. It never creates — a missing
|
||||
// target is an error so the caller can fall back to brain_write.
|
||||
//
|
||||
// Embedding re-sync is delegated to the out-of-band vectorstore.Sync
|
||||
// ticker: the rewritten file's mtime advances, so the next pass re-embeds
|
||||
// it. This mirrors brain_write, which likewise does not embed in-handler.
|
||||
func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainUpdateArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
if a.Content == "" {
|
||||
return nil, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason}
|
||||
switch {
|
||||
case a.Path != "":
|
||||
opts.Path = a.Path
|
||||
case strings.Contains(a.Slug, "/"):
|
||||
// slug carries a full path (issue #45: "slug ... OR full path").
|
||||
opts.Path = a.Slug
|
||||
default:
|
||||
opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug
|
||||
}
|
||||
|
||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort wiki upkeep, mirroring brain_write: rebuild the wing
|
||||
// _index and re-tunnel cross-wing matches against the new body. Both
|
||||
// are idempotent and never block — the note is already superseded.
|
||||
if wing := wingFromRelPath(relPath); wing != "" {
|
||||
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||
slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err)
|
||||
}
|
||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
||||
slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"id": relPath, "path": relPath, "content_hash": hash, "superseded": true,
|
||||
})
|
||||
}
|
||||
|
||||
// wingFromRelPath extracts the wing segment from a structured wiki path
|
||||
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
||||
func wingFromRelPath(relPath string) string {
|
||||
parts := strings.Split(relPath, "/")
|
||||
if len(parts) >= 4 && parts[0] == "wiki" {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type brainGetArgs struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// brainGet fetches a note by id or path (both are the brainDir-relative
|
||||
// path — the de-facto handle). Read-only; the create-path read-after-
|
||||
// write primitive that lets callers confirm a write landed without a
|
||||
// lexical re-query.
|
||||
func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainGetArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
target := a.Path
|
||||
if target == "" {
|
||||
target = a.ID
|
||||
}
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("id or path is required")
|
||||
}
|
||||
fm, body, hash, err := api.ReadNote(s.brainDir, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": target, "path": target, "content_hash": hash,
|
||||
"frontmatter": fm, "body": body,
|
||||
})
|
||||
}
|
||||
|
||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||
// logs failures but never propagates them — the underlying write/ingest
|
||||
// has already succeeded and the graph is an augmentation, not a
|
||||
// correctness invariant.
|
||||
func (s *Server) indexInGraph(ctx context.Context, op, relPath string) {
|
||||
if s.graph == nil || relPath == "" {
|
||||
return
|
||||
}
|
||||
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
|
||||
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_tunnel", a.Source)
|
||||
s.indexInGraph(ctx, "brain_tunnel", a.Target)
|
||||
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{}
|
||||
}
|
||||
if !a.DryRun {
|
||||
for _, p := range pages {
|
||||
s.indexInGraph(ctx, "brain_ingest_raw", p)
|
||||
}
|
||||
}
|
||||
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{}
|
||||
}
|
||||
if !dryRun {
|
||||
for _, p := range pages {
|
||||
s.indexInGraph(ctx, "brain_ingest", p)
|
||||
}
|
||||
}
|
||||
warnings := result.Warnings
|
||||
if warnings == nil {
|
||||
warnings = []string{}
|
||||
}
|
||||
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
||||
// brain_answer, brain_classify, brain_graph, brain_context, session_log.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"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
|
||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// WithGraph wires the brain entities + edges store so every successful
|
||||
// brain_write / brain_ingest / brain_tunnel re-indexes its written docs
|
||||
// into the graph, and so brain_graph + GraphRAG-augmented brain_answer
|
||||
// are available. nil disables graph features and is the legacy default.
|
||||
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
||||
if g == nil {
|
||||
s.graph = nil
|
||||
return s
|
||||
}
|
||||
s.graph = g
|
||||
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_update":
|
||||
return s.brainUpdate(ctx, args)
|
||||
case "brain_get":
|
||||
return s.brainGet(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)
|
||||
case "brain_graph":
|
||||
return s.brainGraph(ctx, args)
|
||||
case "brain_context":
|
||||
return s.brainContext(ctx, args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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_update", "brain_get",
|
||||
"brain_index", "brain_tunnel",
|
||||
"brain_ingest_raw", "brain_ingest",
|
||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||
"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")
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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 {
|
||||
reranked, rerr := rerankResults(ctx, s.reranker, a.Query, results, 5)
|
||||
if rerr != nil {
|
||||
return nil, fmt.Errorf("rerank: %w", rerr)
|
||||
}
|
||||
// The reranker is a filter, not a gate. The Qwen3-Reranker is a
|
||||
// web-search cross-encoder: against a conversational / personal-
|
||||
// intent query ("what am I optimizing toward?") it scores even
|
||||
// on-topic notes as "no", which would collapse the whole answer to
|
||||
// "no relevant content" despite BM25 having retrieved relevant
|
||||
// content. When the reranker keeps nothing, fall back to the
|
||||
// BM25/vector ordering (capped to the no-reranker depth) rather
|
||||
// than returning an empty answer.
|
||||
if len(reranked) > 0 {
|
||||
results = reranked
|
||||
} else if len(results) > 10 {
|
||||
results = results[:10]
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// GraphRAG augmentation: when the graph is wired, attach the 1-hop
|
||||
// outgoing neighbourhood of the top BM25/rerank hit as an extra
|
||||
// context block. The LLM can ignore it when irrelevant; when the
|
||||
// neighbour adds signal we don't need a second retrieval pass.
|
||||
// Failures are silently skipped — graph is augmentation, not
|
||||
// correctness.
|
||||
if reader, ok := s.graph.(graphReader); ok && len(results) > 0 {
|
||||
topSlug := slugFromPath(results[0].Path)
|
||||
if topSlug != "" {
|
||||
if ns, gerr := reader.Subgraph(ctx, topSlug, 1); gerr == nil && len(ns) > 0 {
|
||||
sb.WriteString("<related>\n")
|
||||
for _, n := range ns {
|
||||
label := n.Title
|
||||
if label == "" {
|
||||
label = n.Slug
|
||||
}
|
||||
fmt.Fprintf(&sb, "- %s (%s) at %s\n", label, n.EdgeType, n.DocPath)
|
||||
}
|
||||
sb.WriteString("</related>\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// slugFromPath converts "wiki/concepts/foo.md" → "foo".
|
||||
// Returns "" when path has no .md suffix or empty basename.
|
||||
func slugFromPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
// strip directory
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
path = path[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(path, ".md")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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_RerankerKeepsNone_FallsBackToBM25(t *testing.T) {
|
||||
brainDir := brainDirWithContent(t) // test.md BM25-matches "pass-rate logging"
|
||||
|
||||
// Reranker rejects every candidate ("no" to all) — models a
|
||||
// web-search cross-encoder facing a conversational / personal-intent
|
||||
// query, which is exactly when it wrongly scores on-topic notes as
|
||||
// irrelevant. The answer must still synthesize from the BM25 hits, not
|
||||
// collapse to "no relevant content".
|
||||
rrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": "no", "done": true})
|
||||
}))
|
||||
defer rrSrv.Close()
|
||||
|
||||
var sawSources string
|
||||
llm := func(_ context.Context, _, user string) (string, error) {
|
||||
sawSources = user
|
||||
return "fallback answer", 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))
|
||||
|
||||
assert.Equal(t, "fallback answer", result["answer"])
|
||||
assert.NotEmpty(t, result["sources"], "reranker keeping nothing must fall back to BM25, not empty")
|
||||
assert.Contains(t, sawSources, "test.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"])
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
)
|
||||
|
||||
// brainContextArgs is the input shape of brain_context. project_root is
|
||||
// required; recent_files biases ranking when provided; limit caps the
|
||||
// returned set (default 10).
|
||||
type brainContextArgs struct {
|
||||
ProjectRoot string `json:"project_root"`
|
||||
RecentFiles []string `json:"recent_files,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// contextEntry is one returned brain entry: the slug, its title,
|
||||
// frontmatter-stripped excerpt, source (bm25|graph), and a final score
|
||||
// used for ranking before truncation to Limit.
|
||||
type contextEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
DocPath string `json:"doc_path"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
EdgeType string `json:"edge_type"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// brainContext returns top-N brain entries relevant to a project context.
|
||||
// It runs a BM25 query against the project name, takes the top-3 hits as
|
||||
// seeds, expands each seed 2 hops in the brain graph (when configured),
|
||||
// then merges and deduplicates by slug. recent_files optionally boosts
|
||||
// entries whose doc_path matches a recent file basename.
|
||||
func (s *Server) brainContext(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainContextArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
if a.ProjectRoot == "" {
|
||||
return nil, fmt.Errorf("project_root is required")
|
||||
}
|
||||
limit := a.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
projectName := filepath.Base(strings.TrimRight(a.ProjectRoot, "/"))
|
||||
if projectName == "" || projectName == "." || projectName == "/" {
|
||||
return nil, fmt.Errorf("project_root has no usable basename: %q", a.ProjectRoot)
|
||||
}
|
||||
|
||||
// Seed BM25 hits on the project name. Take top-3 as graph expansion seeds.
|
||||
bm25, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
|
||||
Query: projectName,
|
||||
Limit: 3,
|
||||
Vector: s.vector,
|
||||
Embedder: s.embedder,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
|
||||
// Dedup by slug while merging BM25 hits and graph neighbours.
|
||||
bySlug := make(map[string]*contextEntry)
|
||||
// BM25 score: highest rank gets the largest score, decaying linearly.
|
||||
// Score 3.0 / 2.0 / 1.0 for ranks 0/1/2 respectively.
|
||||
for i, r := range bm25 {
|
||||
slug := slugFromPath(r.Path)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
score := float64(len(bm25) - i)
|
||||
bySlug[slug] = &contextEntry{
|
||||
Slug: slug,
|
||||
Title: r.Title,
|
||||
DocPath: r.Path,
|
||||
Excerpt: truncateExcerpt(r.Excerpt, 200),
|
||||
EdgeType: "bm25",
|
||||
Score: score,
|
||||
}
|
||||
}
|
||||
|
||||
// Graph expansion: for each BM25 hit, fetch its 2-hop subgraph and
|
||||
// merge those neighbours in with a graph score that decays with hop
|
||||
// distance. Failures are silently dropped — graph augmentation is
|
||||
// best-effort.
|
||||
if reader, ok := s.graph.(graphReader); ok {
|
||||
for _, r := range bm25 {
|
||||
seed := slugFromPath(r.Path)
|
||||
if seed == "" {
|
||||
continue
|
||||
}
|
||||
ns, gerr := reader.Subgraph(ctx, seed, 2)
|
||||
if gerr != nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range ns {
|
||||
if n.Slug == "" || n.Slug == seed {
|
||||
continue
|
||||
}
|
||||
// Graph score: closer hops carry more signal. Distance 1
|
||||
// scores 0.6, distance 2 scores 0.3.
|
||||
gscore := 0.6 / float64(max1(n.Distance))
|
||||
if existing, ok := bySlug[n.Slug]; ok {
|
||||
// Already surfaced via BM25 — bump its score so that
|
||||
// BM25 + graph evidence outranks BM25-only hits.
|
||||
existing.Score += gscore
|
||||
continue
|
||||
}
|
||||
bySlug[n.Slug] = &contextEntry{
|
||||
Slug: n.Slug,
|
||||
Title: n.Title,
|
||||
DocPath: n.DocPath,
|
||||
Excerpt: readExcerpt(s.brainDir, n.DocPath, 200),
|
||||
EdgeType: "graph",
|
||||
Score: gscore,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional recent_files boost: +1 to entries whose doc_path basename
|
||||
// matches any recent file basename. v1 is intentionally simple.
|
||||
if len(a.RecentFiles) > 0 {
|
||||
recent := make(map[string]struct{}, len(a.RecentFiles))
|
||||
for _, f := range a.RecentFiles {
|
||||
recent[filepath.Base(f)] = struct{}{}
|
||||
}
|
||||
for _, e := range bySlug {
|
||||
if _, hit := recent[filepath.Base(e.DocPath)]; hit {
|
||||
e.Score += 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten and sort by score desc, slug asc as a stable tiebreaker.
|
||||
entries := make([]contextEntry, 0, len(bySlug))
|
||||
for _, e := range bySlug {
|
||||
entries = append(entries, *e)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].Score != entries[j].Score {
|
||||
return entries[i].Score > entries[j].Score
|
||||
}
|
||||
return entries[i].Slug < entries[j].Slug
|
||||
})
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// truncateExcerpt clamps an already-stripped excerpt to maxLen characters
|
||||
// without re-running the frontmatter parser. The ellipsis suffix matches
|
||||
// the convention used in search.excerpt.
|
||||
func truncateExcerpt(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "…"
|
||||
}
|
||||
|
||||
// readExcerpt loads a doc relative to brainDir, strips its frontmatter,
|
||||
// and returns the first maxLen chars. Returns "" on any error — the
|
||||
// excerpt is informational, not load-bearing for correctness.
|
||||
func readExcerpt(brainDir, relPath string, maxLen int) string {
|
||||
if relPath == "" {
|
||||
return ""
|
||||
}
|
||||
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
content, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(string(content), "---", 3)
|
||||
body := string(content)
|
||||
if len(parts) == 3 {
|
||||
body = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if len(body) > maxLen {
|
||||
return body[:maxLen] + "…"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// max1 returns the maximum of n and 1, used to guard against divide-by-zero
|
||||
// on graph distance and to give self-references (distance 0) a sensible
|
||||
// score instead of an infinity.
|
||||
func max1(n int) int {
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeGraph implements graphsync.Store + graphReader so it can be
|
||||
// assigned to Server.graph and downcast by brainContext. Only Subgraph
|
||||
// is exercised by brain_context today; the rest are no-op satisfiers.
|
||||
type fakeGraph struct {
|
||||
subgraph map[string][]graphstore.Neighbor
|
||||
}
|
||||
|
||||
func (f *fakeGraph) UpsertEntity(_ context.Context, _ graph.Entity) error { return nil }
|
||||
func (f *fakeGraph) ReplaceEdgesForDoc(_ context.Context, _ string, _ []graph.Edge) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGraph) DeleteByDoc(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (f *fakeGraph) Neighbors(_ context.Context, slug, _ string, _ int) ([]graphstore.Neighbor, error) {
|
||||
return f.subgraph[slug], nil
|
||||
}
|
||||
|
||||
func (f *fakeGraph) Subgraph(_ context.Context, origin string, _ int) ([]graphstore.Neighbor, error) {
|
||||
return f.subgraph[origin], nil
|
||||
}
|
||||
|
||||
func (f *fakeGraph) Path(_ context.Context, _, _ string, _ int) ([]graphstore.PathStep, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func writeNote(t *testing.T, brainDir, relPath, title, body string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
content := "---\ntitle: " + title + "\n---\n\n" + body
|
||||
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
// callContext runs brainContext directly and decodes the JSON response.
|
||||
func callContext(t *testing.T, s *Server, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
out, err := s.brainContext(context.Background(), raw)
|
||||
require.NoError(t, err)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func sortedSlugs(entries []any) []string {
|
||||
slugs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
slugs = append(slugs, e.(map[string]any)["slug"].(string))
|
||||
}
|
||||
sort.Strings(slugs)
|
||||
return slugs
|
||||
}
|
||||
|
||||
func TestBrainContext_RejectsMissingProjectRoot(t *testing.T) {
|
||||
s := NewServer(t.TempDir(), nil, nil, nil)
|
||||
_, err := s.brainContext(context.Background(), json.RawMessage(`{}`))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBrainContext_RejectsUnusableBasename(t *testing.T) {
|
||||
s := NewServer(t.TempDir(), nil, nil, nil)
|
||||
_, err := s.brainContext(context.Background(), json.RawMessage(`{"project_root":"/"}`))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBrainContext_BM25Only_NoGraph(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
// Two notes whose body contains the hyphenated project name. BM25
|
||||
// uses literal substring matching after whitespace tokenisation, so
|
||||
// the bodies must carry "azure-tiger" verbatim, not "Azure tiger".
|
||||
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-routing.md",
|
||||
"Azure Tiger Routing", "azure-tiger payment routing decisions.")
|
||||
writeNote(t, brainDir, "wiki/finance/facts/iso20022.md",
|
||||
"Azure Tiger ISO 20022 fields", "azure-tiger maps invoice fields to ISO 20022.")
|
||||
|
||||
s := NewServer(brainDir, nil, nil, nil)
|
||||
// graph is nil — only BM25 hits should appear.
|
||||
|
||||
resp := callContext(t, s, map[string]any{
|
||||
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||
})
|
||||
entries := resp["entries"].([]any)
|
||||
require.NotEmpty(t, entries, "expected at least one BM25 hit on project name")
|
||||
|
||||
for _, e := range entries {
|
||||
entry := e.(map[string]any)
|
||||
assert.Equal(t, "bm25", entry["edge_type"], "no graph configured, every entry must be BM25")
|
||||
assert.NotEmpty(t, entry["slug"])
|
||||
assert.NotEmpty(t, entry["doc_path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrainContext_BM25PlusGraphExpansion(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
// BM25 seed — body carries the hyphenated project name verbatim.
|
||||
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-routing.md",
|
||||
"Azure Tiger Routing", "azure-tiger payment routing decisions.")
|
||||
// Graph neighbour — does NOT match BM25 on "azure-tiger" so it can
|
||||
// only arrive via the graph subgraph traversal.
|
||||
writeNote(t, brainDir, "wiki/finance/facts/sepa-clearing.md",
|
||||
"SEPA Clearing", "SEPA payment clearing rules and timing windows.")
|
||||
|
||||
graphFake := &fakeGraph{
|
||||
subgraph: map[string][]graphstore.Neighbor{
|
||||
"azure-tiger-routing": {
|
||||
{
|
||||
Slug: "sepa-clearing",
|
||||
Title: "SEPA Clearing",
|
||||
DocPath: "wiki/finance/facts/sepa-clearing.md",
|
||||
EdgeType: "wikilink",
|
||||
Distance: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewServer(brainDir, nil, nil, nil)
|
||||
s.graph = graphFake
|
||||
|
||||
resp := callContext(t, s, map[string]any{
|
||||
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||
})
|
||||
entries := resp["entries"].([]any)
|
||||
require.GreaterOrEqual(t, len(entries), 2, "expected BM25 seed plus graph neighbour")
|
||||
|
||||
slugs := sortedSlugs(entries)
|
||||
assert.Contains(t, slugs, "azure-tiger-routing", "BM25 seed must appear")
|
||||
assert.Contains(t, slugs, "sepa-clearing", "graph neighbour must appear")
|
||||
|
||||
// Verify the graph-only entry carries edge_type="graph".
|
||||
var sepaEntry map[string]any
|
||||
for _, e := range entries {
|
||||
m := e.(map[string]any)
|
||||
if m["slug"] == "sepa-clearing" {
|
||||
sepaEntry = m
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sepaEntry)
|
||||
assert.Equal(t, "graph", sepaEntry["edge_type"])
|
||||
assert.NotEmpty(t, sepaEntry["excerpt"], "excerpt should be loaded from disk for graph neighbours")
|
||||
}
|
||||
|
||||
func TestBrainContext_LimitClamps(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
// Five notes all matching "azure-tiger".
|
||||
for i, name := range []string{"a", "b", "c", "d", "e"} {
|
||||
writeNote(t, brainDir,
|
||||
"wiki/finance/decisions/azure-tiger-"+name+".md",
|
||||
"Azure Tiger "+name,
|
||||
"azure-tiger note "+name+" with index "+string(rune('0'+i)))
|
||||
}
|
||||
s := NewServer(brainDir, nil, nil, nil)
|
||||
resp := callContext(t, s, map[string]any{
|
||||
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||
"limit": 2,
|
||||
})
|
||||
entries := resp["entries"].([]any)
|
||||
assert.LessOrEqual(t, len(entries), 2)
|
||||
}
|
||||
|
||||
func TestBrainContext_RecentFilesBoost(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
// Both notes BM25-match the project name, but azure-tiger-z has
|
||||
// twice the term frequency so it naturally ranks above azure-tiger-a.
|
||||
// The recent_files boost on azure-tiger-a should pull it level on
|
||||
// score; the alphabetical slug tiebreaker (a < z) then promotes it
|
||||
// to the top — exercising both the boost and the deterministic
|
||||
// tiebreak.
|
||||
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-a.md",
|
||||
"A", "azure-tiger note about a.")
|
||||
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-z.md",
|
||||
"Z", "azure-tiger azure-tiger note about z.")
|
||||
|
||||
s := NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
// Baseline ranking: azure-tiger-z must lead (higher term frequency).
|
||||
baseline := callContext(t, s, map[string]any{
|
||||
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||
})
|
||||
baselineEntries := baseline["entries"].([]any)
|
||||
require.GreaterOrEqual(t, len(baselineEntries), 2)
|
||||
baselineTop := baselineEntries[0].(map[string]any)
|
||||
require.Equal(t, "azure-tiger-z", baselineTop["slug"],
|
||||
"sanity: higher tf must rank first without a boost")
|
||||
|
||||
// With boost on azure-tiger-a — boosted entry must now lead.
|
||||
boosted := callContext(t, s, map[string]any{
|
||||
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||
"recent_files": []string{"/some/where/azure-tiger-a.md"},
|
||||
})
|
||||
entries := boosted["entries"].([]any)
|
||||
require.GreaterOrEqual(t, len(entries), 2)
|
||||
top := entries[0].(map[string]any)
|
||||
assert.Equal(t, "azure-tiger-a", top["slug"], "recent_files boost must promote the matching doc")
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
)
|
||||
|
||||
// graphReader is the read-side surface of graphstore.PGStore the
|
||||
// brain_graph handler needs. Splitting it out (vs. depending on the
|
||||
// concrete *PGStore) lets tests inject a fake without standing up
|
||||
// postgres, and keeps the write-side graphsync.Store interface free
|
||||
// of query concerns.
|
||||
type graphReader interface {
|
||||
Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]graphstore.Neighbor, error)
|
||||
Subgraph(ctx context.Context, origin string, depth int) ([]graphstore.Neighbor, error)
|
||||
Path(ctx context.Context, src, dst string, maxDepth int) ([]graphstore.PathStep, error)
|
||||
}
|
||||
|
||||
// Compile-time check that *graphstore.PGStore satisfies graphReader.
|
||||
var _ graphReader = (*graphstore.PGStore)(nil)
|
||||
|
||||
type brainGraphArgs struct {
|
||||
Op string `json:"op"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Src string `json:"src,omitempty"`
|
||||
Dst string `json:"dst,omitempty"`
|
||||
EdgeType string `json:"edge_type,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) brainGraph(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
reader, ok := s.graph.(graphReader)
|
||||
if s.graph == nil || !ok {
|
||||
return nil, fmt.Errorf("brain graph not configured: set BRAIN_GRAPH_ENABLED=true")
|
||||
}
|
||||
var a brainGraphArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
|
||||
switch a.Op {
|
||||
case "neighbors":
|
||||
if a.Slug == "" {
|
||||
return nil, fmt.Errorf("slug is required for op=neighbors")
|
||||
}
|
||||
ns, err := reader.Neighbors(ctx, a.Slug, a.EdgeType, a.Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("neighbors: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"results": neighborsView(ns)})
|
||||
|
||||
case "subgraph":
|
||||
if a.Slug == "" {
|
||||
return nil, fmt.Errorf("slug is required for op=subgraph")
|
||||
}
|
||||
ns, err := reader.Subgraph(ctx, a.Slug, a.Depth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subgraph: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"results": neighborsView(ns)})
|
||||
|
||||
case "path":
|
||||
if a.Src == "" || a.Dst == "" {
|
||||
return nil, fmt.Errorf("src and dst are required for op=path")
|
||||
}
|
||||
steps, err := reader.Path(ctx, a.Src, a.Dst, a.Depth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("path: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"steps": pathView(steps)})
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown op %q (want neighbors|subgraph|path)", a.Op)
|
||||
}
|
||||
}
|
||||
|
||||
type neighborView struct {
|
||||
Slug string `json:"slug"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
DocPath string `json:"doc_path,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
EdgeType string `json:"edge_type"`
|
||||
Distance int `json:"distance"`
|
||||
}
|
||||
|
||||
func neighborsView(ns []graphstore.Neighbor) []neighborView {
|
||||
out := make([]neighborView, 0, len(ns))
|
||||
for _, n := range ns {
|
||||
out = append(out, neighborView{
|
||||
Slug: n.Slug, Type: n.Type, Wing: n.Wing, Hall: n.Hall,
|
||||
DocPath: n.DocPath, Title: n.Title,
|
||||
EdgeType: n.EdgeType, Distance: n.Distance,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type pathStepView struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
EdgeType string `json:"edge_type"`
|
||||
}
|
||||
|
||||
func pathView(steps []graphstore.PathStep) []pathStepView {
|
||||
out := make([]pathStepView, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
out = append(out, pathStepView{From: s.FromSlug, To: s.ToSlug, EdgeType: s.EdgeType})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package metrics is a tiny Prometheus exposition layer.
|
||||
//
|
||||
// Hand-rolled rather than pulling in github.com/prometheus/client_golang
|
||||
// to keep ingestion's dependency surface minimal (stdlib + jwx + testify
|
||||
// per the repo CLAUDE.md). The single histogram + counter it emits cover
|
||||
// the canary alert wired in k3s/apps/monitoring/ — see infra#50.
|
||||
//
|
||||
// Wire format follows the OpenMetrics text exposition that
|
||||
// kube-prometheus-stack scrapes by default.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// histogram buckets in seconds. Tuned for in-cluster HTTP API
|
||||
// latencies: BM25 query is sub-10ms, hybrid retrieval + LLM-synthesis
|
||||
// can run into seconds. +Inf catch-all is implicit.
|
||||
var defaultBuckets = []float64{
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
||||
}
|
||||
|
||||
// Registry holds one histogram (request latency) labeled by path + status
|
||||
// and one counter (request total) with the same labels. Concurrent-safe.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
series map[labelKey]*series
|
||||
buckets []float64
|
||||
}
|
||||
|
||||
type labelKey struct{ path, status string }
|
||||
|
||||
type series struct {
|
||||
// One atomic counter per bucket (counts of observations ≤ bucket).
|
||||
// counts[len(buckets)] = +Inf bucket (== total observations).
|
||||
counts []atomic.Uint64
|
||||
sumNs atomic.Uint64 // sum of durations in nanoseconds
|
||||
}
|
||||
|
||||
// New returns a Registry pre-populated with no series; the first
|
||||
// observation per (path, status) lazy-creates one.
|
||||
func New() *Registry {
|
||||
return &Registry{
|
||||
series: make(map[labelKey]*series),
|
||||
buckets: defaultBuckets,
|
||||
}
|
||||
}
|
||||
|
||||
// Observe records a single request duration for the given path + status.
|
||||
func (r *Registry) Observe(path, status string, d time.Duration) {
|
||||
key := labelKey{path: path, status: status}
|
||||
|
||||
r.mu.RLock()
|
||||
s := r.series[key]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if s == nil {
|
||||
r.mu.Lock()
|
||||
s = r.series[key]
|
||||
if s == nil {
|
||||
s = &series{counts: make([]atomic.Uint64, len(r.buckets)+1)}
|
||||
r.series[key] = s
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
secs := d.Seconds()
|
||||
for i, b := range r.buckets {
|
||||
if secs <= b {
|
||||
s.counts[i].Add(1)
|
||||
}
|
||||
}
|
||||
// +Inf bucket always increments.
|
||||
s.counts[len(r.buckets)].Add(1)
|
||||
s.sumNs.Add(uint64(d.Nanoseconds()))
|
||||
}
|
||||
|
||||
// Middleware wraps next, observing every request's duration + status.
|
||||
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
|
||||
// falling back to the URL path if no Pattern is set. Pattern keeps
|
||||
// cardinality bounded (one series per route, not one per unique URL).
|
||||
func (r *Registry) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, req)
|
||||
path := req.Pattern
|
||||
if path == "" {
|
||||
path = req.URL.Path
|
||||
}
|
||||
r.Observe(path, statusClass(rec.code), time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// Handler exposes /metrics in OpenMetrics text format.
|
||||
func (r *Registry) Handler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
r.write(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) write(w http.ResponseWriter) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
_, _ = fmt.Fprintln(w, "# HELP brain_query_duration_seconds Brain HTTP API request latency in seconds.")
|
||||
_, _ = fmt.Fprintln(w, "# TYPE brain_query_duration_seconds histogram")
|
||||
|
||||
// Sort keys for stable output (helps diffing scrape responses).
|
||||
keys := make([]labelKey, 0, len(r.series))
|
||||
for k := range r.series {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].path != keys[j].path {
|
||||
return keys[i].path < keys[j].path
|
||||
}
|
||||
return keys[i].status < keys[j].status
|
||||
})
|
||||
|
||||
for _, k := range keys {
|
||||
s := r.series[k]
|
||||
labels := fmt.Sprintf(`path=%q,status=%q`, k.path, k.status)
|
||||
for i, b := range r.buckets {
|
||||
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_bucket{%s,le=%q} %d\n",
|
||||
labels, formatBucket(b), s.counts[i].Load())
|
||||
}
|
||||
// +Inf bucket
|
||||
inf := s.counts[len(r.buckets)].Load()
|
||||
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, inf)
|
||||
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_sum{%s} %s\n",
|
||||
labels, formatSeconds(s.sumNs.Load()))
|
||||
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_count{%s} %d\n", labels, inf)
|
||||
}
|
||||
}
|
||||
|
||||
func formatBucket(b float64) string {
|
||||
// Match Prometheus convention: no trailing zeros.
|
||||
s := fmt.Sprintf("%g", b)
|
||||
if !strings.ContainsAny(s, ".e") {
|
||||
s = s + ".0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func formatSeconds(ns uint64) string {
|
||||
return fmt.Sprintf("%g", float64(ns)/1e9)
|
||||
}
|
||||
|
||||
func statusClass(code int) string {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return "2xx"
|
||||
case code >= 300 && code < 400:
|
||||
return "3xx"
|
||||
case code >= 400 && code < 500:
|
||||
return "4xx"
|
||||
case code >= 500 && code < 600:
|
||||
return "5xx"
|
||||
default:
|
||||
return "xxx"
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder captures the response code so middleware can label
|
||||
// the histogram by status class without buffering the body.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
if r.wroteHeader {
|
||||
return
|
||||
}
|
||||
r.code = code
|
||||
r.wroteHeader = true
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegistry_ObserveAndExpose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := New()
|
||||
// Three observations on the same series; one falls into each
|
||||
// representative band.
|
||||
r.Observe("/query", "2xx", 4*time.Millisecond) // ≤ 5ms
|
||||
r.Observe("/query", "2xx", 20*time.Millisecond) // ≤ 25ms
|
||||
r.Observe("/query", "2xx", 600*time.Millisecond) // ≤ 1s
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.Handler().ServeHTTP(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
|
||||
mustContain := []string{
|
||||
`# TYPE brain_query_duration_seconds histogram`,
|
||||
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="0.005"} 1`,
|
||||
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="0.025"} 2`,
|
||||
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="1.0"} 3`,
|
||||
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="+Inf"} 3`,
|
||||
`brain_query_duration_seconds_count{path="/query",status="2xx"} 3`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing line: %q\n--- body ---\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||
t.Errorf("content-type = %q, want text/plain prefix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_LabelsByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := New()
|
||||
r.Observe("/query", "2xx", time.Millisecond)
|
||||
r.Observe("/query", "5xx", time.Millisecond)
|
||||
r.Observe("/write", "2xx", time.Millisecond)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`brain_query_duration_seconds_count{path="/query",status="2xx"} 1`,
|
||||
`brain_query_duration_seconds_count{path="/query",status="5xx"} 1`,
|
||||
`brain_query_duration_seconds_count{path="/write",status="2xx"} 1`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q in body:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware_RecordsTiming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := New()
|
||||
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/query")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Exposition should now include /query.
|
||||
rec := httptest.NewRecorder()
|
||||
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `path="/query"`) {
|
||||
t.Errorf("expected /query series, got body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `status="2xx"`) {
|
||||
t.Errorf("expected 2xx status class, got body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRecorder_DefaultsTo200(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := New()
|
||||
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("code %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -59,11 +59,31 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
allWarnings = append(allWarnings, warnings...)
|
||||
}
|
||||
|
||||
pages, buildWarnings := BuildPages(allRaw, sourceSlug, date)
|
||||
allWarnings = append(allWarnings, buildWarnings...)
|
||||
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")
|
||||
|
||||
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)
|
||||
allWarnings = append(allWarnings, linkWarnings...)
|
||||
warnings = append(warnings, linkWarnings...)
|
||||
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
||||
merged := mergeAll(withRefs)
|
||||
|
||||
@@ -83,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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user