Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 | ||
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 | ||
|
|
f0055483a3 | ||
|
|
6d014c1d0f | ||
|
|
1001acfb44 | ||
|
|
6ad275b505 | ||
|
|
b600cc986c | ||
|
|
9bdab1c48c | ||
|
|
6520c2fc4b | ||
|
|
fcbd1072b6 | ||
|
|
3b7706b358 | ||
|
|
394a227877 | ||
|
|
0f84ab5eda | ||
|
|
5b57843346 | ||
|
|
ee1204d76b | ||
|
|
6d58336ce2 | ||
|
|
0785f14220 | ||
|
|
a7db0dd00d | ||
|
|
fb59c390e2 | ||
|
|
00e5f62c8e | ||
|
|
b9d03316fd | ||
|
|
da9bdc4cbb | ||
|
|
dcb9ff4a56 | ||
|
|
0454527b83 | ||
|
|
ef11864121 | ||
|
|
14b04a25cb | ||
|
|
66a9b8e725 | ||
|
|
9f8fb9c138 | ||
|
|
d39a18dd69 | ||
|
|
5288554338 | ||
|
|
2368564523 | ||
|
|
0e28b2125b | ||
|
|
723dab51ae | ||
|
|
06e21c019e | ||
|
|
76514215f4 | ||
|
|
7cf5bc221d | ||
|
|
f78a5474a5 | ||
|
|
c307b72bd5 | ||
|
|
38a2e91002 | ||
|
|
77f5e06d6b | ||
|
|
202212e8d5 | ||
|
|
b7938d4636 | ||
|
|
a1997838b0 | ||
|
|
77680c7445 | ||
|
|
d7a842f356 | ||
|
|
aad90f2dfe | ||
|
|
07fca9ee73 | ||
|
|
f6bf9b5f57 | ||
|
|
6606b38a76 | ||
|
|
4cfc98de56 | ||
|
|
0ac165cca3 | ||
|
|
43f92e3102 | ||
|
|
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 |
@@ -1,315 +0,0 @@
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
| Layer | Default | Fallback | Last resort |
|
||||
|-------|---------|----------|-------------|
|
||||
| Language | Go | Python | TypeScript, Java, C |
|
||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- **Go style**: golines, gofumpt, golangci-lint
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Infrastructure
|
||||
|
||||
Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
|
||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||
|
||||
Organized in thematic folders:
|
||||
|
||||
| Folder | Focus | Count |
|
||||
|--------|-------|-------|
|
||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||
|
||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
|
||||
### Key active projects
|
||||
|
||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||
|
||||
## Knowledge base — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
When working on a project tagged with a client name:
|
||||
1. Never send code, data, or context to cloud APIs — use local models only
|
||||
2. Never reference other client projects or their data
|
||||
3. Keep all artifacts within the client's git org / directory
|
||||
4. Treat everything as confidential unless told otherwise
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
|
||||
## How context propagates
|
||||
|
||||
Canonical sources of truth:
|
||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: supervisor
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**:
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- Client data never leaves local network unless explicitly cleared
|
||||
- Dependencies: audit with `govulncheck` before adding
|
||||
|
||||
## MCP endpoints
|
||||
|
||||
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||
|
||||
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||
(opt-in). Only `mode client-local` registers this endpoint.
|
||||
|
||||
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||
the routing pod; brain tools moved to the brain MCP.
|
||||
|
||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||
for shell scripts and non-MCP clients.
|
||||
|
||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||
|
||||
## Agent instructions
|
||||
|
||||
When acting as a coding agent on this project:
|
||||
|
||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||
2. Run `task check` before committing (lint + test + vet)
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
+61
-10
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
||||
|
||||
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
|
||||
@@ -54,6 +62,22 @@ These rules apply to every task across every project, regardless of harness.
|
||||
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 |
|
||||
@@ -64,7 +88,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
@@ -73,7 +97,12 @@ 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
|
||||
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||
- **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,
|
||||
@@ -83,6 +112,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **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:
|
||||
@@ -162,7 +211,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
| **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
|
||||
@@ -224,15 +273,17 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
-318
@@ -1,318 +0,0 @@
|
||||
# Cursor rules — auto-generated
|
||||
# Do not edit. Run: task context:sync
|
||||
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
| Layer | Default | Fallback | Last resort |
|
||||
|-------|---------|----------|-------------|
|
||||
| Language | Go | Python | TypeScript, Java, C |
|
||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- **Go style**: golines, gofumpt, golangci-lint
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Infrastructure
|
||||
|
||||
Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
|
||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||
|
||||
Organized in thematic folders:
|
||||
|
||||
| Folder | Focus | Count |
|
||||
|--------|-------|-------|
|
||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||
|
||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
|
||||
### Key active projects
|
||||
|
||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||
|
||||
## Knowledge base — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
When working on a project tagged with a client name:
|
||||
1. Never send code, data, or context to cloud APIs — use local models only
|
||||
2. Never reference other client projects or their data
|
||||
3. Keep all artifacts within the client's git org / directory
|
||||
4. Treat everything as confidential unless told otherwise
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
|
||||
## How context propagates
|
||||
|
||||
Canonical sources of truth:
|
||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: supervisor
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**:
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- Client data never leaves local network unless explicitly cleared
|
||||
- Dependencies: audit with `govulncheck` before adding
|
||||
|
||||
## MCP endpoints
|
||||
|
||||
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
||||
|
||||
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
||||
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
||||
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
||||
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
||||
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
||||
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
||||
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
||||
(opt-in). Only `mode client-local` registers this endpoint.
|
||||
|
||||
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
||||
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
||||
the routing pod; brain tools moved to the brain MCP.
|
||||
|
||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||
for shell scripts and non-MCP clients.
|
||||
|
||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
||||
|
||||
## Agent instructions
|
||||
|
||||
When acting as a coding agent on this project:
|
||||
|
||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||
2. Run `task check` before committing (lint + test + vet)
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
+25
-70
@@ -1,6 +1,6 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
"on":
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
@@ -13,9 +13,8 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||
environment: staging
|
||||
env:
|
||||
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
||||
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
|
||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -41,28 +40,6 @@ 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
|
||||
@@ -71,28 +48,38 @@ 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
|
||||
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
# In-cluster DNS to gitea's SSH NodePort service, not 127.0.0.1:30022
|
||||
# (that only worked when act_runner ran on koala's bare host network;
|
||||
# from inside the containerized runner's own pod netns, loopback
|
||||
# never reaches the host — "Connection refused", found 2026-07-27).
|
||||
#
|
||||
# Pass as -o overrides on the ssh invocation itself, NOT appended to
|
||||
# ~/.ssh/config: $HOME (/data) is a PVC that persists across job
|
||||
# runs on this runner (same "workspace not ephemeral" class as
|
||||
# brain: act-runner-host-executor-tmp-persists), so an appended
|
||||
# line here would pile up duplicate `Host git.d-ma.be` blocks
|
||||
# across every run — ssh_config is first-match-wins, so a stale
|
||||
# entry from an earlier failed run would silently shadow this
|
||||
# fix forever (exactly what happened once already: this fix's
|
||||
# own first attempt got appended AFTER an already-stale entry
|
||||
# and lost). CLI -o options always win regardless of file state,
|
||||
# so this step is safe to re-run any number of times.
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes -o HostName=gitea-ssh-nodeport.gitea.svc.cluster.local -o Port=22 -o StrictHostKeyChecking=no" \
|
||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||
|
||||
cd /tmp/infra-update
|
||||
|
||||
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||
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/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
||||
"k3s/apps/routing/deployment.yaml"
|
||||
|
||||
git config user.email "cd-bot@d-ma.be"
|
||||
git config user.name "CD Bot"
|
||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
||||
"k3s/apps/routing/deployment.yaml"
|
||||
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
|
||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
git push
|
||||
|
||||
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||
|
||||
- name: Trigger Flux reconcile (immediate)
|
||||
run: |
|
||||
@@ -103,7 +90,7 @@ jobs:
|
||||
|
||||
- name: Wait for Flux to apply new ingestion image
|
||||
run: |
|
||||
EXPECTED="gitea.d-ma.be/mathias/ingestion:${{ github.sha }}"
|
||||
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 "")
|
||||
@@ -132,35 +119,3 @@ jobs:
|
||||
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Wait for Flux to apply new routing image
|
||||
run: |
|
||||
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
|
||||
for i in $(seq 1 60); do
|
||||
CURRENT=$(kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||
echo "✓ Flux applied routing image after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kubectl get deploy routing -n routing \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||
| grep -qx "$EXPECTED" \
|
||||
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
||||
|
||||
- name: Verify routing rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/routing \
|
||||
--namespace routing \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
echo "── pod status ──"
|
||||
kubectl get pods -n routing -o wide
|
||||
echo "── events ──"
|
||||
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
||||
echo "── describe ──"
|
||||
kubectl describe pods -n routing -l app=routing | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
"on":
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# gitleaks config for the hyperguild repo (infra#39 — leak prevention pass,
|
||||
# Phase 3 checklist item: "gitleaks pre-commit hook in infra AND hyperguild").
|
||||
#
|
||||
# Ported from mathias/infra's .gitleaks.toml (2026-08-04) — same homelab
|
||||
# token-shape rules, minus the SOPS/searxng allowlists infra needed (this
|
||||
# repo doesn't use SOPS).
|
||||
|
||||
title = "hyperguild gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# --- Homelab-specific rules -------------------------------------------------
|
||||
|
||||
[[rules]]
|
||||
id = "homelab-static-bearer"
|
||||
description = "Homelab MCP/LLM static bearer or API key assigned a long literal value"
|
||||
regex = '''(?i)\b(DMABE_[A-Z0-9_]+|[A-Z0-9_]*MCP_TOKEN|ROUTING_MCP_TOKEN|INFRA_MCP_TOKEN|BRAIN_MCP_TOKEN|GITEA_MCP_TOKEN|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|DMABE_LLMAPI_KEY|BRAIN_PG_DSN)\s*[:=]\s*['"]?([A-Za-z0-9/_+.\-]{16,})['"]?'''
|
||||
keywords = ["dmabe_", "mcp_token", "litellm_master_key", "litellm_salt_key", "llmapi_key", "brain_pg_dsn"]
|
||||
[[rules.allowlists]]
|
||||
description = "Env indirection is not a literal secret"
|
||||
regexes = [
|
||||
'''os\.environ''',
|
||||
'''valueFrom''',
|
||||
'''secretKeyRef''',
|
||||
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||
'''REDACTED''',
|
||||
'''<[A-Z_]+>''',
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "homelab-authorization-bearer"
|
||||
description = "Hardcoded Authorization: Bearer header"
|
||||
regex = '''(?i)authorization['"]?\s*[:=]\s*['"]?bearer\s+([A-Za-z0-9/_+.\-=]{16,})'''
|
||||
keywords = ["authorization", "bearer"]
|
||||
[[rules.allowlists]]
|
||||
description = "Env indirection is not a literal secret"
|
||||
regexes = [
|
||||
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||
'''os\.environ''',
|
||||
'''REDACTED''',
|
||||
'''<[A-Z_]+>''',
|
||||
]
|
||||
|
||||
# --- Global allowlist: claudewatcher's own scrubber test fixtures ------------
|
||||
# ingestion/internal/claudewatcher/{scrubber,watcher}_test.go deliberately
|
||||
# contain fake secret-shaped literals to test that the scrubber detects and
|
||||
# redacts them. Verified 2026-08-04: all 9 findings here are test fixtures
|
||||
# (github-pat, jwt, generic-api-key, homelab-authorization-bearer rules) plus
|
||||
# 1 doc finding that was gitleaks matching the literal placeholder word
|
||||
# "REDACTED" in a plan doc — not a real secret in either case.
|
||||
[[allowlists]]
|
||||
description = "claudewatcher scrubber test fixtures — deliberately fake secrets"
|
||||
paths = [
|
||||
'''ingestion/internal/claudewatcher/scrubber_test\.go$''',
|
||||
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)"
|
||||
condition = "AND"
|
||||
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
|
||||
regexes = ['''REDACTED''']
|
||||
@@ -6,6 +6,13 @@
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://git-mcp.d-ma.be/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
||||
|
||||
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
|
||||
@@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness.
|
||||
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 |
|
||||
@@ -59,7 +83,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
@@ -68,7 +92,12 @@ 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
|
||||
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||
- **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,
|
||||
@@ -78,6 +107,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **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:
|
||||
@@ -157,7 +206,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
| **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
|
||||
@@ -219,15 +268,17 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,30 +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/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"]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Icebox — retired code (recoverable, not destroyed)
|
||||
|
||||
Per issue #75 (consolidate to a single harness), the routing-pod path was removed
|
||||
from the live tree. It is **preserved and recoverable**, not deleted without trace.
|
||||
|
||||
## What was iceboxed (2026-07-01, issue #75)
|
||||
|
||||
| Path | Why |
|
||||
|------|-----|
|
||||
| `cmd/routing/` | The routing MCP-server binary; every skill call was wrapped through the broken pass-rate router (`wrap(skillName)`). |
|
||||
| `internal/routing/` | Router / Fetcher / Policy / pass-rate. Signal is survivorship-biased and unusable as-is (infra#174). |
|
||||
| `internal/skills/{review,debug,retrospective,trainer,project}/` | Skill handlers usable **only** through `cmd/routing` (verified: each imported solely by `cmd/routing`). |
|
||||
| `Dockerfile.routing` | Built `cmd/routing` exclusively. |
|
||||
| `.gitea/workflows/cd.yml` (routing steps only) | Removed the routing image build + infra image-bump + Flux-wait/rollout-verify for routing; **ingestion build/deploy is unchanged**. |
|
||||
|
||||
## Why
|
||||
|
||||
The live minimal harness is `cmd/hyperguild` + `brain-mcp` (+ `gitea-mcp` available) —
|
||||
that is what ran the infra#170 loop-1 experiment (routing/injection machinery off) and
|
||||
closed it twice. `cmd/hyperguild` has **zero** transitive dependency on `internal/routing`
|
||||
or `cmd/routing`'s packages (it imports only `internal/tier`). The routing pass-rate signal
|
||||
is being retired, not resurrected (fresh start, per infra#174).
|
||||
|
||||
## How to recover
|
||||
|
||||
Everything above is preserved at commit `00e5f62` under:
|
||||
|
||||
- **tag** `icebox/cmd-routing-2026-07-01`
|
||||
- **branch** `icebox/cmd-routing`
|
||||
|
||||
```bash
|
||||
# inspect
|
||||
git checkout icebox/cmd-routing-2026-07-01
|
||||
|
||||
# restore specific packages onto a branch
|
||||
git checkout icebox/cmd-routing-2026-07-01 -- cmd/routing internal/routing \
|
||||
internal/skills/review internal/skills/debug internal/skills/retrospective \
|
||||
internal/skills/trainer internal/skills/project Dockerfile.routing
|
||||
```
|
||||
|
||||
## Deliberately NOT touched here (separate scope)
|
||||
|
||||
- **Live k8s routing deployment** (`infra` repo, `k3s/apps/routing/`) still runs its last
|
||||
image; CD no longer rebuilds/redeploys it. Tearing down that deployment is a separate
|
||||
infra-repo task.
|
||||
- **`internal/skills/{brain,org,sessionlog}/`** — kept per #75; already had no importer
|
||||
(orphaned before this cut), harmless, compile + test green.
|
||||
- **`config/supervisor/{review,debug,retrospective,trainer-*}.md`** — routing skill prompts,
|
||||
now orphaned data; left in place (not code, no build impact).
|
||||
@@ -5,14 +5,31 @@ 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 over HTTP (Tailscale)
|
||||
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
||||
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
||||
├──▶ 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, …)
|
||||
@@ -20,34 +37,28 @@ Your Claude Code session (in any project)
|
||||
▼
|
||||
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
|
||||
|
||||
@@ -56,9 +67,9 @@ Create `.mcp.json` in your project root:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supervisor": {
|
||||
"routing": {
|
||||
"type": "http",
|
||||
"url": "http://koala:30320/mcp"
|
||||
"url": "http://koala:30310/mcp"
|
||||
},
|
||||
"brain": {
|
||||
"type": "http",
|
||||
@@ -68,65 +79,77 @@ Create `.mcp.json` in your project root:
|
||||
}
|
||||
```
|
||||
|
||||
Two MCP servers are exposed today, both reachable over Tailscale:
|
||||
Two MCP servers are exposed, both reachable over Tailscale:
|
||||
|
||||
- **`supervisor`** at `koala:30320` — skill workers (`tdd_red/green/refactor`,
|
||||
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
|
||||
- **`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`) and `session_log`. Hosted by the ingestion
|
||||
service directly, no separate pod.
|
||||
`brain_ingest`, `brain_ingest_raw`, `brain_answer`, `brain_classify`) and `session_log`.
|
||||
|
||||
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
|
||||
|
||||
Open Claude Code in your project — run `/mcp` to confirm both servers are listed.
|
||||
|
||||
## A typical TDD session
|
||||
## A typical 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 |
|
||||
|------|-------|-----------|
|
||||
|------|-------|-----------|
|
||||
| 1 | full-online | Can reach api.anthropic.com |
|
||||
| 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 |
|
||||
| `SUPERVISOR_MCP_TOKEN` | — | Optional bearer token for the supervisor MCP HTTP endpoint; when empty, no auth is enforced |
|
||||
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
|
||||
| `ROUTING_MCP_TOKEN` | — | Optional bearer token for the routing MCP HTTP endpoint |
|
||||
| `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` | At/above pass rate, route to fast model |
|
||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
|
||||
| `HYPERGUILD_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 |
|
||||
|
||||
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL` and `HYPERGUILD_THINKING_MODEL` for routing to do useful work. If a model is missing, LiteLLM returns 4xx, the routing pod's fast route fails, the fail-open retry on the thinking model likely also fails (since both are missing), and the only signal is `final_status: "fail"` on `_routing` entries in the brain.
|
||||
> **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.
|
||||
|
||||
## Phase 2 (planned)
|
||||
## Open issues
|
||||
|
||||
- `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
|
||||
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
|
||||
|
||||
+33
-4
@@ -17,8 +17,6 @@ tasks:
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -90,12 +88,12 @@ tasks:
|
||||
cmds:
|
||||
- task: context:sync
|
||||
- cmd: |
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||
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 .cursorrules .aider.conventions.md .context/system-prompt.txt" >&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
|
||||
@@ -103,6 +101,37 @@ tasks:
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
- task: security:gitleaks
|
||||
|
||||
# ── Security ─────────────────────────────────────────────
|
||||
security:gitleaks:
|
||||
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- |
|
||||
GL="$(command -v gitleaks || true)"
|
||||
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||
if [ -z "$GL" ]; then
|
||||
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
|
||||
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||
exit 0
|
||||
fi
|
||||
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
|
||||
|
||||
security:gitleaks:history:
|
||||
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- |
|
||||
GL="$(command -v gitleaks || true)"
|
||||
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||
if [ -z "$GL" ]; then
|
||||
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
|
||||
echo "until the infra#39 rotation pass completes — triage against the rotation list."
|
||||
"$GL" detect --redact --config .gitleaks.toml
|
||||
|
||||
lint:
|
||||
cmds:
|
||||
|
||||
@@ -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`.
|
||||
@@ -112,16 +112,15 @@ Flags:
|
||||
- `--out PATH` — output file (default `./.mcp.json`)
|
||||
- `--force` — overwrite an existing file
|
||||
|
||||
Modes:
|
||||
Modes (all list **brain + gitea** — Gitea is the audit-trail invariant of the
|
||||
consolidated single harness, #75):
|
||||
|
||||
- **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.
|
||||
- **cloud** — brain + gitea MCP.
|
||||
- **client-local** — brain + gitea MCP. (The former `routing` entry pointing at
|
||||
`koala:30310/mcp` was removed — the routing pod is iceboxed, #75.)
|
||||
- **sovereign** — brain + gitea, 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
|
||||
|
||||
|
||||
+26
-19
@@ -59,31 +59,40 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.
|
||||
return nil
|
||||
}
|
||||
|
||||
// giteaMCPURL is the public Gitea MCP endpoint (OAuth via Dex/Authentik). Gitea
|
||||
// is the audit-trail invariant of the consolidated single harness (#75), so every
|
||||
// mode lists it as an available connection.
|
||||
const giteaMCPURL = "https://git-mcp.d-ma.be/mcp"
|
||||
|
||||
func brainEntry(brainURL string) map[string]any {
|
||||
return map[string]any{
|
||||
"url": brainURL + "/mcp",
|
||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||
}
|
||||
}
|
||||
|
||||
func giteaEntry() map[string]any {
|
||||
return map[string]any{
|
||||
"url": giteaMCPURL,
|
||||
"description": "Gitea MCP — issues/PRs/repo ops (audit-trail invariant)",
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func modeClientLocal(brainURL string) map[string]any {
|
||||
// The routing pod is iceboxed (#75); the consolidated harness is brain + gitea.
|
||||
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",
|
||||
},
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -92,10 +101,8 @@ 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",
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -35,11 +35,13 @@ func TestRunMode_Cloud_Default(t *testing.T) {
|
||||
servers, ok := got["mcpServers"].(map[string]any)
|
||||
require.True(t, ok, "mcpServers must be a JSON object")
|
||||
assert.Contains(t, servers, "brain")
|
||||
assert.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing")
|
||||
assert.NotContains(t, got, "_mode_note")
|
||||
}
|
||||
|
||||
func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
||||
func TestRunMode_ClientLocal_NoRouting_HasGitea(t *testing.T) {
|
||||
// #75: the routing pod is iceboxed; the consolidated harness is brain + gitea.
|
||||
dir := t.TempDir()
|
||||
outPath := filepath.Join(dir, ".mcp.json")
|
||||
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||
@@ -51,17 +53,11 @@ func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
||||
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"])
|
||||
require.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing", "routing pod iceboxed (#75)")
|
||||
}
|
||||
|
||||
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
||||
func TestModeGiteaEntryPresentAndWellFormed(t *testing.T) {
|
||||
tmp := t.TempDir() + "/mcp.json"
|
||||
out := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
@@ -73,13 +69,10 @@ func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
||||
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"])
|
||||
require.NotContains(t, servers, "routing")
|
||||
gitea, ok := servers["gitea"].(map[string]any)
|
||||
require.True(t, ok, "gitea entry must be present (audit-trail invariant)")
|
||||
assert.Equal(t, "https://git-mcp.d-ma.be/mcp", gitea["url"])
|
||||
}
|
||||
|
||||
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||
@@ -94,6 +87,7 @@ func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||
assert.Contains(t, got, "_mode_note")
|
||||
servers := got["mcpServers"].(map[string]any)
|
||||
assert.Contains(t, servers, "brain")
|
||||
assert.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
)
|
||||
|
||||
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Runbook: exercising review/debug traffic to fill the pass-rate dataset
|
||||
|
||||
**Why this exists:** the routing pod's local-vs-cloud decision is gated on a
|
||||
pass-rate history that only accrues from real `review`/`debug` invocations
|
||||
**through the pod**. Until the dataset has data, the fast (local) path never
|
||||
activates and the core hypothesis (hyperguild #35) can't be validated. This
|
||||
runbook is how you spin that flywheel.
|
||||
|
||||
## The one trap
|
||||
|
||||
Pass-rate accrues **only** when a skill tool is called via the routing pod's MCP
|
||||
endpoint. These look like they should count but **do not**:
|
||||
|
||||
- **Crush** — talks to LiteLLM directly, bypasses the pod. No log.
|
||||
- **claude.ai web / Claude Desktop without the connector** — no log.
|
||||
- **Running the local `code-review` / `debug` skills** (`~/dev/.skills`) inline in
|
||||
a Claude Code session — those are local skills, not the pod's MCP tools. No log.
|
||||
|
||||
Only a `tools/call` to the routing pod records a pass/fail.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Purpose | URL | Auth |
|
||||
|---------|-----|------|
|
||||
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||
|
||||
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||
plus `session_log`, `retrospective`, `trainer`.
|
||||
|
||||
## Step 1 — connect the routing pod as an MCP server
|
||||
|
||||
**Local** (needs the bearer token; keep it out of argv via 1Password):
|
||||
|
||||
```bash
|
||||
op run --env-file ~/.op-env -- \
|
||||
claude mcp add routing --transport http http://koala:30310/mcp \
|
||||
--header "Authorization: Bearer $ROUTING_MCP_TOKEN"
|
||||
```
|
||||
|
||||
**Remote** (claude.ai / Claude Desktop): add a custom connector pointing at
|
||||
`https://routing-mcp.d-ma.be/mcp`; it completes OAuth against `auth.d-ma.be`,
|
||||
no static token.
|
||||
|
||||
Verify: a `tools/list` should return `review`, `debug`, `session_log`,
|
||||
`retrospective`, `trainer`.
|
||||
|
||||
## Step 2 — route real work through it
|
||||
|
||||
In normal sessions, invoke the pod's tools instead of reviewing/debugging inline:
|
||||
|
||||
- *"Use the **routing** `review` tool on this diff."*
|
||||
- *"**debug** this failure through the routing pod."*
|
||||
|
||||
Each call logs an outcome to ingestion → `/pass-rate` ticks up.
|
||||
|
||||
## Step 3 — how routing actually picks the model
|
||||
|
||||
Per `internal/routing/policy.go`:
|
||||
|
||||
1. pass-rate `nil` (cold) → **local** fast tier. The router defaults to local
|
||||
from invocation #1, not to cloud — so the fast tier is exercised immediately.
|
||||
2. pass-rate `>= 0.90` (floor) → **local**; `< 0.70` (ceil) → **cloud/thinking**;
|
||||
in the `[0.70, 0.90)` band a request-hash bit samples 50/50.
|
||||
3. On a local execution error the router falls open to the thinking model for
|
||||
that one call (logged `thinking_fallback`).
|
||||
|
||||
So you are not "paying in on cloud" — cold calls already run on the (validated)
|
||||
local fast tier **`koala/qwen36-35b-a3b`** (Qwen3.6-35B-A3B MTP, promoted
|
||||
2026-06-29, infra `c66a195`, `HYPERGUILD_FAST_MODEL`). Accumulating passes just
|
||||
keeps it there once real pass-rate is computed.
|
||||
|
||||
> **Instrumentation note (#73, fixed 2026-06-30):** until v0.11.1 the pod logged
|
||||
> successes as `"skip"` (not `"pass"`), under `skill:"_routing"`, via an
|
||||
> unauthenticated POST that silently 401'd — so `/pass-rate` stayed at zero no
|
||||
> matter how much you used it. That's fixed and verified (a real review call now
|
||||
> moves `/pass-rate?skill=review` 0→1). If you see traffic not registering,
|
||||
> re-check #73's three failure modes first.
|
||||
|
||||
## Target & verification
|
||||
|
||||
- **50 logged invocations** across `review` + `debug` within the 14-day window.
|
||||
The clock restarts **2026-06-30** (the day instrumentation was verified working;
|
||||
the original 2026-06-26→07-10 window measured broken plumbing) → **kill-date
|
||||
2026-07-14**, ~4 calls/day (1 already logged from the #73 smoke test).
|
||||
- Check progress anytime:
|
||||
|
||||
```bash
|
||||
curl -s "http://koala:30330/pass-rate?skill=review"
|
||||
curl -s "http://koala:30330/pass-rate?skill=debug"
|
||||
```
|
||||
|
||||
- If ~4–5/day isn't realistic alongside Crush, that is **not** a failure — per
|
||||
#35 deliverable #1 it's the signal hyperguild isn't on the work critical path,
|
||||
and the pre-decided **Berget fallback** (`gpt-oss-120b` / `qwen3-32b`) carries
|
||||
the fast tier instead.
|
||||
|
||||
## Refs
|
||||
|
||||
- hyperguild #35 — the validation issue (data gate = deliverable #1)
|
||||
- `docs/multi-model-routing.md` — routing policy
|
||||
- brain: `wiki/homelab/hypotheses/qwen36-35b-a3b-fast-model-experiment-2026-05-28.md`
|
||||
- infra `c66a195` — qwen36 promotion; `models.yml` / `llama-swap-configmap.yaml`
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/mathiasbq/supervisor
|
||||
module git.d-ma.be/mathias/hyperguild
|
||||
|
||||
go 1.26.1
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+290
-15
@@ -8,23 +8,97 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||
"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/embed"
|
||||
"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"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// kubeClient builds an in-cluster Kubernetes client (falls back to
|
||||
// $KUBECONFIG for local dev/testing against a real cluster).
|
||||
func kubeClient() (kubernetes.Interface, error) {
|
||||
if cfg, err := rest.InClusterConfig(); err == nil {
|
||||
return kubernetes.NewForConfig(cfg)
|
||||
}
|
||||
rules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{})
|
||||
cfg, err := cc.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kube config (no in-cluster, no kubeconfig): %w", err)
|
||||
}
|
||||
return kubernetes.NewForConfig(cfg)
|
||||
}
|
||||
|
||||
// claudeSink converts each claudewatcher.Batch into a raw session dump
|
||||
// under brain/archive/claude-sessions/<host>/. Deliberately NOT a wiki
|
||||
// note (api.WriteNote / brain/wiki/) — raw full transcripts out-ranked
|
||||
// curated ai-sessions summaries in BM25 (177,818 vs 45,335 on the same
|
||||
// query) and duplicated content already summarized elsewhere. Kept for
|
||||
// deep lookups, never indexed. See ai-sessions#10.
|
||||
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
|
||||
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create claude-sessions archive dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("write claude session archive: %w", err)
|
||||
}
|
||||
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
||||
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.
|
||||
@@ -69,6 +143,57 @@ func envInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||
// set it builds the classification-aware DegradingSink (loki central +
|
||||
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||
// otherwise it falls back to a plain slog sink. The buffer lives under the
|
||||
// brain dir so it survives process restarts.
|
||||
func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) capture.AuditSink {
|
||||
lokiURL := os.Getenv("BRAIN_LOKI_URL")
|
||||
central := audit.NewLokiCentral(lokiURL)
|
||||
if central == nil {
|
||||
logger.Info("capture audit: slog sink (BRAIN_LOKI_URL unset)")
|
||||
return audit.NewSlogSink(logger)
|
||||
}
|
||||
buffer, err := audit.NewFileBuffer(filepath.Join(brainDir, ".audit-buffer", "capture.jsonl"))
|
||||
if err != nil {
|
||||
logger.Error("capture audit buffer init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
||||
// so DegradingSink/Reconcile skip it cleanly.
|
||||
var notifier audit.Notifier
|
||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||
notifier = n
|
||||
}
|
||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||
return audit.NewDegradingSink(central, buffer, notifier)
|
||||
}
|
||||
|
||||
// splitList parses a comma-separated env value into a trimmed,
|
||||
// empty-free slice. Used for the capture sovereign-principal allowlist.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -116,6 +241,15 @@ func main() {
|
||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||
}
|
||||
|
||||
// Gitea ticket tracker for the capture capability (#52). Token via env
|
||||
// only — never logged or in argv. Both vars must be set to enable it;
|
||||
// gitea.New returns nil otherwise, leaving ticket integration off.
|
||||
giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be")
|
||||
if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil {
|
||||
mcpSrv = mcpSrv.WithIssueTracker(tracker)
|
||||
logger.Info("brain gitea tracker configured", "url", giteaURL)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -140,6 +274,32 @@ func main() {
|
||||
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:
|
||||
@@ -161,6 +321,79 @@ func main() {
|
||||
Pipeline: pipelineCfg,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Gitea push webhook -> on-demand brain-sync Job, instead of waiting up
|
||||
// to 15 minutes for the next CronJob poll. Off by default (opt in via
|
||||
// GITEA_WEBHOOK_SECRET) since it needs Job-create RBAC in the "brain"
|
||||
// namespace that a fresh deploy won't have granted yet.
|
||||
var webhookHandler *webhook.Handler
|
||||
if webhookSecret := os.Getenv("GITEA_WEBHOOK_SECRET"); webhookSecret != "" {
|
||||
kc, err := kubeClient()
|
||||
if err != nil {
|
||||
logger.Error("brain-sync webhook: kube client", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
webhookHandler = &webhook.Handler{
|
||||
Secret: webhookSecret,
|
||||
Clientset: kc,
|
||||
Namespace: envOr("BRAIN_SYNC_NAMESPACE", "brain"),
|
||||
CronJobName: envOr("BRAIN_SYNC_CRONJOB", "brain-sync"),
|
||||
WatchRepo: envOr("BRAIN_SYNC_WATCH_REPO", "mathias/brain"),
|
||||
Logger: logger,
|
||||
}
|
||||
logger.Info("brain-sync webhook enabled", "namespace", webhookHandler.Namespace, "cronjob", webhookHandler.CronJobName)
|
||||
}
|
||||
|
||||
if vectorStore != nil {
|
||||
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||
@@ -178,18 +411,20 @@ func main() {
|
||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||
mux.HandleFunc("GET /pending", h.Pending)
|
||||
mux.HandleFunc("POST /promote", h.Promote)
|
||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||
var jwtValidator *auth.Validator
|
||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||
audience := os.Getenv("MCP_AUDIENCE")
|
||||
v, err := auth.NewValidator(dexURL, audience)
|
||||
if err != nil {
|
||||
logger.Error("build jwt validator", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
jwtValidator = v
|
||||
logger.Info("jwt auth enabled", "issuer", dexURL)
|
||||
if webhookHandler != nil {
|
||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||
}
|
||||
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
|
||||
@@ -199,13 +434,44 @@ func main() {
|
||||
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))
|
||||
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||
if resourceURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
}
|
||||
|
||||
mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv))
|
||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||
|
||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||
// tracker to file action items, so it only mounts when Gitea is
|
||||
// configured. It reuses the MCP server's graph-wired brain store (one
|
||||
// implementation), the classification tags for the I1 gate, and a
|
||||
// classification-aware audit sink (loki + durable buffer + ntfy when
|
||||
// BRAIN_LOKI_URL is set, else a plain slog sink). The handler does its
|
||||
// own auth (static + JWT) because it needs the principal to derive the
|
||||
// trust-zone origin — the chassis middleware hides it.
|
||||
if tracker := mcpSrv.IssueTracker(); tracker != nil {
|
||||
classCfg, cerr := classification.Load(brainDir)
|
||||
if cerr != nil {
|
||||
logger.Error("load classification config", "err", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||
captureSvc := capture.NewService(
|
||||
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
|
||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
mux.Handle("POST /capture", captureH)
|
||||
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||
// WithCapture mutates the same instance, so the tool appears live.
|
||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
||||
} else {
|
||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||
}
|
||||
|
||||
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||
// integration UI, which has no static-Bearer field. Setting both
|
||||
@@ -235,6 +501,15 @@ func main() {
|
||||
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"
|
||||
if watchInterval > 0 {
|
||||
@@ -249,7 +524,7 @@ func main() {
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// ingestion/cmd/server/main_test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||
)
|
||||
|
||||
func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
|
||||
|
||||
err := sink.Ingest(context.Background(), claudewatcher.Batch{
|
||||
Host: "koala",
|
||||
FilePath: "/host-home-claude/projects/-home-mathias-dev/abc.jsonl",
|
||||
SessionID: "abc",
|
||||
ProjectID: "-home-mathias-dev",
|
||||
Turns: []claudewatcher.Turn{
|
||||
{Type: "assistant", Content: "did a thing"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "archive", "claude-sessions", "koala", "session-koala-abc.md"))
|
||||
require.NoError(t, err, "raw session dump must land in the non-indexed archive")
|
||||
assert.Contains(t, string(got), "did a thing")
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
||||
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||
}
|
||||
+49
-5
@@ -3,28 +3,72 @@ module github.com/mathiasbq/hyperguild/ingestion
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/stretchr/testify v1.11.1
|
||||
k8s.io/api v0.31.3
|
||||
k8s.io/apimachinery v0.31.3
|
||||
k8s.io/client-go v0.31.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/imdario/mergo v0.3.6 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/pgx/v5 v5.9.2
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
+143
-3
@@ -1,10 +1,47 @@
|
||||
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
|
||||
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
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=
|
||||
@@ -13,6 +50,19 @@ 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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=
|
||||
@@ -25,28 +75,118 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
||||
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
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=
|
||||
k8s.io/api v0.31.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8=
|
||||
k8s.io/api v0.31.3/go.mod h1:UJrkIp9pnMOI9K2nlL6vwpxRzzEX5sWgn8kGQe92kCE=
|
||||
k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4=
|
||||
k8s.io/apimachinery v0.31.3/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
||||
k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4=
|
||||
k8s.io/client-go v0.31.3/go.mod h1:2CgjPUTpv3fE5dNygAr2NcM8nhHzXvxB8KL5gYc3kJs=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -51,12 +51,13 @@ type queryRequest struct {
|
||||
}
|
||||
|
||||
type writeRequest 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"`
|
||||
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"`
|
||||
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
||||
}
|
||||
|
||||
type ingestRequest struct {
|
||||
@@ -115,12 +116,13 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
Content string
|
||||
Filename string
|
||||
Type string
|
||||
Domain string
|
||||
Wing string
|
||||
Hall string
|
||||
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
||||
}
|
||||
|
||||
// WriteNote writes a markdown note into the brain. Returns the path
|
||||
@@ -154,26 +156,111 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
|
||||
existingFields, body := splitFrontmatter(opts.Content)
|
||||
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||
for _, f := range existingFields {
|
||||
existingByKey[f.key] = f
|
||||
}
|
||||
emitted := make(map[string]bool, 6)
|
||||
|
||||
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)
|
||||
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||
|
||||
// writeField merges one key: opts.Content's own value (if the note already
|
||||
// carries this field in its own frontmatter) always wins over the fallback,
|
||||
// so promotion/extraction-step metadata survives verbatim instead of being
|
||||
// shadowed by a second, stacked frontmatter block (#86).
|
||||
writeField := func(key, fallback string) {
|
||||
emitted[key] = true
|
||||
if f, ok := existingByKey[key]; ok {
|
||||
for _, line := range f.lines {
|
||||
fm.WriteString(line)
|
||||
fm.WriteString("\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
if fallback != "" {
|
||||
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||
}
|
||||
}
|
||||
if opts.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||
writeField("type", opts.Type)
|
||||
writeField("domain", opts.Domain)
|
||||
|
||||
sourceType := opts.SourceType
|
||||
if sourceType == "" && opts.Hall == "facts" {
|
||||
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||
// writer ran itself), not external claims — default to internal and
|
||||
// require an explicit source_type: external opt-out for the rare
|
||||
// citation-needing entry (brain-gardener#7).
|
||||
sourceType = "internal"
|
||||
}
|
||||
writeField("source_type", sourceType)
|
||||
|
||||
for _, f := range existingFields {
|
||||
if emitted[f.key] {
|
||||
continue
|
||||
}
|
||||
for _, line := range f.lines {
|
||||
fm.WriteString(line)
|
||||
fm.WriteString("\n")
|
||||
}
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||
// along with its raw line and any indented continuation lines (e.g. a
|
||||
// bulleted list value spanning multiple lines).
|
||||
type frontmatterField struct {
|
||||
key string
|
||||
lines []string
|
||||
}
|
||||
|
||||
// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of
|
||||
// content, returning its top-level fields in original order and the
|
||||
// remaining body. If content has no leading frontmatter block, fields is
|
||||
// nil and body is content unchanged.
|
||||
func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
||||
if !strings.HasPrefix(content, "---\n") {
|
||||
return nil, content
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
i := 1
|
||||
var cur *frontmatterField
|
||||
for ; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
if cur != nil {
|
||||
fields = append(fields, *cur)
|
||||
}
|
||||
key, _, _ := strings.Cut(line, ":")
|
||||
cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}}
|
||||
} else if cur != nil {
|
||||
cur.lines = append(cur.lines, line)
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
fields = append(fields, *cur)
|
||||
}
|
||||
body = strings.Join(lines[i:], "\n")
|
||||
return fields, body
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -332,11 +419,19 @@ func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||
}
|
||||
|
||||
// supportedExtensions lists file extensions that IngestPath will process.
|
||||
var supportedExtensions = map[string]bool{
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".pdf": true,
|
||||
// isSupportedExtension reports whether IngestPath will process ext.
|
||||
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg require docmark (ADR-0013) and are only
|
||||
// supported when DOCMARK_URL is configured — checked per-call (not cached at
|
||||
// package init) so it reflects the environment at request time.
|
||||
func isSupportedExtension(ext string) bool {
|
||||
switch ext {
|
||||
case ".md", ".txt", ".pdf":
|
||||
return true
|
||||
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||
return os.Getenv("DOCMARK_URL") != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||
@@ -369,7 +464,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedExtensions[ext] {
|
||||
if !isSupportedExtension(ext) {
|
||||
return nil
|
||||
}
|
||||
content, readErr := extract.Text(path)
|
||||
@@ -397,7 +492,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||
if !supportedExtensions[ext] {
|
||||
if !isSupportedExtension(ext) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||
return
|
||||
}
|
||||
@@ -483,6 +578,40 @@ func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]int{"updated": n})
|
||||
}
|
||||
|
||||
// Pending handles GET /pending — list raw/ notes awaiting promotion.
|
||||
func (h *Handler) Pending(w http.ResponseWriter, _ *http.Request) {
|
||||
pending, err := ListPending(h.brainDir)
|
||||
if err != nil {
|
||||
h.logger.Error("pending failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "pending error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"pending": pending})
|
||||
}
|
||||
|
||||
type promoteRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
}
|
||||
|
||||
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||
// hall / collision / missing source is a 400 (caller error), not a 500.
|
||||
func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
||||
var req promoteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"path": rel})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
|
||||
@@ -118,6 +118,116 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
||||
assert.Contains(t, string(content), "Some learning.")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "# Claude session abc (koala)\n\nBody.\n",
|
||||
Filename: "session-koala-abc",
|
||||
Wing: "claude-sessions",
|
||||
Hall: "facts",
|
||||
Type: "source",
|
||||
SourceType: "internal",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "source_type: internal")
|
||||
assert.Contains(t, string(got), "wing: claude-sessions")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallFactsDefaultsSourceTypeInternalWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "manually captured fact.\n",
|
||||
Wing: "agentsquad",
|
||||
Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
// Most hall=facts entries are first-party (an eval/benchmark the agent ran
|
||||
// itself), not external claims — default to internal, require explicit
|
||||
// opt-out for the rare case that does need a citation (brain-gardener#7).
|
||||
assert.Contains(t, string(got), "source_type: internal")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallFactsPreservesExplicitExternalSourceType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "vendor pricing claim, needs a citation.\n",
|
||||
Wing: "agentsquad",
|
||||
Hall: "facts",
|
||||
SourceType: "external",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "source_type: external")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "a decision record.\n",
|
||||
Wing: "agentsquad",
|
||||
Hall: "decisions",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(got), "source_type")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n",
|
||||
Filename: "act-runner-host-executor",
|
||||
Wing: "homelab",
|
||||
Hall: "failures",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
body := string(got)
|
||||
|
||||
// exactly one frontmatter block: only two "---" delimiter lines total
|
||||
assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks")
|
||||
assert.Contains(t, body, "wing: homelab")
|
||||
assert.Contains(t, body, "hall: failures")
|
||||
assert.Contains(t, body, "title: act_runner host-executor")
|
||||
assert.Contains(t, body, "tags: [gitea-actions, act_runner]")
|
||||
assert.Contains(t, body, "# Body")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||
Filename: "note",
|
||||
Wing: "agentsquad",
|
||||
Hall: "decisions",
|
||||
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "type: hypothesis")
|
||||
assert.NotContains(t, string(got), "type: decision")
|
||||
}
|
||||
|
||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// ingestion/internal/api/ingestpath_docmark_test.go
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIngestPath_DocxUnsupportedWhenDocmarkNotConfigured(t *testing.T) {
|
||||
t.Setenv("DOCMARK_URL", "")
|
||||
_, h := setup(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.IngestPath(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestIngestPath_DocxSupportedWhenDocmarkConfigured(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(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"# Converted Doc"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||
|
||||
_, h := setup(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.IngestPath(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
pages, ok := resp["pages"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.NotEmpty(t, pages)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
)
|
||||
|
||||
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
||||
type PendingNote struct {
|
||||
Filename string `json:"filename"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
}
|
||||
|
||||
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
||||
// deriving the default promoted slug.
|
||||
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
||||
|
||||
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
||||
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||
dir := filepath.Join(brainDir, "raw")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []PendingNote{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||
}
|
||||
|
||||
out := make([]PendingNote, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||
continue
|
||||
}
|
||||
info, statErr := e.Info()
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
fm, body := parseFrontmatter(string(raw))
|
||||
created := fm.get("created_at")
|
||||
if created == "" {
|
||||
created = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, PendingNote{
|
||||
Filename: e.Name(),
|
||||
CreatedAt: created,
|
||||
SizeBytes: info.Size(),
|
||||
Excerpt: excerpt(body, 200),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
||||
type PromoteOptions struct {
|
||||
Filename string // basename in brain/raw/
|
||||
Wing string
|
||||
Hall string
|
||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||
}
|
||||
|
||||
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
||||
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
||||
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
||||
//
|
||||
// It is atomic from the caller's view: validation (hall, wing, slug,
|
||||
// collision) happens before any filesystem change, and the source is deleted
|
||||
// only after the destination write succeeds (write-then-delete, never move).
|
||||
// Returns the promoted note's path relative to brainDir.
|
||||
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
// Validate filename (basename only — no traversal) before touching fs.
|
||||
base := filepath.Base(opts.Filename)
|
||||
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||
}
|
||||
|
||||
slug := opts.Slug
|
||||
if slug == "" {
|
||||
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||
}
|
||||
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
src := filepath.Join(brainDir, "raw", base)
|
||||
raw, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return "", fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
|
||||
// Collision: never silently overwrite an existing promoted note.
|
||||
if _, statErr := os.Stat(dest); statErr == nil {
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
fm, body := parseFrontmatter(string(raw))
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||
fm.set("hall", opts.Hall)
|
||||
if fm.get("created_at") == "" {
|
||||
fm.set("created_at", now)
|
||||
}
|
||||
fm.set("promoted_at", now)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create wing dir: %w", err)
|
||||
}
|
||||
// Write-then-delete: the source survives any write failure.
|
||||
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write promoted note: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
|
||||
// Best-effort wiki upkeep — the note is already promoted.
|
||||
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
||||
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
||||
|
||||
return relSlash, nil
|
||||
}
|
||||
|
||||
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||
func excerpt(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > n {
|
||||
r = r[:n]
|
||||
}
|
||||
return strings.TrimSpace(string(r))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func writeRaw(t *testing.T, brainDir, name, content string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func TestListPendingEmptyWhenAbsent(t *testing.T) {
|
||||
got, err := ListPending(t.TempDir())
|
||||
require.NoError(t, err, "absent raw/ is not an error")
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestListPendingReturnsOldestFirstWithExcerpt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-02-newer.md", "---\ncreated_at: 2026-06-02T00:00:00Z\n---\nNewer body here.\n")
|
||||
writeRaw(t, dir, "2026-06-01-older.md", "---\ncreated_at: 2026-06-01T00:00:00Z\n---\nOlder body content.\n")
|
||||
// non-md ignored
|
||||
writeRaw(t, dir, "notes.txt", "ignore me")
|
||||
|
||||
got, err := ListPending(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "2026-06-01-older.md", got[0].Filename, "oldest first")
|
||||
assert.Equal(t, "2026-06-02-newer.md", got[1].Filename)
|
||||
assert.Contains(t, got[0].Excerpt, "Older body content")
|
||||
assert.NotContains(t, got[0].Excerpt, "---", "excerpt is body, not frontmatter")
|
||||
assert.Positive(t, got[0].SizeBytes)
|
||||
}
|
||||
|
||||
func TestPromoteHappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-lejpa-decision.md",
|
||||
"---\ncreated_at: 2026-06-01T09:00:00Z\ncustom_field: keep-me\n---\n# LeJEPA\n\nbody.\n")
|
||||
|
||||
rel, err := PromoteNote(dir, PromoteOptions{
|
||||
Filename: "2026-06-01-lejpa-decision.md", Wing: "jepa-fx", Hall: "decisions",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wiki/jepa-fx/decisions/lejpa-decision.md", rel, "slug defaults to filename minus date prefix")
|
||||
|
||||
// Source deleted.
|
||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-lejpa-decision.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "source removed after promote")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "wing: jepa-fx")
|
||||
assert.Contains(t, s, "hall: decisions")
|
||||
assert.Contains(t, s, "created_at: 2026-06-01T09:00:00Z", "original created_at preserved")
|
||||
assert.Contains(t, s, "promoted_at:")
|
||||
assert.Contains(t, s, "custom_field: keep-me", "custom frontmatter preserved")
|
||||
assert.Contains(t, s, "# LeJEPA")
|
||||
}
|
||||
|
||||
func TestPromoteExplicitSlug(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||
rel, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts", Slug: "custom-slug"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wiki/a/facts/custom-slug.md", rel)
|
||||
}
|
||||
|
||||
func TestPromoteInvalidHallErrorsBeforeTouchingFS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "garbage"})
|
||||
require.Error(t, err)
|
||||
// Source untouched.
|
||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||
assert.NoError(t, statErr, "invalid hall must not delete or move the source")
|
||||
}
|
||||
|
||||
func TestPromoteMissingSourceErrors(t *testing.T) {
|
||||
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "ghost.md", Wing: "a", Hall: "facts"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPromoteSlugCollisionNoOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Pre-existing target.
|
||||
dest := filepath.Join(dir, "wiki", "a", "facts", "x.md")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "NEW\n")
|
||||
|
||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||
require.Error(t, err, "collision must error, not overwrite")
|
||||
|
||||
got, _ := os.ReadFile(dest)
|
||||
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||
}
|
||||
|
||||
func TestPromoteRejectsTraversalFilename(t *testing.T) {
|
||||
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "../escape.md", Wing: "a", Hall: "facts"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPromoteRebuildsWingIndex(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "---\ntitle: X Note\n---\nbody\n")
|
||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||
require.NoError(t, err)
|
||||
idx, err := os.ReadFile(filepath.Join(dir, "wiki", "a", "_index.md"))
|
||||
require.NoError(t, err, "wing _index regenerated")
|
||||
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
||||
}
|
||||
@@ -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,167 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// FileBuffer is a durable, restart-surviving audit buffer backed by a
|
||||
// JSONL file: one {id, entry} record per line. It is the internal/public
|
||||
// tier fallback when loki is unreachable. Confirm rewrites the file
|
||||
// without the confirmed record, so a record is cleared only after its
|
||||
// central write is confirmed.
|
||||
//
|
||||
// Access is serialised by a mutex; the buffer is low-throughput (only
|
||||
// written during a loki outage), so a whole-file rewrite on Confirm is
|
||||
// acceptable and keeps the on-disk format trivially correct.
|
||||
type FileBuffer struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type bufferLine struct {
|
||||
ID string `json:"id"`
|
||||
Entry capture.AuditEntry `json:"entry"`
|
||||
}
|
||||
|
||||
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||
// created if needed. The file itself is created lazily on first Append.
|
||||
func NewFileBuffer(path string) (*FileBuffer, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create buffer dir: %w", err)
|
||||
}
|
||||
return &FileBuffer{path: path}, nil
|
||||
}
|
||||
|
||||
// Writable reports whether the buffer file can be appended to. It probes
|
||||
// by opening the file for append (creating it if absent) — the same
|
||||
// operation Append performs — so Reserve's check matches Append's reality.
|
||||
func (b *FileBuffer) Writable() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// Append durably writes one audit record. The ID is derived from the
|
||||
// content + timestamp so it is stable and unique per record.
|
||||
func (b *FileBuffer) Append(e capture.AuditEntry) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
line := bufferLine{ID: recordID(e), Entry: e}
|
||||
data, err := json.Marshal(line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal buffer line: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// Pending reads all buffered records. A missing file means none.
|
||||
func (b *FileBuffer) Pending() ([]Buffered, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.readAllLocked()
|
||||
}
|
||||
|
||||
func (b *FileBuffer) readAllLocked() ([]Buffered, error) {
|
||||
f, err := os.Open(b.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var out []Buffered
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
raw := sc.Bytes()
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var l bufferLine
|
||||
if err := json.Unmarshal(raw, &l); err != nil {
|
||||
return nil, fmt.Errorf("parse buffer line: %w", err)
|
||||
}
|
||||
out = append(out, Buffered(l))
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// Confirm removes a single record after its central write is confirmed, by
|
||||
// rewriting the file without it. Unknown IDs are a no-op.
|
||||
func (b *FileBuffer) Confirm(id string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
all, err := b.readAllLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := b.path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := bufio.NewWriter(f)
|
||||
kept := 0
|
||||
for _, rec := range all {
|
||||
if rec.ID == id {
|
||||
continue
|
||||
}
|
||||
data, _ := json.Marshal(bufferLine(rec))
|
||||
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
kept++
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Empty buffer → remove the file entirely so Pending sees nothing.
|
||||
if kept == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return os.Remove(b.path)
|
||||
}
|
||||
return os.Rename(tmp, b.path)
|
||||
}
|
||||
|
||||
// recordID is a stable per-record identifier: sha256 of the principal,
|
||||
// timestamp, and item list. Distinct captures never collide; the same
|
||||
// buffered record always hashes the same.
|
||||
func recordID(e capture.AuditEntry) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "%s|%s|%v|%s", e.Principal, e.Timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"), e.Items, e.SessionRef)
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// Central is the central audit substrate (loki). Ready is a cheap
|
||||
// reachability probe used by the pre-write reserve; Push writes a record.
|
||||
type Central interface {
|
||||
Ready(ctx context.Context) error
|
||||
Push(ctx context.Context, e capture.AuditEntry) error
|
||||
}
|
||||
|
||||
// Buffer is the durable local fallback for internal/public-tier records
|
||||
// when the central sink is unreachable. It must survive process restart.
|
||||
type Buffer interface {
|
||||
// Writable reports whether the buffer can currently be appended to.
|
||||
Writable() error
|
||||
Append(e capture.AuditEntry) error
|
||||
// Pending returns buffered records awaiting reconciliation, each with a
|
||||
// stable ID used to Confirm (delete) it after a confirmed central write.
|
||||
Pending() ([]Buffered, error)
|
||||
Confirm(id string) error
|
||||
}
|
||||
|
||||
// Buffered is a buffered audit record plus its stable buffer ID.
|
||||
type Buffered struct {
|
||||
ID string
|
||||
Entry capture.AuditEntry
|
||||
}
|
||||
|
||||
// Notifier raises an out-of-band alert (ntfy) about a degraded state.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, msg string) error
|
||||
}
|
||||
|
||||
// DegradingSink is the classification-aware AuditSink (§4.4):
|
||||
//
|
||||
// - central reachable → AuditCentral (all tiers).
|
||||
// - central down + confidential → refuse (no buffer): confidential must
|
||||
// be centrally auditable at write time.
|
||||
// - central down + internal/public + buffer writable → AuditBuffered.
|
||||
// - central down + (confidential, or buffer not writable) → refuse (floor).
|
||||
//
|
||||
// The decision is made in Reserve, before any write; Record then executes it.
|
||||
type DegradingSink struct {
|
||||
central Central
|
||||
buffer Buffer
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
// NewDegradingSink wires the central sink, durable buffer, and notifier.
|
||||
func NewDegradingSink(central Central, buffer Buffer, notifier Notifier) *DegradingSink {
|
||||
return &DegradingSink{central: central, buffer: buffer, notifier: notifier}
|
||||
}
|
||||
|
||||
// Reserve decides, before any write, how the capture will be audited — or
|
||||
// returns an error to refuse it.
|
||||
func (d *DegradingSink) Reserve(ctx context.Context, level classification.Level) (capture.AuditOutcome, error) {
|
||||
if err := d.central.Ready(ctx); err == nil {
|
||||
return capture.AuditCentral, nil
|
||||
}
|
||||
// Central sink is down.
|
||||
if level == classification.Confidential {
|
||||
return 0, fmt.Errorf("confidential capture requires the central audit sink, which is unreachable")
|
||||
}
|
||||
if err := d.buffer.Writable(); err != nil {
|
||||
// Floor: neither central nor local buffer can record the audit.
|
||||
return 0, fmt.Errorf("audit floor: central sink down and local buffer unwritable: %w", err)
|
||||
}
|
||||
return capture.AuditBuffered, nil
|
||||
}
|
||||
|
||||
// Record persists the entry per the reserved outcome. For AuditBuffered it
|
||||
// also fires the degraded-state alert.
|
||||
func (d *DegradingSink) Record(ctx context.Context, e capture.AuditEntry, outcome capture.AuditOutcome) error {
|
||||
switch outcome {
|
||||
case capture.AuditBuffered:
|
||||
if err := d.buffer.Append(e); err != nil {
|
||||
return fmt.Errorf("buffer audit record: %w", err)
|
||||
}
|
||||
// Best-effort alert; the record is already durably buffered.
|
||||
if d.notifier != nil {
|
||||
_ = d.notifier.Notify(ctx, fmt.Sprintf(
|
||||
"capture audit BUFFERED LOCALLY (loki unreachable) — principal=%s class=%s items=%d",
|
||||
e.Principal, e.EffectiveClassification, len(e.Items)))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return d.central.Push(ctx, e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- fakes ---
|
||||
|
||||
type fakeCentral struct {
|
||||
down bool
|
||||
pushed []capture.AuditEntry
|
||||
pushErr error
|
||||
}
|
||||
|
||||
func (f *fakeCentral) Ready(context.Context) error {
|
||||
if f.down {
|
||||
return errors.New("loki down")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCentral) Push(_ context.Context, e capture.AuditEntry) error {
|
||||
if f.pushErr != nil {
|
||||
return f.pushErr
|
||||
}
|
||||
f.pushed = append(f.pushed, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeNotifier struct{ msgs []string }
|
||||
|
||||
func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
||||
f.msgs = append(f.msgs, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||
type unwritableBuffer struct{}
|
||||
|
||||
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
||||
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
||||
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
||||
func (unwritableBuffer) Confirm(string) error { return nil }
|
||||
|
||||
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||
t.Helper()
|
||||
b, err := audit.NewFileBuffer(filepath.Join(t.TempDir(), "audit-buffer.jsonl"))
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func entry(principal string) capture.AuditEntry {
|
||||
return capture.AuditEntry{Principal: principal, EffectiveClassification: "internal", Items: []string{"insight:x"}}
|
||||
}
|
||||
|
||||
// --- Reserve: classification-aware decision ---
|
||||
|
||||
func TestReserveCentralUpGrantsCentral(t *testing.T) {
|
||||
d := audit.NewDegradingSink(&fakeCentral{}, newFileBuffer(t), &fakeNotifier{})
|
||||
for _, lvl := range []classification.Level{classification.Public, classification.Internal, classification.Confidential} {
|
||||
out, err := d.Reserve(context.Background(), lvl)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, capture.AuditCentral, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveConfidentialSinkDownRefuses(t *testing.T) {
|
||||
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||
_, err := d.Reserve(context.Background(), classification.Confidential)
|
||||
require.Error(t, err, "confidential + sink down → refuse, no buffer")
|
||||
}
|
||||
|
||||
func TestReserveInternalSinkDownBuffers(t *testing.T) {
|
||||
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||
out, err := d.Reserve(context.Background(), classification.Internal)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, capture.AuditBuffered, out)
|
||||
}
|
||||
|
||||
func TestReserveFloorRefusesWhenNothingCanRecord(t *testing.T) {
|
||||
d := audit.NewDegradingSink(&fakeCentral{down: true}, unwritableBuffer{}, &fakeNotifier{})
|
||||
_, err := d.Reserve(context.Background(), classification.Internal)
|
||||
require.Error(t, err, "central down AND buffer unwritable → floor refuse")
|
||||
}
|
||||
|
||||
// --- Record: executes the reserved outcome ---
|
||||
|
||||
func TestRecordCentralPushes(t *testing.T) {
|
||||
c := &fakeCentral{}
|
||||
d := audit.NewDegradingSink(c, newFileBuffer(t), &fakeNotifier{})
|
||||
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditCentral))
|
||||
assert.Len(t, c.pushed, 1)
|
||||
}
|
||||
|
||||
func TestRecordBufferedAppendsAndNotifies(t *testing.T) {
|
||||
buf := newFileBuffer(t)
|
||||
nt := &fakeNotifier{}
|
||||
d := audit.NewDegradingSink(&fakeCentral{down: true}, buf, nt)
|
||||
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditBuffered))
|
||||
|
||||
pending, err := buf.Pending()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pending, 1)
|
||||
assert.NotEmpty(t, nt.msgs, "degraded state alerts via ntfy")
|
||||
}
|
||||
|
||||
// --- FileBuffer durability + Confirm ---
|
||||
|
||||
func TestFileBufferSurvivesRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "buf.jsonl")
|
||||
b1, err := audit.NewFileBuffer(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, b1.Append(entry("p1")))
|
||||
require.NoError(t, b1.Append(entry("p2")))
|
||||
|
||||
// "restart": a fresh FileBuffer over the same file sees the records.
|
||||
b2, err := audit.NewFileBuffer(path)
|
||||
require.NoError(t, err)
|
||||
pending, err := b2.Pending()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pending, 2)
|
||||
}
|
||||
|
||||
func TestFileBufferConfirmRemovesOnlyThatRecord(t *testing.T) {
|
||||
buf := newFileBuffer(t)
|
||||
require.NoError(t, buf.Append(entry("keep")))
|
||||
require.NoError(t, buf.Append(entry("drop")))
|
||||
|
||||
pending, _ := buf.Pending()
|
||||
require.Len(t, pending, 2)
|
||||
var dropID string
|
||||
for _, p := range pending {
|
||||
if p.Entry.Principal == "drop" {
|
||||
dropID = p.ID
|
||||
}
|
||||
}
|
||||
require.NoError(t, buf.Confirm(dropID))
|
||||
|
||||
after, _ := buf.Pending()
|
||||
require.Len(t, after, 1)
|
||||
assert.Equal(t, "keep", after[0].Entry.Principal)
|
||||
}
|
||||
|
||||
// --- Reconcile ---
|
||||
|
||||
func TestReconcileReplaysAndClearsOnlyAfterConfirmedWrite(t *testing.T) {
|
||||
buf := newFileBuffer(t)
|
||||
require.NoError(t, buf.Append(entry("a")))
|
||||
require.NoError(t, buf.Append(entry("b")))
|
||||
c := &fakeCentral{} // up
|
||||
nt := &fakeNotifier{}
|
||||
|
||||
n, err := audit.Reconcile(context.Background(), c, buf, nt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, n)
|
||||
assert.Len(t, c.pushed, 2, "buffered records replayed to central")
|
||||
|
||||
pending, _ := buf.Pending()
|
||||
assert.Empty(t, pending, "buffer cleared after confirmed central writes")
|
||||
}
|
||||
|
||||
func TestReconcileNoopWhenCentralDown(t *testing.T) {
|
||||
buf := newFileBuffer(t)
|
||||
require.NoError(t, buf.Append(entry("a")))
|
||||
n, err := audit.Reconcile(context.Background(), &fakeCentral{down: true}, buf, &fakeNotifier{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
pending, _ := buf.Pending()
|
||||
assert.Len(t, pending, 1, "records stay buffered while central is down")
|
||||
}
|
||||
|
||||
func TestReconcileKeepsRecordWhenPushFails(t *testing.T) {
|
||||
buf := newFileBuffer(t)
|
||||
require.NoError(t, buf.Append(entry("a")))
|
||||
// Ready ok but Push fails → record must remain buffered (not lost).
|
||||
c := &fakeCentral{pushErr: errors.New("push rejected")}
|
||||
n, err := audit.Reconcile(context.Background(), c, buf, &fakeNotifier{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
pending, _ := buf.Pending()
|
||||
assert.Len(t, pending, 1)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// LokiCentral pushes capture audit records to a Grafana Loki instance via
|
||||
// its push API, and probes readiness via /ready. It is the central audit
|
||||
// substrate behind DegradingSink.
|
||||
type LokiCentral struct {
|
||||
baseURL string
|
||||
labels map[string]string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewLokiCentral constructs a LokiCentral for the given base URL (e.g.
|
||||
// http://loki:3100). Returns nil when baseURL is empty so callers can
|
||||
// treat missing config as "no central sink" with a single nil check.
|
||||
func NewLokiCentral(baseURL string) *LokiCentral {
|
||||
if baseURL == "" {
|
||||
return nil
|
||||
}
|
||||
return &LokiCentral{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
labels: map[string]string{"service": "brain-capture", "kind": "audit"},
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Ready probes Loki's readiness endpoint.
|
||||
func (l *LokiCentral) Ready(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.baseURL+"/ready", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loki not ready: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("loki not ready: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushPayload is the Loki push API body: one stream, one entry whose line
|
||||
// is the JSON-encoded audit record.
|
||||
type pushPayload struct {
|
||||
Streams []lokiStream `json:"streams"`
|
||||
}
|
||||
|
||||
type lokiStream struct {
|
||||
Stream map[string]string `json:"stream"`
|
||||
Values [][2]string `json:"values"`
|
||||
}
|
||||
|
||||
// Push writes one audit record to Loki as a structured log line.
|
||||
func (l *LokiCentral) Push(ctx context.Context, e capture.AuditEntry) error {
|
||||
line, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit entry: %w", err)
|
||||
}
|
||||
ts := e.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
body, err := json.Marshal(pushPayload{Streams: []lokiStream{{
|
||||
Stream: l.labels,
|
||||
Values: [][2]string{{strconv.FormatInt(ts.UTC().UnixNano(), 10), string(line)}},
|
||||
}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
l.baseURL+"/loki/api/v1/push", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loki push: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("loki push: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLokiReadyAndPush(t *testing.T) {
|
||||
var pushBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ready":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/loki/api/v1/push":
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
pushBody = string(b)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := audit.NewLokiCentral(srv.URL)
|
||||
require.NotNil(t, c)
|
||||
require.NoError(t, c.Ready(context.Background()))
|
||||
|
||||
err := c.Push(context.Background(), capture.AuditEntry{
|
||||
Principal: "koala-cli", EffectiveClassification: "internal", Items: []string{"insight:x"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, pushBody, "streams")
|
||||
assert.Contains(t, pushBody, "koala-cli", "audit entry serialised into the loki line")
|
||||
}
|
||||
|
||||
func TestLokiReadyFailsWhenDown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
require.Error(t, audit.NewLokiCentral(srv.URL).Ready(context.Background()))
|
||||
}
|
||||
|
||||
func TestLokiNilWhenUnconfigured(t *testing.T) {
|
||||
assert.Nil(t, audit.NewLokiCentral(""))
|
||||
}
|
||||
|
||||
func TestNtfyNotify(t *testing.T) {
|
||||
var gotBody, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := audit.NewNtfyNotifier(srv.URL, "ntfy-token")
|
||||
require.NotNil(t, n)
|
||||
require.NoError(t, n.Notify(context.Background(), "audit buffered locally"))
|
||||
assert.Contains(t, gotBody, "audit buffered locally")
|
||||
assert.Equal(t, "Bearer ntfy-token", gotAuth)
|
||||
}
|
||||
|
||||
func TestNtfyDoesNotLeakTokenOnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
err := audit.NewNtfyNotifier(srv.URL, "secret-token").Notify(context.Background(), "x")
|
||||
require.Error(t, err)
|
||||
assert.False(t, strings.Contains(err.Error(), "secret-token"), "token must not leak into errors")
|
||||
}
|
||||
|
||||
func TestNtfyNilWhenUnconfigured(t *testing.T) {
|
||||
assert.Nil(t, audit.NewNtfyNotifier("", "tok"))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NtfyNotifier posts alerts to an ntfy topic URL. Used to surface a
|
||||
// degraded audit state (records buffered locally during a loki outage).
|
||||
type NtfyNotifier struct {
|
||||
topicURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
|
||||
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
|
||||
// protected ntfy instances; it is held here and only sent in the
|
||||
// Authorization header, never logged. Returns nil when topicURL is empty.
|
||||
func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
|
||||
if topicURL == "" {
|
||||
return nil
|
||||
}
|
||||
return &NtfyNotifier{
|
||||
topicURL: strings.TrimRight(topicURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Notify posts a message to the ntfy topic.
|
||||
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", "brain-capture audit degraded")
|
||||
req.Header.Set("Priority", "high")
|
||||
req.Header.Set("Tags", "warning,brain")
|
||||
if n.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+n.token)
|
||||
}
|
||||
resp, err := n.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy notify: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reconcile replays locally-buffered audit records to the central sink
|
||||
// when it is reachable again. A record is removed from the buffer ONLY
|
||||
// after its central write is confirmed, so a crash mid-reconcile re-plays
|
||||
// rather than loses. Returns the number of records reconciled.
|
||||
//
|
||||
// A no-op (0, nil) when the central sink is still unreachable or the
|
||||
// buffer is empty.
|
||||
func Reconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier) (int, error) {
|
||||
if err := central.Ready(ctx); err != nil {
|
||||
return 0, nil // still down; try again next tick
|
||||
}
|
||||
pending, err := buffer.Pending()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read buffer: %w", err)
|
||||
}
|
||||
reconciled := 0
|
||||
for _, rec := range pending {
|
||||
if err := central.Push(ctx, rec.Entry); err != nil {
|
||||
// Central went away mid-drain; stop and keep the rest buffered.
|
||||
break
|
||||
}
|
||||
if err := buffer.Confirm(rec.ID); err != nil {
|
||||
return reconciled, fmt.Errorf("confirm buffered record %s: %w", rec.ID, err)
|
||||
}
|
||||
reconciled++
|
||||
}
|
||||
if reconciled > 0 && notifier != nil {
|
||||
_ = notifier.Notify(ctx, fmt.Sprintf("reconciled %d buffered capture audit record(s) to loki", reconciled))
|
||||
}
|
||||
return reconciled, nil
|
||||
}
|
||||
|
||||
// StartReconcile runs Reconcile on a ticker until ctx is cancelled. It is
|
||||
// the recovery half of the degrade-and-buffer path; pair it with a
|
||||
// DegradingSink sharing the same buffer + central.
|
||||
func StartReconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n, err := Reconcile(ctx, central, buffer, notifier); err != nil {
|
||||
slog.Warn("audit reconcile failed", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("audit reconcile", "reconciled", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package audit provides AuditSink implementations for the capture
|
||||
// capability (I5). This file ships the minimal slog-backed sink used in
|
||||
// #53: it emits the request-level audit record to structured logs, which
|
||||
// the alloy/loki substrate already scrapes. The classification-aware
|
||||
// degradation/refusal sink (confidential fails closed, internal buffers +
|
||||
// reconciles) lands in #54 and replaces this behind the same interface.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// SlogSink records audit entries to an slog.Logger. It never fails and is
|
||||
// always centrally available, so its Reserve always grants AuditCentral —
|
||||
// it does not exercise the I5 degradation/floor. That is DegradingSink's
|
||||
// job (loki + durable buffer). SlogSink is the default for deployments
|
||||
// without a loki endpoint configured. A nil logger ⇒ slog.Default().
|
||||
type SlogSink struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSlogSink constructs a SlogSink. nil logger ⇒ slog.Default().
|
||||
func NewSlogSink(logger *slog.Logger) *SlogSink {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &SlogSink{logger: logger}
|
||||
}
|
||||
|
||||
// Reserve always grants central recording — slog is always available.
|
||||
func (s *SlogSink) Reserve(_ context.Context, _ classification.Level) (capture.AuditOutcome, error) {
|
||||
return capture.AuditCentral, nil
|
||||
}
|
||||
|
||||
// Record emits the audit entry at info level. Security events, when
|
||||
// present, are logged at warn level so they surface independently of the
|
||||
// routine audit stream.
|
||||
func (s *SlogSink) Record(_ context.Context, e capture.AuditEntry, _ capture.AuditOutcome) error {
|
||||
s.logger.Info("capture audit",
|
||||
"principal", e.Principal,
|
||||
"actor", e.Actor,
|
||||
"harness", e.Harness,
|
||||
"session_ref", e.SessionRef,
|
||||
"classification", e.EffectiveClassification,
|
||||
"items", e.Items,
|
||||
"ts", e.Timestamp,
|
||||
)
|
||||
for _, ev := range e.SecurityEvents {
|
||||
s.logger.Warn("capture security event", "principal", e.Principal, "event", ev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSlogSinkRecordsEntryAndSecurityEvents(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sink := audit.NewSlogSink(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
|
||||
err := sink.Record(context.Background(), capture.AuditEntry{
|
||||
Principal: "koala-cli",
|
||||
Harness: "claude-code",
|
||||
EffectiveClassification: "confidential",
|
||||
Items: []string{"insight:wiki/a/facts/x.md"},
|
||||
SecurityEvents: []string{"asserted-vs-derived origin mismatch"},
|
||||
}, capture.AuditCentral)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "capture audit")
|
||||
assert.Contains(t, out, "koala-cli")
|
||||
assert.Contains(t, out, "confidential")
|
||||
assert.Contains(t, out, "capture security event")
|
||||
assert.Contains(t, out, "asserted-vs-derived origin mismatch")
|
||||
}
|
||||
|
||||
func TestSlogSinkNilLoggerDefaults(t *testing.T) {
|
||||
// nil logger must not panic.
|
||||
require.NotPanics(t, func() {
|
||||
_ = audit.NewSlogSink(nil).Record(context.Background(), capture.AuditEntry{}, capture.AuditCentral)
|
||||
})
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
)
|
||||
|
||||
// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
||||
// Audience is optional; leave empty to skip audience validation.
|
||||
type Validator struct {
|
||||
issuer string
|
||||
audience string
|
||||
jwksURI string
|
||||
cache *jwk.Cache
|
||||
}
|
||||
|
||||
// NewValidator fetches the OIDC discovery document from issuerURL, extracts
|
||||
// jwks_uri, seeds the JWKS cache, and returns a ready Validator.
|
||||
// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction.
|
||||
func NewValidator(issuerURL, audience string) (*Validator, error) {
|
||||
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||
}
|
||||
if doc.JWKSURI == "" {
|
||||
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cache := jwk.NewCache(ctx)
|
||||
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
||||
return nil, fmt.Errorf("register jwks cache: %w", err)
|
||||
}
|
||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||
return nil, fmt.Errorf("initial jwks fetch: %w", err)
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
issuer: issuerURL,
|
||||
audience: audience,
|
||||
jwksURI: doc.JWKSURI,
|
||||
cache: cache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate parses and validates rawToken. Returns the subject claim on success.
|
||||
func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get jwks: %w", err)
|
||||
}
|
||||
|
||||
opts := []jwt.ParseOption{
|
||||
jwt.WithKeySet(keySet),
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithIssuer(v.issuer),
|
||||
}
|
||||
if v.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.audience))
|
||||
}
|
||||
|
||||
tok, err := jwt.ParseString(rawToken, opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("validate jwt: %w", err)
|
||||
}
|
||||
return tok.Subject(), nil
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testKeys struct {
|
||||
priv jwk.Key
|
||||
pub jwk.Key
|
||||
}
|
||||
|
||||
func generateRSAKeys(t *testing.T) testKeys {
|
||||
t.Helper()
|
||||
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
priv, err := jwk.FromRaw(raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, priv.Set(jwk.KeyIDKey, "test-kid"))
|
||||
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||
|
||||
pub, err := jwk.PublicKeyOf(priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
return testKeys{priv: priv, pub: pub}
|
||||
}
|
||||
|
||||
func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server {
|
||||
t.Helper()
|
||||
set := jwk.NewSet()
|
||||
require.NoError(t, set.AddKey(keys.pub))
|
||||
jwksBytes, err := json.Marshal(set)
|
||||
require.NoError(t, err)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": srv.URL,
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBytes)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string {
|
||||
t.Helper()
|
||||
b := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(subject).
|
||||
Expiration(exp)
|
||||
if audience != "" {
|
||||
b = b.Audience([]string{audience})
|
||||
}
|
||||
tok, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
func TestValidator(t *testing.T) {
|
||||
keys := generateRSAKeys(t)
|
||||
srv := mockOIDCServer(t, keys)
|
||||
ctx := context.Background()
|
||||
|
||||
v, err := auth.NewValidator(srv.URL, "brain")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantSub string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid jwt",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)),
|
||||
wantSub: "test-user",
|
||||
},
|
||||
{
|
||||
name: "expired jwt",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong audience",
|
||||
token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "tampered token",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a jwt",
|
||||
token: "not-a-jwt",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sub, err := v.Validate(ctx, tc.token)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, sub)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantSub, sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidator_NoAudience(t *testing.T) {
|
||||
keys := generateRSAKeys(t)
|
||||
srv := mockOIDCServer(t, keys)
|
||||
ctx := context.Background()
|
||||
|
||||
v, err := auth.NewValidator(srv.URL, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token without audience passes when audience validation is disabled.
|
||||
tok, err := jwt.NewBuilder().
|
||||
Issuer(srv.URL).
|
||||
Subject("sub").
|
||||
Expiration(time.Now().Add(time.Hour)).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := v.Validate(ctx, string(signed))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "sub", sub)
|
||||
}
|
||||
|
||||
func TestNewValidator_BadDiscoveryURL(t *testing.T) {
|
||||
_, err := auth.NewValidator("http://127.0.0.1:1", "brain")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource metadata
|
||||
// handler. Mount at GET /.well-known/oauth-protected-resource (no auth required).
|
||||
func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc {
|
||||
type metadata struct {
|
||||
Resource string `json:"resource"`
|
||||
AuthorizationServers []string `json:"authorization_servers"`
|
||||
}
|
||||
body, _ := json.Marshal(metadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{issuerURL},
|
||||
})
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProtectedResourceHandler(t *testing.T) {
|
||||
h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be")
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"])
|
||||
servers := body["authorization_servers"].([]any)
|
||||
assert.Equal(t, "https://auth.d-ma.be", servers[0])
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package brainstore is the concrete BrainStore: the single shared
|
||||
// implementation of the #45 write/update/get verbs, used by BOTH the MCP
|
||||
// handlers and the capture use-case so there is one implementation, not
|
||||
// two (the Clean-Architecture / DRY payoff of #51).
|
||||
//
|
||||
// It composes the file-level primitives in package api (WriteNote,
|
||||
// UpdateNote, ReadNote — the read-after-write contract) with the wiki
|
||||
// upkeep that must accompany a write: wing _index rebuild, cross-wing
|
||||
// auto-tunnel, and graph re-index. Embedding refresh is intentionally
|
||||
// out-of-band (mtime-driven vectorstore.Sync) and not triggered here —
|
||||
// see the brain note on out-of-band sync.
|
||||
package brainstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
)
|
||||
|
||||
// Store implements capture.BrainStore against a brain directory on disk,
|
||||
// optionally re-indexing each write into the knowledge graph.
|
||||
type Store struct {
|
||||
brainDir string
|
||||
graph graphsync.Store // nil = graph re-index disabled
|
||||
}
|
||||
|
||||
// New constructs a Store bound to brainDir with graph indexing disabled.
|
||||
func New(brainDir string) *Store {
|
||||
return &Store{brainDir: brainDir}
|
||||
}
|
||||
|
||||
// WithGraph enables graph re-index on every write/update. nil disables it.
|
||||
func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||
s.graph = g
|
||||
return s
|
||||
}
|
||||
|
||||
// Write creates a brain note and returns its read-after-write handle.
|
||||
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: n.Content,
|
||||
Filename: n.Filename,
|
||||
Type: n.Type,
|
||||
Domain: n.Domain,
|
||||
Wing: n.Wing,
|
||||
Hall: n.Hall,
|
||||
})
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
s.wikiUpkeep(relPath, n.Wing, n.Content)
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
||||
}
|
||||
|
||||
// Update supersedes an existing note in place. slug may be a bare slug
|
||||
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||
// contains a slash). It never creates — a missing target is an error.
|
||||
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||
if strings.Contains(slug, "/") {
|
||||
opts.Path = slug
|
||||
} else {
|
||||
opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug
|
||||
}
|
||||
|
||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
if wing := wingFromRelPath(relPath); wing != "" {
|
||||
s.wikiUpkeep(relPath, wing, n.Content)
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
||||
}
|
||||
|
||||
// Get fetches a note by id/path — the read-after-write confirmation
|
||||
// primitive (a direct fetch, never a semantic query).
|
||||
func (s *Store) Get(_ context.Context, id string) (capture.StoredNote, error) {
|
||||
fm, body, hash, err := api.ReadNote(s.brainDir, id)
|
||||
if err != nil {
|
||||
return capture.StoredNote{}, err
|
||||
}
|
||||
return capture.StoredNote{ID: id, Path: id, ContentHash: hash, Frontmatter: fm, Body: body}, nil
|
||||
}
|
||||
|
||||
// wikiUpkeep rebuilds the wing _index and re-tunnels cross-wing matches
|
||||
// when a note lands in the structured wiki. Both are best-effort: the
|
||||
// note is already written, so a failure here is logged, not propagated.
|
||||
func (s *Store) wikiUpkeep(relPath, wing, content string) {
|
||||
if wing == "" {
|
||||
return
|
||||
}
|
||||
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||
slog.Warn("brainstore: auto-index failed", "wing", wing, "err", err)
|
||||
}
|
||||
if err := brain.AutoTunnel(s.brainDir, relPath, content); err != nil {
|
||||
slog.Warn("brainstore: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// indexInGraph re-indexes a written doc into the graph, best-effort.
|
||||
func (s *Store) 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)
|
||||
}
|
||||
}
|
||||
|
||||
// wingFromRelPath extracts the wing 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 ""
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package brainstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStoreWriteReturnsHandle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
|
||||
ref, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "# X\n\nbody\n", Filename: "x", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wiki/a/facts/x.md", ref.Path)
|
||||
assert.Equal(t, ref.Path, ref.ID)
|
||||
assert.NotEmpty(t, ref.ContentHash)
|
||||
assert.False(t, ref.Superseded)
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestStoreUpdateSupersedes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||
Content: "new\n", Wing: "a", Hall: "facts", Reason: "changed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ref.Superseded)
|
||||
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||
assert.Contains(t, string(got), "new")
|
||||
assert.Contains(t, string(got), "supersede_reason: changed")
|
||||
}
|
||||
|
||||
func TestStoreUpdateByFullPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{Content: "old\n", Filename: "n", Wing: "a", Hall: "facts"})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Update(context.Background(), "wiki/a/facts/n.md", capture.Note{Content: "fresh\n"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||
}
|
||||
|
||||
func TestStoreUpdateMissingErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Update(context.Background(), "ghost", capture.Note{Content: "x\n", Wing: "a", Hall: "facts"})
|
||||
require.Error(t, err)
|
||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/a/facts/ghost.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "update must not create")
|
||||
}
|
||||
|
||||
func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
ref, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "# Body\n\ntext\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
note, err := s.Get(context.Background(), ref.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ref.ContentHash, note.ContentHash, "write→get hash round-trips")
|
||||
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||
assert.Contains(t, note.Body, "# Body")
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package capture is the Clean-Architecture use-case for the uniform
|
||||
// capture capability (issue #49/#51): persist a finished session's
|
||||
// valuable output — insights → brain, action items → Gitea tickets —
|
||||
// with one invocation, identical core behaviour across every harness.
|
||||
//
|
||||
// This package is pure orchestration. It depends only on ports
|
||||
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||
// or audit I/O. The real adapters are wired in #52 (Gitea tracker), #53
|
||||
// (REST + I1 origin gate), and #54/#55 (audit path + relay). The I1
|
||||
// sovereignty refusal and the classification-aware audit degradation are
|
||||
// deliberately NOT here — those need the server-derived principal origin
|
||||
// (#53) and the loki/buffer machinery (#54). What lives here is everything
|
||||
// testable against fakes: validation, effective-classification resolution
|
||||
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||
package capture
|
||||
|
||||
// Zone is the trust zone a capture originates from, server-derived from
|
||||
// the authenticated principal (spec §4.2 / I1). It is NEVER taken from
|
||||
// caller input — context.Harness is descriptive telemetry only.
|
||||
type Zone int
|
||||
|
||||
const (
|
||||
// ZoneUnknown means the origin was not set. The REST adapter always
|
||||
// sets a concrete zone; the service treats Unknown as "not gated" (only
|
||||
// an explicit ZoneUSNexus triggers the I1 refusal) so the gate can
|
||||
// never fire on a caller-controllable default.
|
||||
ZoneUnknown Zone = iota
|
||||
// ZoneSovereign is sovereign soil (homelab / Tailscale CLI callers).
|
||||
ZoneSovereign
|
||||
// ZoneUSNexus is a non-sovereign US-jurisdiction surface (e.g.
|
||||
// claude.ai). Confidential captures through it are refused (I1).
|
||||
ZoneUSNexus
|
||||
)
|
||||
|
||||
// String renders the zone for audit/refusal messages.
|
||||
func (z Zone) String() string {
|
||||
switch z {
|
||||
case ZoneSovereign:
|
||||
return "sovereign-soil"
|
||||
case ZoneUSNexus:
|
||||
return "us-nexus"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureContext is the per-session metadata accompanying a capture.
|
||||
//
|
||||
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||
// the server independently derives the target's classification and gates
|
||||
// on the stricter of the two. Principal and Origin are server-derived from
|
||||
// the authenticated identity (the REST adapter populates them); they are
|
||||
// never caller-asserted. Harness is descriptive telemetry only — never a
|
||||
// gate input.
|
||||
type CaptureContext struct {
|
||||
Harness string
|
||||
SessionRef string
|
||||
Fidelity string
|
||||
Actor string
|
||||
Classification string // caller-declared level token ("" = unspecified)
|
||||
Principal string // server-derived (auth); audit identity
|
||||
Origin Zone // server-derived trust zone; the I1 gate input
|
||||
}
|
||||
|
||||
// Insight is one piece of session knowledge bound for the brain. A
|
||||
// non-empty SupersedeSlug routes to Update (revise in place); otherwise
|
||||
// Write (create).
|
||||
type Insight struct {
|
||||
Text string
|
||||
Wing string
|
||||
Hall string
|
||||
SupersedeSlug string
|
||||
}
|
||||
|
||||
// Ticket is one action item bound for a Gitea repo. Owner is always the
|
||||
// operator (set by the tracker adapter), never carried here.
|
||||
type Ticket struct {
|
||||
Repo string
|
||||
Action string // create | close | comment
|
||||
Number int // required for close/comment
|
||||
Title string // required for create
|
||||
Body string
|
||||
}
|
||||
|
||||
// CaptureInput is the whole capture request.
|
||||
type CaptureInput struct {
|
||||
Context CaptureContext
|
||||
Insights []Insight
|
||||
Tickets []Ticket
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// InsightResult is the per-insight outcome in the receipt.
|
||||
type InsightResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ContentHash string `json:"content_hash,omitempty"`
|
||||
Superseded bool `json:"superseded"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// TicketResult is the per-ticket outcome in the receipt.
|
||||
type TicketResult struct {
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Action string `json:"action"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// ItemError pins a failure to a specific request item for the partial
|
||||
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||
type ItemError struct {
|
||||
Item string `json:"item"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// CaptureReceipt is the structured, partial-aware result. Per-item ok
|
||||
// flags plus a flat Errors list make partial success explicit; the
|
||||
// caller never has to infer what landed.
|
||||
type CaptureReceipt struct {
|
||||
Insights []InsightResult `json:"insights"`
|
||||
Tickets []TicketResult `json:"tickets"`
|
||||
Errors []ItemError `json:"errors"`
|
||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
// AuditBuffered is true when the central audit sink was unreachable and
|
||||
// this capture's audit record was written to the durable local buffer
|
||||
// instead (internal/public tier). Surfaces the degraded state to the
|
||||
// caller per §4.4.
|
||||
AuditBuffered bool `json:"audit_buffered,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// Ref is the read-after-write handle returned by a brain write/update —
|
||||
// the #45 contract. ContentHash lets the caller confirm what landed
|
||||
// without a re-query; for an Update, Superseded is true.
|
||||
type Ref struct {
|
||||
ID string
|
||||
Path string
|
||||
ContentHash string
|
||||
Superseded bool
|
||||
}
|
||||
|
||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||
// confirmation primitive (a direct fetch, never a semantic query).
|
||||
type StoredNote struct {
|
||||
ID string
|
||||
Path string
|
||||
ContentHash string
|
||||
Frontmatter map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||
// and the legacy type/domain fields so a single BrainStore serves both
|
||||
// capture insights and the existing MCP brain_write surface. Reason is
|
||||
// the supersede rationale, used only by Update.
|
||||
type Note struct {
|
||||
Content string
|
||||
Filename string
|
||||
Wing string
|
||||
Hall string
|
||||
Type string
|
||||
Domain string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// BrainStore is the brain persistence port — the shared implementation of
|
||||
// the #45 write/update/get verbs that both the MCP handlers and capture
|
||||
// call, so there is one implementation, not two. The read-after-write +
|
||||
// staleness discipline lives behind this interface so no caller carries
|
||||
// the rule.
|
||||
type BrainStore interface {
|
||||
Write(ctx context.Context, n Note) (Ref, error)
|
||||
Update(ctx context.Context, slug string, n Note) (Ref, error)
|
||||
Get(ctx context.Context, id string) (StoredNote, error)
|
||||
}
|
||||
|
||||
// IssueRef identifies a ticket touched by the tracker.
|
||||
type IssueRef struct {
|
||||
Repo string
|
||||
Number int
|
||||
URL string
|
||||
}
|
||||
|
||||
// IssueTracker is the Gitea ticket port. The implementation (#52) always
|
||||
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||
type IssueTracker interface {
|
||||
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||
// CloseIssue closes an issue, optionally posting a closing comment
|
||||
// first (empty comment ⇒ close only).
|
||||
CloseIssue(ctx context.Context, repo string, number int, comment string) (IssueRef, error)
|
||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||
}
|
||||
|
||||
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||
// "stricter wins" combination of declared vs derived is use-case policy
|
||||
// and lives in the service, so the port stays minimal. Satisfied by
|
||||
// classification.Config (#50).
|
||||
type ClassificationPolicy interface {
|
||||
Derive(target classification.Target) classification.Level
|
||||
}
|
||||
|
||||
// AuditEntry is the request-level audit record (I5): who/what captured
|
||||
// what, when, via which principal. SecurityEvents carries anomalies such
|
||||
// as a caller under-declaring sensitivity relative to the target floor.
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time
|
||||
Principal string
|
||||
Actor string
|
||||
Harness string
|
||||
SessionRef string
|
||||
EffectiveClassification string
|
||||
Items []string
|
||||
SecurityEvents []string
|
||||
}
|
||||
|
||||
// AuditOutcome is how a capture's audit record was (or will be) persisted.
|
||||
type AuditOutcome int
|
||||
|
||||
const (
|
||||
// AuditCentral means the record goes to the central sink (loki).
|
||||
AuditCentral AuditOutcome = iota
|
||||
// AuditBuffered means the central sink was unreachable and the record
|
||||
// is written to a durable local buffer for later reconciliation
|
||||
// (internal/public tier only).
|
||||
AuditBuffered
|
||||
)
|
||||
|
||||
// AuditSink is the two-phase, classification-aware audit port (I5, §4.4).
|
||||
//
|
||||
// Reserve runs BEFORE any write and decides whether the capture can be
|
||||
// audited at its effective classification: it returns the outcome to use,
|
||||
// or an error to refuse the capture before anything is written
|
||||
// (confidential + central sink down → refuse; the all-tiers floor when
|
||||
// nothing can record → refuse). Record runs AFTER the writes and persists
|
||||
// the final entry per the reserved outcome.
|
||||
//
|
||||
// Splitting reserve from record is what lets "confidential + sink-down →
|
||||
// refuse before any write" be literally true while the record itself
|
||||
// (which lists what landed) is necessarily written afterwards.
|
||||
type AuditSink interface {
|
||||
Reserve(ctx context.Context, level classification.Level) (AuditOutcome, error)
|
||||
Record(ctx context.Context, e AuditEntry, outcome AuditOutcome) error
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// Service is the CaptureSession use-case. It depends only on ports.
|
||||
type Service struct {
|
||||
brain BrainStore
|
||||
issues IssueTracker
|
||||
policy ClassificationPolicy
|
||||
audit AuditSink
|
||||
|
||||
// now is the clock, injectable for deterministic audit timestamps in
|
||||
// tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service from its ports.
|
||||
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
|
||||
}
|
||||
|
||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||
|
||||
// ErrSovereigntyRefused is returned when the I1 gate refuses a capture
|
||||
// (confidential effective classification through a us-nexus origin). The
|
||||
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
||||
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
||||
|
||||
// ErrAuditUnavailable is returned when the I5 audit gate refuses a capture
|
||||
// before any write: a confidential capture whose central audit sink is
|
||||
// unreachable, or the all-tiers floor where nothing can record the audit.
|
||||
// The REST adapter maps it to HTTP 503. Callers test with errors.Is.
|
||||
var ErrAuditUnavailable = fmt.Errorf("capture refused: audit substrate unavailable")
|
||||
|
||||
// assertedZoneMismatch returns a security-event string when the caller's
|
||||
// harness label asserts a trust zone that contradicts the server-derived
|
||||
// origin. A harness label that names no zone (the normal case, e.g.
|
||||
// "claude-code") returns "". The label is never used as a gate input —
|
||||
// this only flags the discrepancy for the audit trail.
|
||||
func assertedZoneMismatch(harness string, derived Zone) string {
|
||||
var asserted Zone
|
||||
switch strings.ToLower(strings.TrimSpace(harness)) {
|
||||
case "sovereign-soil", "sovereign":
|
||||
asserted = ZoneSovereign
|
||||
case "us-nexus", "usnexus":
|
||||
asserted = ZoneUSNexus
|
||||
default:
|
||||
return "" // no zone claim
|
||||
}
|
||||
if asserted != derived {
|
||||
return fmt.Sprintf("asserted-vs-derived origin mismatch: harness asserted %s, principal resolves to %s",
|
||||
asserted, derived)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||
// classification (stricter of declared vs target-derived), then persist
|
||||
// insights → tickets → summary best-effort, emit an audit record, and
|
||||
// return a partial-aware receipt.
|
||||
//
|
||||
// A validation failure returns a non-nil error with nothing written. A
|
||||
// per-item execution failure is recorded in the receipt (no rollback);
|
||||
// the call still returns a nil error so the caller gets the partial
|
||||
// receipt. The I1 origin gate and audit-down degradation are layered on
|
||||
// by #53/#54 around this core.
|
||||
func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, error) {
|
||||
if err := s.validate(in); err != nil {
|
||||
return CaptureReceipt{}, err
|
||||
}
|
||||
|
||||
declared := classification.Public // unspecified ⇒ lowest ⇒ target floor governs
|
||||
if in.Context.Classification != "" {
|
||||
// Already validated parseable.
|
||||
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||
}
|
||||
|
||||
effective, securityEvents := s.resolveClassification(declared, in)
|
||||
|
||||
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||
// label that names a different zone is descriptive-only and logged as a
|
||||
// security event (spec §4.2: a control keyed on attacker-suppliable
|
||||
// input is not a control).
|
||||
if ev := assertedZoneMismatch(in.Context.Harness, in.Context.Origin); ev != "" {
|
||||
securityEvents = append(securityEvents, ev)
|
||||
}
|
||||
|
||||
// I1 sovereignty gate: a confidential capture through a us-nexus origin
|
||||
// is refused before ANY write. The refusal itself is audited (best
|
||||
// effort) — refusals must be reconstructable too.
|
||||
if effective == classification.Confidential && in.Context.Origin == ZoneUSNexus {
|
||||
_ = s.audit.Record(ctx, AuditEntry{
|
||||
Timestamp: s.now().UTC(),
|
||||
Principal: in.Context.Principal,
|
||||
Actor: in.Context.Actor,
|
||||
Harness: in.Context.Harness,
|
||||
SessionRef: in.Context.SessionRef,
|
||||
EffectiveClassification: effective.String(),
|
||||
Items: nil, // refused before any write
|
||||
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||
}, AuditCentral)
|
||||
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
||||
ErrSovereigntyRefused, in.Context.Origin)
|
||||
}
|
||||
|
||||
receipt := CaptureReceipt{
|
||||
Errors: []ItemError{},
|
||||
EffectiveClassification: effective.String(),
|
||||
DryRun: in.DryRun,
|
||||
}
|
||||
|
||||
if in.DryRun {
|
||||
// Would-be receipt: mark planned items ok, write nothing (not even
|
||||
// audit — dry_run touches nothing).
|
||||
for range in.Insights {
|
||||
receipt.Insights = append(receipt.Insights, InsightResult{OK: true})
|
||||
}
|
||||
for _, tk := range in.Tickets {
|
||||
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// I5 audit gate: decide BEFORE any write whether this capture can be
|
||||
// audited at its effective classification. Confidential + central sink
|
||||
// down → refuse here, before writing anything; the all-tiers floor
|
||||
// (nothing can record) likewise refuses. Internal/public degrade to the
|
||||
// durable local buffer (signalled by AuditBuffered).
|
||||
outcome, err := s.audit.Reserve(ctx, effective)
|
||||
if err != nil {
|
||||
return CaptureReceipt{}, fmt.Errorf("%w: %v", ErrAuditUnavailable, err)
|
||||
}
|
||||
|
||||
var landed []string
|
||||
|
||||
for i, ins := range in.Insights {
|
||||
res, item, err := s.persistInsight(ctx, ins)
|
||||
receipt.Insights = append(receipt.Insights, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, item)
|
||||
}
|
||||
|
||||
for i, tk := range in.Tickets {
|
||||
res, err := s.persistTicket(ctx, tk)
|
||||
receipt.Tickets = append(receipt.Tickets, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("ticket[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||
}
|
||||
|
||||
// I5: persist the request-level audit record of exactly what landed,
|
||||
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||
// the degraded (locally-buffered) state on the receipt.
|
||||
if err := s.audit.Record(ctx, AuditEntry{
|
||||
Timestamp: s.now().UTC(),
|
||||
Principal: in.Context.Principal,
|
||||
Actor: in.Context.Actor,
|
||||
Harness: in.Context.Harness,
|
||||
SessionRef: in.Context.SessionRef,
|
||||
EffectiveClassification: effective.String(),
|
||||
Items: landed,
|
||||
SecurityEvents: securityEvents,
|
||||
}, outcome); err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||
}
|
||||
if outcome == AuditBuffered {
|
||||
receipt.AuditBuffered = true
|
||||
}
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// validate enforces fail-closed structural validity over the whole
|
||||
// request before any write. A bad declared classification, an invalid
|
||||
// wing/hall, an empty insight, or a malformed ticket aborts the capture
|
||||
// with nothing written.
|
||||
func (s *Service) validate(in CaptureInput) error {
|
||||
if in.Context.Classification != "" {
|
||||
if _, err := classification.ParseLevel(in.Context.Classification); err != nil {
|
||||
return fmt.Errorf("context.classification: %w", err)
|
||||
}
|
||||
}
|
||||
for i, ins := range in.Insights {
|
||||
if strings.TrimSpace(ins.Text) == "" {
|
||||
return fmt.Errorf("insight[%d]: text is required", i)
|
||||
}
|
||||
if strings.TrimSpace(ins.Wing) == "" {
|
||||
return fmt.Errorf("insight[%d]: wing is required", i)
|
||||
}
|
||||
if !brain.IsValidHall(ins.Hall) {
|
||||
return fmt.Errorf("insight[%d]: invalid hall %q", i, ins.Hall)
|
||||
}
|
||||
}
|
||||
for i, tk := range in.Tickets {
|
||||
if strings.TrimSpace(tk.Repo) == "" {
|
||||
return fmt.Errorf("ticket[%d]: repo is required", i)
|
||||
}
|
||||
if !validActions[tk.Action] {
|
||||
return fmt.Errorf("ticket[%d]: invalid action %q (want create/close/comment)", i, tk.Action)
|
||||
}
|
||||
if tk.Action == "create" && strings.TrimSpace(tk.Title) == "" {
|
||||
return fmt.Errorf("ticket[%d]: create requires a title", i)
|
||||
}
|
||||
if (tk.Action == "close" || tk.Action == "comment") && tk.Number <= 0 {
|
||||
return fmt.Errorf("ticket[%d]: %s requires an issue number", i, tk.Action)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveClassification computes the effective level (stricter of
|
||||
// declared and every target's derived level) and collects a security
|
||||
// event whenever the caller under-declared relative to a target floor.
|
||||
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
||||
effective := declared
|
||||
var events []string
|
||||
consider := func(kind classification.TargetKind, name string) {
|
||||
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||
effective = classification.Stricter(effective, derived)
|
||||
if declared < derived {
|
||||
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||
declared, name, kindString(kind), derived))
|
||||
}
|
||||
}
|
||||
for _, ins := range in.Insights {
|
||||
consider(classification.WingTarget, ins.Wing)
|
||||
}
|
||||
for _, tk := range in.Tickets {
|
||||
consider(classification.RepoTarget, tk.Repo)
|
||||
}
|
||||
return effective, events
|
||||
}
|
||||
|
||||
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||
var ref Ref
|
||||
var err error
|
||||
if ins.SupersedeSlug != "" {
|
||||
note.Reason = "superseded via capture"
|
||||
ref, err = s.brain.Update(ctx, ins.SupersedeSlug, note)
|
||||
} else {
|
||||
ref, err = s.brain.Write(ctx, note)
|
||||
}
|
||||
if err != nil {
|
||||
return InsightResult{OK: false, Superseded: ins.SupersedeSlug != ""}, "", err
|
||||
}
|
||||
return InsightResult{
|
||||
ID: ref.ID, Path: ref.Path, ContentHash: ref.ContentHash,
|
||||
Superseded: ref.Superseded, OK: true,
|
||||
}, "insight:" + ref.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, error) {
|
||||
res := TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number}
|
||||
var ref IssueRef
|
||||
var err error
|
||||
switch tk.Action {
|
||||
case "create":
|
||||
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||
case "close":
|
||||
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||
case "comment":
|
||||
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if ref.Number != 0 {
|
||||
res.Number = ref.Number
|
||||
}
|
||||
res.URL = ref.URL
|
||||
res.OK = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func kindString(k classification.TargetKind) string {
|
||||
if k == classification.RepoTarget {
|
||||
return "repo"
|
||||
}
|
||||
return "wing"
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.TrimLeft(s, "# ")
|
||||
if len(s) > 60 {
|
||||
s = s[:60]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- fakes ---
|
||||
|
||||
type fakeBrain struct {
|
||||
writes []Note
|
||||
updates []Note
|
||||
gets []string
|
||||
failOn func(Note) error // nil = always succeed
|
||||
hashSeq int
|
||||
}
|
||||
|
||||
func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref {
|
||||
f.hashSeq++
|
||||
path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md"
|
||||
return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded}
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) {
|
||||
if f.failOn != nil {
|
||||
if err := f.failOn(n); err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
}
|
||||
f.writes = append(f.writes, n)
|
||||
return f.ref("w", n, false), nil
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) {
|
||||
if f.failOn != nil {
|
||||
if err := f.failOn(n); err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
}
|
||||
n.Filename = slug
|
||||
f.updates = append(f.updates, n)
|
||||
return f.ref("u", n, true), nil
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) {
|
||||
f.gets = append(f.gets, id)
|
||||
return StoredNote{ID: id, Path: id}, nil
|
||||
}
|
||||
|
||||
type fakeTracker struct {
|
||||
created []string
|
||||
closed []int
|
||||
comments []int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.created = append(f.created, repo+":"+title)
|
||||
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.closed = append(f.closed, number)
|
||||
return IssueRef{Repo: repo, Number: number}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.comments = append(f.comments, number)
|
||||
return IssueRef{Repo: repo, Number: number}, nil
|
||||
}
|
||||
|
||||
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||
// behaviour without depending on the real defaulting.
|
||||
type fakePolicy struct{ tags map[string]classification.Level }
|
||||
|
||||
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||
if lvl, ok := p.tags[t.Name]; ok {
|
||||
return lvl
|
||||
}
|
||||
return classification.Internal
|
||||
}
|
||||
|
||||
type fakeAudit struct {
|
||||
entries []AuditEntry
|
||||
err error // Record error
|
||||
reserveErr error // Reserve error (refuse before write)
|
||||
reserveMode AuditOutcome
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) {
|
||||
if f.reserveErr != nil {
|
||||
return 0, f.reserveErr
|
||||
}
|
||||
return f.reserveMode, nil
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.entries = append(f.entries, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||
s := NewService(b, tr, p, a)
|
||||
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||
return s
|
||||
}
|
||||
|
||||
func baseCtx() CaptureContext {
|
||||
return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"}
|
||||
}
|
||||
|
||||
// --- scenarios ---
|
||||
|
||||
func TestCaptureHappyPath(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""},
|
||||
{Text: "b", Wing: "hyperguild", Hall: "facts"},
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rec.Insights, 2)
|
||||
for _, r := range rec.Insights {
|
||||
assert.True(t, r.OK)
|
||||
assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned")
|
||||
}
|
||||
require.Len(t, rec.Tickets, 1)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
assert.Equal(t, 2, len(b.writes))
|
||||
assert.Empty(t, rec.Errors)
|
||||
// Audit emitted naming principal/harness + items that landed.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Equal(t, "mathias", au.entries[0].Principal)
|
||||
assert.Equal(t, "claude-code", au.entries[0].Harness)
|
||||
assert.Len(t, au.entries[0].Items, 3)
|
||||
}
|
||||
|
||||
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, b.writes, "supersede must not create")
|
||||
require.Len(t, b.updates, 1)
|
||||
assert.Equal(t, "prior-note", b.updates[0].Filename)
|
||||
assert.True(t, rec.Insights[0].Superseded)
|
||||
}
|
||||
|
||||
func TestCaptureValidationFailClosed(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "ok", Wing: "hyperguild", Hall: "facts"},
|
||||
{Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// Nothing written anywhere.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, b.updates)
|
||||
assert.Empty(t, tr.created)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
|
||||
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCapturePartialFailureBestEffort(t *testing.T) {
|
||||
b := &fakeBrain{failOn: func(n Note) error {
|
||||
if strings.Contains(n.Content, "FAIL") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "good one", Wing: "hyperguild", Hall: "facts"},
|
||||
{Text: "FAIL here", Wing: "hyperguild", Hall: "facts"},
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.NoError(t, err, "partial failure is not a request-level error")
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.False(t, rec.Insights[1].OK)
|
||||
assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback")
|
||||
require.Len(t, rec.Errors, 1)
|
||||
assert.Equal(t, "insight[1]", rec.Errors[0].Item)
|
||||
// Audit reflects exactly what landed: 1 insight + 1 ticket.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Len(t, au.entries[0].Items, 2)
|
||||
}
|
||||
|
||||
func TestCaptureDryRunWritesNothing(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
DryRun: true,
|
||||
Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.DryRun)
|
||||
assert.Len(t, rec.Insights, 1)
|
||||
assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok")
|
||||
// Nothing written anywhere, including audit.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, tr.created)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
|
||||
func TestCaptureStricterClassificationWins(t *testing.T) {
|
||||
// Caller declares internal; target wing tagged confidential → effective confidential + security event.
|
||||
b := &fakeBrain{}
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, &fakeTracker{}, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "internal"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event")
|
||||
assert.Equal(t, "confidential", au.entries[0].EffectiveClassification)
|
||||
}
|
||||
|
||||
func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
||||
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||
}
|
||||
|
||||
// --- I1 sovereignty gate (#53) ---
|
||||
|
||||
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, tr, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
ctx.Origin = ZoneUSNexus
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||
// Refused before any write.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, tr.created)
|
||||
// Refusal is audited.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Empty(t, au.entries[0].Items, "no items landed on refusal")
|
||||
}
|
||||
|
||||
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
ctx.Origin = ZoneSovereign
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.Len(t, b.writes, 1)
|
||||
}
|
||||
|
||||
func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
||||
// Caller asserts harness "sovereign-soil" but principal resolves to
|
||||
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Harness = "sovereign-soil" // asserted
|
||||
ctx.Origin = ZoneUSNexus // server-derived
|
||||
ctx.Classification = "confidential"
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||
require.Len(t, au.entries, 1)
|
||||
joined := strings.Join(au.entries[0].SecurityEvents, " | ")
|
||||
assert.Contains(t, joined, "asserted-vs-derived origin mismatch")
|
||||
assert.Contains(t, joined, "I1 refusal")
|
||||
}
|
||||
|
||||
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||
// us-nexus origin is fine for non-confidential data.
|
||||
b := &fakeBrain{}
|
||||
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||
ctx := baseCtx()
|
||||
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
}
|
||||
|
||||
// --- I5 audit gate (#54) ---
|
||||
|
||||
func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
||||
// Reserve refusing (e.g. confidential + central sink down, or the floor)
|
||||
// aborts the capture before any write.
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrAuditUnavailable)
|
||||
assert.Empty(t, b.writes, "nothing written when audit unavailable")
|
||||
assert.Empty(t, tr.created)
|
||||
}
|
||||
|
||||
func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
||||
// Reserve returns AuditBuffered (internal/public, central down) → capture
|
||||
// proceeds and the receipt flags the degraded audit state.
|
||||
b := &fakeBrain{}
|
||||
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit")
|
||||
assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit")
|
||||
require.Len(t, au.entries, 1)
|
||||
}
|
||||
|
||||
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
DryRun: true,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err, "dry-run does not hit the audit gate")
|
||||
assert.True(t, rec.DryRun)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package capturehttp is the REST adapter for the capture use-case: the
|
||||
// POST /capture door (#53). It is deliberately thin — authenticate, derive
|
||||
// the trust-zone origin from the authenticated principal, decode the
|
||||
// request, call capture.Service, map the receipt to an HTTP status. No
|
||||
// business logic lives here; the I1 gate, validation, and orchestration
|
||||
// are all in the use-case.
|
||||
package capturehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// Validator validates a Bearer JWT and returns its subject. The chassis
|
||||
// *auth.JWTValidator satisfies it (including its nil-receiver "disabled"
|
||||
// behaviour), and tests can substitute a fake without a live JWKS.
|
||||
type Validator interface {
|
||||
Validate(ctx context.Context, rawToken string) (string, error)
|
||||
}
|
||||
|
||||
// Handler serves POST /capture.
|
||||
type Handler struct {
|
||||
svc *capture.Service
|
||||
validator Validator // nil ⇒ JWT auth disabled
|
||||
staticToken string // "" ⇒ static auth disabled
|
||||
staticPrincipal string // principal name attributed to static-token callers
|
||||
resolver OriginResolver
|
||||
}
|
||||
|
||||
// New constructs a capture HTTP handler. staticToken callers are
|
||||
// attributed to staticPrincipal (a sovereign homelab identity); JWT
|
||||
// callers are attributed to their token subject.
|
||||
func New(svc *capture.Service, validator Validator, staticToken, staticPrincipal string, resolver OriginResolver) *Handler {
|
||||
if staticPrincipal == "" {
|
||||
staticPrincipal = "local-cli"
|
||||
}
|
||||
return &Handler{
|
||||
svc: svc,
|
||||
validator: validator,
|
||||
staticToken: staticToken,
|
||||
staticPrincipal: staticPrincipal,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// wire types — the POST /capture request body.
|
||||
type request struct {
|
||||
Context contextBody `json:"context"`
|
||||
Insights []insightBody `json:"insights"`
|
||||
Tickets []ticketBody `json:"tickets"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type contextBody struct {
|
||||
Harness string `json:"harness"`
|
||||
SessionRef string `json:"session_ref"`
|
||||
Fidelity string `json:"fidelity"`
|
||||
Actor string `json:"actor"`
|
||||
Classification string `json:"classification"`
|
||||
}
|
||||
|
||||
type insightBody struct {
|
||||
Text string `json:"text"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
SupersedeSlug string `json:"supersede_slug,omitempty"`
|
||||
}
|
||||
|
||||
type ticketBody struct {
|
||||
Repo string `json:"repo"`
|
||||
Action string `json:"action"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||
// result to an HTTP status.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"})
|
||||
return
|
||||
}
|
||||
in, err := DecodeRequest(body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Principal and origin are server-derived — overwrite anything the
|
||||
// caller may have tried to put in the body.
|
||||
in.Context.Principal = principal
|
||||
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
||||
|
||||
rec, err := h.svc.Capture(r.Context(), in)
|
||||
switch {
|
||||
case errors.Is(err, capture.ErrSovereigntyRefused):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, capture.ErrAuditUnavailable):
|
||||
// I5 refusal: confidential + audit sink down, or the all-tiers floor.
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
// Pre-write validation failure (fail-closed).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, statusFor(rec), rec)
|
||||
}
|
||||
|
||||
// Authenticate mirrors the chassis Bearer precedence (static token wins,
|
||||
// then Dex JWT) and returns the resolved principal plus whether the static
|
||||
// path was taken — the chassis middleware hides both, and capture (REST or
|
||||
// MCP) needs them to derive the trust-zone origin. ok is false when no
|
||||
// credential matched.
|
||||
func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) {
|
||||
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !found || raw == "" {
|
||||
return "", false, false
|
||||
}
|
||||
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
|
||||
return staticPrincipal, true, true
|
||||
}
|
||||
if validator != nil {
|
||||
if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||
return sub, false, true
|
||||
}
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// DecodeRequest parses a capture request body into a CaptureInput. Shared
|
||||
// by the REST adapter and the MCP capture tool so the wire shape has one
|
||||
// definition. Principal and Origin are NOT set here — the caller sets them
|
||||
// from the authenticated identity.
|
||||
func DecodeRequest(data []byte) (capture.CaptureInput, error) {
|
||||
var b request
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return capture.CaptureInput{}, err
|
||||
}
|
||||
return b.toInput(), nil
|
||||
}
|
||||
|
||||
func (b request) toInput() capture.CaptureInput {
|
||||
in := capture.CaptureInput{
|
||||
Context: capture.CaptureContext{
|
||||
Harness: b.Context.Harness,
|
||||
SessionRef: b.Context.SessionRef,
|
||||
Fidelity: b.Context.Fidelity,
|
||||
Actor: b.Context.Actor,
|
||||
Classification: b.Context.Classification,
|
||||
},
|
||||
DryRun: b.DryRun,
|
||||
}
|
||||
for _, i := range b.Insights {
|
||||
in.Insights = append(in.Insights, capture.Insight{
|
||||
Text: i.Text, Wing: i.Wing, Hall: i.Hall, SupersedeSlug: i.SupersedeSlug,
|
||||
})
|
||||
}
|
||||
for _, t := range b.Tickets {
|
||||
in.Tickets = append(in.Tickets, capture.Ticket{
|
||||
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||
})
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// statusFor maps a receipt to an HTTP status: 200 all-ok (or dry-run),
|
||||
// 207 partial, 502 everything-failed.
|
||||
func statusFor(rec capture.CaptureReceipt) int {
|
||||
if rec.DryRun {
|
||||
return http.StatusOK
|
||||
}
|
||||
var ok, fail int
|
||||
for _, i := range rec.Insights {
|
||||
count(&ok, &fail, i.OK)
|
||||
}
|
||||
for _, t := range rec.Tickets {
|
||||
count(&ok, &fail, t.OK)
|
||||
}
|
||||
switch {
|
||||
case fail == 0:
|
||||
return http.StatusOK
|
||||
case ok == 0:
|
||||
return http.StatusBadGateway // every persistence attempt failed
|
||||
default:
|
||||
return http.StatusMultiStatus // 207: partial success
|
||||
}
|
||||
}
|
||||
|
||||
func count(ok, fail *int, isOK bool) {
|
||||
if isOK {
|
||||
*ok++
|
||||
} else {
|
||||
*fail++
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package capturehttp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const staticTok = "static-secret"
|
||||
|
||||
// fakeValidator stands in for the chassis JWT validator.
|
||||
type fakeValidator struct {
|
||||
subject string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
||||
return f.subject, f.err
|
||||
}
|
||||
|
||||
type fakeTracker struct{ failCreate bool }
|
||||
|
||||
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
if f.failCreate {
|
||||
return capture.IssueRef{}, errors.New("gitea down")
|
||||
}
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
||||
t.Helper()
|
||||
cfg, err := classification.Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
|
||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||
}
|
||||
|
||||
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func internalReq() map[string]any {
|
||||
return map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedWithoutToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedBadToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer wrong", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestHappyPathStaticToken(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
assert.Empty(t, rec.Errors)
|
||||
}
|
||||
|
||||
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
||||
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "sovereignty")
|
||||
}
|
||||
|
||||
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
||||
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
||||
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestPartialFailureIs207(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.False(t, rec.Tickets[0].OK)
|
||||
assert.Len(t, rec.Errors, 1)
|
||||
}
|
||||
|
||||
func TestDryRunWritesNothing(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||
"dry_run": true,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.DryRun)
|
||||
}
|
||||
|
||||
func TestCallerCannotForgeOrigin(t *testing.T) {
|
||||
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
||||
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt", map[string]any{
|
||||
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
// refusingAudit refuses at Reserve (e.g. confidential + loki down, or floor).
|
||||
type refusingAudit struct{}
|
||||
|
||||
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||
return 0, errors.New("central audit sink unreachable")
|
||||
}
|
||||
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAuditUnavailableIs503(t *testing.T) {
|
||||
cfg, err := classification.Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
|
||||
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||
|
||||
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package capturehttp
|
||||
|
||||
import "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
|
||||
// OriginResolver maps an authenticated principal to its trust zone
|
||||
// (spec §4.2). The mapping is server-side and never reads caller input.
|
||||
//
|
||||
// Rules:
|
||||
// - The static-token path is a homelab CLI caller on sovereign soil →
|
||||
// ZoneSovereign.
|
||||
// - A JWT principal in the sovereign allowlist → ZoneSovereign.
|
||||
// - Any other JWT principal (e.g. claude.ai's OAuth identity, or any
|
||||
// unrecognised subject) → ZoneUSNexus.
|
||||
//
|
||||
// The default is the strict one: an unknown principal is treated as
|
||||
// us-nexus so the I1 gate fails safe (refuses confidential), exactly as
|
||||
// an untagged classification target fails safe to confidential (#50).
|
||||
type OriginResolver struct {
|
||||
sovereign map[string]bool
|
||||
}
|
||||
|
||||
// NewOriginResolver builds a resolver whose JWT sovereign principals are
|
||||
// the given subjects. The static-token caller is always sovereign and
|
||||
// need not be listed.
|
||||
func NewOriginResolver(sovereignPrincipals []string) OriginResolver {
|
||||
m := make(map[string]bool, len(sovereignPrincipals))
|
||||
for _, p := range sovereignPrincipals {
|
||||
if p != "" {
|
||||
m[p] = true
|
||||
}
|
||||
}
|
||||
return OriginResolver{sovereign: m}
|
||||
}
|
||||
|
||||
// Resolve returns the trust zone for a principal. viaStatic is true when
|
||||
// the static-token auth path was taken.
|
||||
func (r OriginResolver) Resolve(principal string, viaStatic bool) capture.Zone {
|
||||
if viaStatic || r.sovereign[principal] {
|
||||
return capture.ZoneSovereign
|
||||
}
|
||||
return capture.ZoneUSNexus
|
||||
}
|
||||
@@ -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,148 @@
|
||||
// ingestion/internal/extract/docmark.go
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// docmarkRequest is a JSON-RPC 2.0 tools/call request for docmark's
|
||||
// convert_to_markdown tool.
|
||||
type docmarkRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments struct {
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
type docmarkResponse struct {
|
||||
Result *struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// extractViaDocmark converts path (PDF/DOCX/XLSX/PPTX/image) to Markdown by
|
||||
// calling the docmark MCP server with a single self-contained tools/call
|
||||
// request (docmark runs stateless_http -- no initialize handshake or session
|
||||
// ID needed). DOCMARK_URL must be set (e.g.
|
||||
// http://docmark.docmark.svc.cluster.local:3001/mcp); DOCMARK_BEARER_TOKEN
|
||||
// is docmark's static bearer (network is docmark's primary auth boundary,
|
||||
// this is defense-in-depth — ADR-0013).
|
||||
func extractViaDocmark(path string) (string, error) {
|
||||
url := os.Getenv("DOCMARK_URL")
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("extractViaDocmark: DOCMARK_URL is not set")
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var reqBody docmarkRequest
|
||||
reqBody.JSONRPC = "2.0"
|
||||
reqBody.ID = 1
|
||||
reqBody.Method = "tools/call"
|
||||
reqBody.Params.Name = "convert_to_markdown"
|
||||
reqBody.Params.Arguments.ContentBase64 = base64.StdEncoding.EncodeToString(raw)
|
||||
reqBody.Params.Arguments.Filename = fileBase(path)
|
||||
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal docmark request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build docmark request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if tok := os.Getenv("DOCMARK_BEARER_TOKEN"); tok != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("call docmark: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read docmark response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("docmark: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
jsonBody := body
|
||||
if data := sseDataPayload(body); data != nil {
|
||||
jsonBody = data
|
||||
}
|
||||
|
||||
var out docmarkResponse
|
||||
if err := json.Unmarshal(jsonBody, &out); err != nil {
|
||||
return "", fmt.Errorf("decode docmark response: %w", err)
|
||||
}
|
||||
if out.Error != nil {
|
||||
return "", fmt.Errorf("docmark: %s", out.Error.Message)
|
||||
}
|
||||
if out.Result == nil || len(out.Result.Content) == 0 {
|
||||
return "", fmt.Errorf("docmark: empty response")
|
||||
}
|
||||
text := out.Result.Content[0].Text
|
||||
if out.Result.IsError {
|
||||
return "", fmt.Errorf("docmark: %s", text)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// sseDataPayload extracts the JSON payload from an SSE-framed response body
|
||||
// ("event: message\r\ndata: {...}\r\n\r\n"). docmark's Streamable-HTTP
|
||||
// transport frames every response this way (Content-Type: text/event-stream)
|
||||
// regardless of stateless_http — that flag removes the session/initialize
|
||||
// requirement, not the SSE wire framing. Returns nil if body isn't SSE-framed
|
||||
// (e.g. a plain-JSON response, kept as a fallback for forward-compatibility).
|
||||
func sseDataPayload(body []byte) []byte {
|
||||
const prefix = "data: "
|
||||
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||
line = bytes.TrimRight(line, "\r")
|
||||
if bytes.HasPrefix(line, []byte(prefix)) {
|
||||
return bytes.TrimPrefix(line, []byte(prefix))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileBase returns the final path segment (like filepath.Base, kept local to
|
||||
// avoid importing path/filepath just for this one call).
|
||||
func fileBase(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' || path[i] == '\\' {
|
||||
return path[i+1:]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// ingestion/internal/extract/docmark_test.go
|
||||
package extract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mcpToolResult mirrors the shape of docmark's JSON-RPC tools/call response.
|
||||
type mcpToolResult struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result *struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// writeMCPResponse mirrors docmark's REAL response framing (empirically
|
||||
// confirmed against the live server): Content-Type: text/event-stream,
|
||||
// body is SSE-framed ("event: message\r\ndata: {...}\r\n\r\n"), not bare
|
||||
// JSON -- inherent to MCP Streamable-HTTP, independent of stateless_http.
|
||||
func writeMCPResponse(w http.ResponseWriter, body mcpToolResult) {
|
||||
payload, _ := json.Marshal(body)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("event: message\r\ndata: "))
|
||||
_, _ = w.Write(payload)
|
||||
_, _ = w.Write([]byte("\r\n\r\n"))
|
||||
}
|
||||
|
||||
func TestExtractViaDocmark_Success(t *testing.T) {
|
||||
var gotAuth, gotAccept string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotAccept = r.Header.Get("Accept")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
writeMCPResponse(w, mcpToolResult{
|
||||
JSONRPC: "2.0", ID: 1,
|
||||
Result: &struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}{
|
||||
Content: []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}{{Type: "text", Text: "# Converted\n\nhello"}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "test-token-123")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(path, []byte("fake docx bytes"), 0o644))
|
||||
|
||||
got, err := extractViaDocmark(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# Converted\n\nhello", got)
|
||||
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||
assert.Contains(t, gotAccept, "application/json")
|
||||
params, _ := gotBody["params"].(map[string]any)
|
||||
require.NotNil(t, params)
|
||||
assert.Equal(t, "convert_to_markdown", params["name"])
|
||||
args, _ := params["arguments"].(map[string]any)
|
||||
require.NotNil(t, args)
|
||||
assert.Equal(t, "doc.docx", args["filename"])
|
||||
assert.NotEmpty(t, args["content_base64"])
|
||||
}
|
||||
|
||||
func TestExtractViaDocmark_NotConfigured(t *testing.T) {
|
||||
t.Setenv("DOCMARK_URL", "")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||
|
||||
_, err := extractViaDocmark(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DOCMARK_URL")
|
||||
}
|
||||
|
||||
func TestExtractViaDocmark_ToolErrorSurfacesMessage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeMCPResponse(w, mcpToolResult{
|
||||
JSONRPC: "2.0", ID: 1,
|
||||
Result: &struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}{
|
||||
Content: []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}{{Type: "text", Text: "unsupported format for 'doc.docx'"}},
|
||||
IsError: true,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||
|
||||
_, err := extractViaDocmark(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported format")
|
||||
}
|
||||
|
||||
func TestExtractViaDocmark_HTTPErrorSurfaces(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "wrong")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "doc.docx")
|
||||
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||
|
||||
_, err := extractViaDocmark(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "401")
|
||||
}
|
||||
|
||||
func TestText_RoutesDocxXlsxPptxImagesToDocmark(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeMCPResponse(w, mcpToolResult{
|
||||
JSONRPC: "2.0", ID: 1,
|
||||
Result: &struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}{
|
||||
Content: []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}{{Type: "text", Text: "converted"}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||
|
||||
for _, ext := range []string{".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg"} {
|
||||
t.Run(ext, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f"+ext)
|
||||
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||
got, err := Text(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "converted", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
)
|
||||
|
||||
// Text reads the file at path and returns its plain-text content.
|
||||
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
||||
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext),
|
||||
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg (via docmark, ADR-0013 -- requires
|
||||
// DOCMARK_URL to be set; see docmark.go).
|
||||
func Text(path string) (string, error) {
|
||||
ext := strings.ToLower(fileExt(path))
|
||||
switch ext {
|
||||
@@ -20,6 +22,8 @@ func Text(path string) (string, error) {
|
||||
return string(b), nil
|
||||
case ".pdf":
|
||||
return extractPDF(path)
|
||||
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||
return extractViaDocmark(path)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package gitea implements capture.IssueTracker against a Gitea instance
|
||||
// over its REST API. It is the new outbound dependency the brain server
|
||||
// gains for the capture capability (#49c/#52): the server otherwise does
|
||||
// brain-local file ops only.
|
||||
//
|
||||
// Owner is hard-coded to the operator and never taken from caller input.
|
||||
// The API token is read once at construction, held in the struct, and
|
||||
// never logged or placed in argv — it travels only in the Authorization
|
||||
// header of outbound requests (AGENTS.md secret-handling).
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// owner is the fixed repository owner for every ticket operation. It is a
|
||||
// constant, not a parameter, so a caller can never redirect a write to
|
||||
// another owner's repo.
|
||||
const owner = "mathias"
|
||||
|
||||
// Client is a Gitea REST API IssueTracker.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New constructs a Client. It returns nil when either baseURL or token is
|
||||
// empty, so callers can treat missing config as "tracker disabled" with a
|
||||
// single nil check (mirrors embed.New).
|
||||
func New(baseURL, token string) *Client {
|
||||
if baseURL == "" || token == "" {
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// issueResponse is the subset of a Gitea issue/comment payload we read.
|
||||
type issueResponse struct {
|
||||
Number int `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// CreateIssue opens a new issue under the fixed owner.
|
||||
func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) {
|
||||
var out issueResponse
|
||||
if err := c.do(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo),
|
||||
map[string]any{"title": title, "body": body}, &out); err != nil {
|
||||
return capture.IssueRef{}, err
|
||||
}
|
||||
return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil
|
||||
}
|
||||
|
||||
// CommentIssue posts a comment on an existing issue.
|
||||
func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) {
|
||||
var out issueResponse
|
||||
if err := c.do(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number),
|
||||
map[string]any{"body": body}, &out); err != nil {
|
||||
return capture.IssueRef{}, err
|
||||
}
|
||||
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||
}
|
||||
|
||||
// CloseIssue closes an issue, first posting a closing comment when one is
|
||||
// given (empty comment ⇒ close only).
|
||||
func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) {
|
||||
if strings.TrimSpace(comment) != "" {
|
||||
if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil {
|
||||
return capture.IssueRef{}, err
|
||||
}
|
||||
}
|
||||
var out issueResponse
|
||||
if err := c.do(ctx, http.MethodPatch,
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number),
|
||||
map[string]any{"state": "closed"}, &out); err != nil {
|
||||
return capture.IssueRef{}, err
|
||||
}
|
||||
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||
}
|
||||
|
||||
// WriteFile creates or updates a file in repo at path via the Gitea
|
||||
// contents API — the SummaryWriter port (#66). It upserts: a GET resolves
|
||||
// the current blob sha (if any) so an existing file is updated rather than
|
||||
// rejected (the richer-fidelity-supersedes rule for re-captured sessions).
|
||||
// Owner is the fixed const, like every other call.
|
||||
func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error {
|
||||
cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||
sha, err := c.fileSHA(ctx, cpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"message": "capture: " + path,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
}
|
||||
// Gitea contents API: POST creates a new file, PUT updates an existing
|
||||
// one (PUT requires the current sha). Pick by whether the file exists.
|
||||
method := http.MethodPost
|
||||
if sha != "" {
|
||||
method = http.MethodPut
|
||||
payload["sha"] = sha
|
||||
}
|
||||
status, body, err := c.request(ctx, method, cpath, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSHA returns the current blob sha for a contents path, or "" when the
|
||||
// file does not exist (404). Any other non-2xx is an error.
|
||||
func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) {
|
||||
status, body, err := c.request(ctx, http.MethodGet, cpath, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var meta struct {
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err)
|
||||
}
|
||||
return meta.SHA, nil
|
||||
}
|
||||
|
||||
// do performs a JSON request against the Gitea API and decodes a 2xx
|
||||
// response into out. Errors carry the status and a truncated body for
|
||||
// diagnosis but never the token.
|
||||
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||
status, body, err := c.request(ctx, method, path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if out != nil && len(body) > 0 {
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// request is the shared HTTP path: marshals an optional JSON payload,
|
||||
// attaches auth (token only ever in the header), and returns the status +
|
||||
// body so callers can branch on status (e.g. 404) without it being an
|
||||
// error. Never logs the token.
|
||||
func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) {
|
||||
var reader io.Reader
|
||||
if payload != nil {
|
||||
reqBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(reqBody)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testToken = "super-secret-token-value"
|
||||
|
||||
func TestNewNilWhenUnconfigured(t *testing.T) {
|
||||
assert.Nil(t, gitea.New("", testToken))
|
||||
assert.Nil(t, gitea.New("https://git.example", ""))
|
||||
}
|
||||
|
||||
func TestCreateIssueForcesOwnerAndAuth(t *testing.T) {
|
||||
var gotPath, gotAuth, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.New(srv.URL, testToken)
|
||||
require.NotNil(t, c)
|
||||
ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias")
|
||||
assert.Equal(t, "token "+testToken, gotAuth)
|
||||
assert.Contains(t, gotBody, "Do the thing")
|
||||
assert.Equal(t, "hyperguild", ref.Repo)
|
||||
assert.Equal(t, 42, ref.Number)
|
||||
assert.Contains(t, ref.URL, "/issues/42")
|
||||
}
|
||||
|
||||
func TestCommentIssue(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath)
|
||||
assert.Equal(t, 7, ref.Number)
|
||||
}
|
||||
|
||||
func TestCloseIssueWithComment(t *testing.T) {
|
||||
var paths []string
|
||||
var states []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||
if r.Method == http.MethodPatch {
|
||||
var body map[string]any
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &body)
|
||||
states = append(states, body["state"].(string))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 9, ref.Number)
|
||||
// Comment posted first, then state PATCHed to closed.
|
||||
assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments")
|
||||
assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9")
|
||||
assert.Equal(t, []string{"closed"}, states)
|
||||
}
|
||||
|
||||
func TestCloseIssueNoComment(t *testing.T) {
|
||||
var commented bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/comments") {
|
||||
commented = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, commented, "empty comment ⇒ no comment POST")
|
||||
}
|
||||
|
||||
func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b")
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||
assert.Contains(t, err.Error(), "500")
|
||||
}
|
||||
|
||||
func TestWriteFileCreatesNewFile(t *testing.T) {
|
||||
var getPath, postPath, postBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNotFound) // file does not exist yet
|
||||
case http.MethodPost: // gitea contents API: POST = create
|
||||
postPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
postBody = string(b)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||
default:
|
||||
t.Errorf("create must POST, got %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(),
|
||||
"ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath)
|
||||
assert.Equal(t, getPath, postPath)
|
||||
// base64 of the content, no sha on create.
|
||||
assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary")
|
||||
assert.NotContains(t, postBody, `"sha"`)
|
||||
}
|
||||
|
||||
func TestWriteFileUpdatesExisting(t *testing.T) {
|
||||
var putBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"})
|
||||
case http.MethodPut:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
putBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha")
|
||||
}
|
||||
|
||||
func TestWriteFileErrorNoTokenLeak(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte("bad"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x")
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), testToken)
|
||||
assert.Contains(t, err.Error(), "422")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
)
|
||||
|
||||
// BearerAuth gates an HTTP handler behind dual-mode authentication.
|
||||
//
|
||||
// Auth precedence:
|
||||
//
|
||||
// 1. Static Bearer match (constant-time compare against staticToken).
|
||||
// Wins immediately and never emits a WWW-Authenticate header. This is
|
||||
// the path used by internal Tailscale/LAN CLI callers that supply
|
||||
// `Authorization: Bearer $BRAIN_MCP_TOKEN` via `.mcp.json`. Returning
|
||||
// 200 without a WWW-Authenticate prevents the MCP client from
|
||||
// speculatively flipping into OAuth-discovery mode.
|
||||
// 2. Dex JWT validation (when validator is non-nil). Used by claude.ai
|
||||
// custom MCP connectors that finished the OAuth handshake.
|
||||
// 3. Otherwise 401. When resourceMetadataURL is non-empty, a
|
||||
// `WWW-Authenticate: Bearer resource_metadata="…"` header is emitted
|
||||
// per RFC 9728 §6.2 so claude.ai's OAuth discovery flow can find the
|
||||
// server's protected-resource metadata document.
|
||||
//
|
||||
// The order matters: a valid static Bearer must short-circuit BEFORE any
|
||||
// JWT path runs, because a non-empty WWW-Authenticate emitted on the
|
||||
// fall-through 401 confuses static-Bearer-only clients into discarding
|
||||
// their header and starting an OAuth handshake instead.
|
||||
func BearerAuth(staticToken string, validator *auth.Validator, resourceMetadataURL string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok {
|
||||
unauthorized(w, resourceMetadataURL)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Static Bearer wins first — never emits a challenge.
|
||||
if staticToken != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(staticToken)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Then Dex JWT, if configured.
|
||||
if validator != nil {
|
||||
if _, err := validator.Validate(r.Context(), rawToken); err == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reject with an OAuth resource-metadata challenge if configured.
|
||||
unauthorized(w, resourceMetadataURL)
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, resourceMetadataURL string) {
|
||||
if resourceMetadataURL != "" {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer realm="brain", resource_metadata="`+resourceMetadataURL+`"`)
|
||||
}
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testResourceMetadataURL = "https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"
|
||||
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBearerAuth_MissingHeader(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestBearerAuth_WrongToken(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestBearerAuth_CorrectToken(t *testing.T) {
|
||||
called := false
|
||||
handler := mcp.BearerAuth("secret", nil, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestBearerAuth_EmptyConfiguredToken(t *testing.T) {
|
||||
handler := mcp.BearerAuth("", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
// Issue #9: a valid static Bearer must never emit a WWW-Authenticate header,
|
||||
// even when a resource-metadata URL is configured. The presence of that
|
||||
// header on a 200 response would flip MCP CLI clients into OAuth-discovery
|
||||
// mode and break static-Bearer auth from `.mcp.json` on Tailscale/LAN.
|
||||
func TestBearerAuth_ValidStaticBearer_NoWWWAuthenticate(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Empty(t, rr.Header().Get("WWW-Authenticate"), "static-Bearer 200 must not advertise OAuth")
|
||||
}
|
||||
|
||||
// Issue #9: a 401 with resource-metadata configured must emit a
|
||||
// WWW-Authenticate header so claude.ai discovers the protected-resource
|
||||
// metadata document and continues the OAuth dance.
|
||||
func TestBearerAuth_Unauthorized_EmitsResourceMetadataChallenge(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
got := rr.Header().Get("WWW-Authenticate")
|
||||
assert.Contains(t, got, `Bearer realm="brain"`)
|
||||
assert.Contains(t, got, `resource_metadata="`+testResourceMetadataURL+`"`)
|
||||
}
|
||||
|
||||
// Static-Bearer-only deployment: no resource-metadata URL, no challenge
|
||||
// header on 401 — matches pre-#9 behaviour for tests without Dex wired.
|
||||
func TestBearerAuth_Unauthorized_NoChallengeWhenResourceUnset(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
assert.Empty(t, rr.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
|
||||
// JWT auth tests
|
||||
|
||||
func buildOIDCServer(t *testing.T) (*httptest.Server, jwk.Key) {
|
||||
t.Helper()
|
||||
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
priv, err := jwk.FromRaw(raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, priv.Set(jwk.KeyIDKey, "k1"))
|
||||
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||
pub, err := jwk.PublicKeyOf(priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
set := jwk.NewSet()
|
||||
require.NoError(t, set.AddKey(pub))
|
||||
jwksBytes, err := json.Marshal(set)
|
||||
require.NoError(t, err)
|
||||
|
||||
muxSrv := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
muxSrv.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": srv.URL,
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
})
|
||||
})
|
||||
muxSrv.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(jwksBytes)
|
||||
})
|
||||
srv = httptest.NewServer(muxSrv)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, priv
|
||||
}
|
||||
|
||||
func signJWT(t *testing.T, priv jwk.Key, issuer, audience string, exp time.Time) string {
|
||||
t.Helper()
|
||||
tok, err := jwt.NewBuilder().
|
||||
Issuer(issuer).Audience([]string{audience}).
|
||||
Subject("s").Expiration(exp).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, priv))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
func TestBearerAuth_ValidJWT(t *testing.T) {
|
||||
oidcSrv, priv := buildOIDCServer(t)
|
||||
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||
require.NoError(t, err)
|
||||
|
||||
called := false
|
||||
handler := mcp.BearerAuth("static-secret", v, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(time.Hour))
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestBearerAuth_InvalidJWT_FallsBackToStaticToken(t *testing.T) {
|
||||
oidcSrv, _ := buildOIDCServer(t)
|
||||
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer static-secret")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestBearerAuth_InvalidJWT_WrongStaticToken(t *testing.T) {
|
||||
oidcSrv, priv := buildOIDCServer(t)
|
||||
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
||||
// Expired JWT — JWT fails, static token doesn't match either
|
||||
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(-time.Hour))
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
_ = context.Background() // satisfies import
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"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"
|
||||
@@ -37,7 +39,7 @@ func (s *Server) tools() []map[string]any {
|
||||
return b
|
||||
}
|
||||
|
||||
return []map[string]any{
|
||||
tools := []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).",
|
||||
@@ -60,6 +62,41 @@ func (s *Server) tools() []map[string]any {
|
||||
"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_pending",
|
||||
"description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.",
|
||||
"inputSchema": schema([]string{}, map[string]any{}),
|
||||
},
|
||||
{
|
||||
"name": "brain_promote",
|
||||
"description": "Promote a brain/raw/ note into brain/wiki/<wing>/<hall>/: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.",
|
||||
"inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{
|
||||
"filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"),
|
||||
"wing": str("target wing, e.g. jepa-fx"),
|
||||
"hall": enum("target hall", halls...),
|
||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_tunnel",
|
||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||
@@ -108,6 +145,32 @@ func (s *Server) tools() []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.",
|
||||
@@ -124,6 +187,13 @@ func (s *Server) tools() []map[string]any {
|
||||
}),
|
||||
},
|
||||
}
|
||||
// The capture relay tool (#55) is advertised only when wired via
|
||||
// WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council)
|
||||
// reach capture through it.
|
||||
if s.capture != nil {
|
||||
tools = append(tools, captureToolDescriptor())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
type brainQueryArgs struct {
|
||||
@@ -172,7 +242,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
// Delegate to the shared BrainStore so write+index+tunnel+graph live in
|
||||
// one implementation (capture uses the same store). The read-after-write
|
||||
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||
// for backward compatibility.
|
||||
ref, err := s.store.Write(ctx, capture.Note{
|
||||
Content: a.Content,
|
||||
Filename: a.Filename,
|
||||
Type: a.Type,
|
||||
@@ -183,20 +257,133 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-regenerate the wing _index.md when the write landed in the
|
||||
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
||||
// best-effort: the note is already written.
|
||||
if a.Wing != "" && a.Hall != "" {
|
||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
||||
}
|
||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// path takes precedence over slug; the store treats any slug containing
|
||||
// a slash as a full brain-relative path (issue #45: "slug ... OR path").
|
||||
slug := a.Slug
|
||||
if a.Path != "" {
|
||||
slug = a.Path
|
||||
}
|
||||
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||
Content: a.Content,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
Reason: a.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||
})
|
||||
}
|
||||
|
||||
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(ctx 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")
|
||||
}
|
||||
note, err := s.store.Get(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
|
||||
"frontmatter": note.Frontmatter, "body": note.Body,
|
||||
})
|
||||
}
|
||||
|
||||
// brainPending lists the raw/ review queue (oldest-first).
|
||||
func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) {
|
||||
pending, err := api.ListPending(s.brainDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"pending": pending})
|
||||
}
|
||||
|
||||
type brainPromoteArgs struct {
|
||||
Filename string `json:"filename"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
}
|
||||
|
||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||
// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then
|
||||
// re-indexes it into the graph. The human-facing complement to brain_write.
|
||||
func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainPromoteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_promote", relPath)
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -213,6 +400,8 @@ func (s *Server) brainTunnel(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -268,6 +457,11 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -358,6 +552,11 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
||||
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{}
|
||||
|
||||
@@ -332,3 +332,61 @@ func TestSessionLogRequiresSessionID(t *testing.T) {
|
||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainPendingListsRaw(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-x.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "2026-06-01-x.md")
|
||||
assert.Contains(t, text, "pending body")
|
||||
}
|
||||
|
||||
func TestBrainPendingEmpty(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, `"pending":[]`)
|
||||
}
|
||||
|
||||
func TestBrainPromoteMovesToWiki(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-decision.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions",
|
||||
})
|
||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "wiki/jepa-fx/decisions/decision.md")
|
||||
|
||||
_, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md"))
|
||||
require.NoError(t, err)
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md"))
|
||||
assert.True(t, os.IsNotExist(srcErr), "source removed")
|
||||
}
|
||||
|
||||
func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "x.md"), []byte("body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "x.md", "wing": "a", "hall": "garbage",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||
// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
|
||||
// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log.
|
||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||
// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest,
|
||||
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
||||
// brain_context, session_log, and capture (the #55 relay tool, registered
|
||||
// only when WithCapture is set).
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -9,6 +12,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"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"
|
||||
@@ -42,6 +50,22 @@ type Server struct {
|
||||
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
|
||||
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
||||
capture *captureDeps // nil = capture MCP tool disabled (#55 relay)
|
||||
}
|
||||
|
||||
// captureDeps holds what the MCP `capture` tool (the #55 relay door for
|
||||
// MCP-native harnesses like claude.ai) needs: the use-case, the auth bits
|
||||
// to re-derive the caller's principal from the Bearer header (the chassis
|
||||
// middleware gates but discards the principal), and the origin resolver.
|
||||
type captureDeps struct {
|
||||
svc *capture.Service
|
||||
validator capturehttp.Validator
|
||||
staticToken string
|
||||
staticPrincipal string
|
||||
resolver capturehttp.OriginResolver
|
||||
}
|
||||
|
||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||
@@ -52,7 +76,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl
|
||||
if pipelineCfg != nil {
|
||||
cfg = *pipelineCfg
|
||||
}
|
||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
||||
return &Server{
|
||||
brainDir: brainDir,
|
||||
pipeline: cfg,
|
||||
llm: llm,
|
||||
answerLLM: answerLLM,
|
||||
store: brainstore.New(brainDir),
|
||||
}
|
||||
}
|
||||
|
||||
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||
@@ -73,6 +103,65 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
||||
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
|
||||
s.store.WithGraph(nil)
|
||||
return s
|
||||
}
|
||||
s.graph = g
|
||||
s.store.WithGraph(g)
|
||||
return s
|
||||
}
|
||||
|
||||
// WithIssueTracker injects the Gitea ticket tracker behind the
|
||||
// capture.IssueTracker interface. nil leaves ticket integration off. The
|
||||
// use-case (capture) consumes this in #53; it is wired here so the
|
||||
// dependency is constructed once and stays swappable/testable.
|
||||
func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server {
|
||||
s.tracker = t
|
||||
return s
|
||||
}
|
||||
|
||||
// IssueTracker returns the injected ticket tracker (nil when unconfigured).
|
||||
func (s *Server) IssueTracker() capture.IssueTracker {
|
||||
return s.tracker
|
||||
}
|
||||
|
||||
// BrainStore returns the shared brain store (graph-wired once WithGraph
|
||||
// has run), so the capture use-case writes through the exact same
|
||||
// implementation as the MCP handlers.
|
||||
func (s *Server) BrainStore() *brainstore.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// WithCapture enables the MCP `capture` tool (#55) — the relay door for
|
||||
// MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) that cannot run
|
||||
// the use-case in-process. It forwards to the same CaptureService as
|
||||
// POST /capture, deriving the caller's principal + origin from the same
|
||||
// auth credentials that gate /mcp. nil svc leaves the tool unregistered.
|
||||
func (s *Server) WithCapture(svc *capture.Service, validator capturehttp.Validator, staticToken, staticPrincipal string, resolver capturehttp.OriginResolver) *Server {
|
||||
if svc == nil {
|
||||
s.capture = nil
|
||||
return s
|
||||
}
|
||||
if staticPrincipal == "" {
|
||||
staticPrincipal = "local-cli"
|
||||
}
|
||||
s.capture = &captureDeps{
|
||||
svc: svc,
|
||||
validator: validator,
|
||||
staticToken: staticToken,
|
||||
staticPrincipal: staticPrincipal,
|
||||
resolver: resolver,
|
||||
}
|
||||
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 {
|
||||
@@ -122,7 +211,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||
break
|
||||
}
|
||||
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
||||
// Re-derive the authenticated principal from the Bearer header so
|
||||
// the capture tool can compute the trust-zone origin. The request
|
||||
// is already gated by BearerMiddleware; this only recovers the
|
||||
// identity that middleware discards.
|
||||
ctx := r.Context()
|
||||
if s.capture != nil {
|
||||
if principal, viaStatic, ok := capturehttp.Authenticate(
|
||||
r, s.capture.staticToken, s.capture.staticPrincipal, s.capture.validator); ok {
|
||||
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||
}
|
||||
}
|
||||
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||
if err != nil {
|
||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||
break
|
||||
@@ -160,6 +260,16 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
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_pending":
|
||||
return s.brainPending(ctx, args)
|
||||
case "brain_promote":
|
||||
return s.brainPromote(ctx, args)
|
||||
case "capture":
|
||||
return s.brainCapture(ctx, args)
|
||||
case "brain_index":
|
||||
return s.brainIndex(ctx, args)
|
||||
case "brain_tunnel":
|
||||
@@ -174,6 +284,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -55,9 +57,12 @@ func TestServerToolsList(t *testing.T) {
|
||||
names = append(names, t.(map[string]any)["name"].(string))
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||
"brain_pending", "brain_promote",
|
||||
"brain_index", "brain_tunnel",
|
||||
"brain_ingest_raw", "brain_ingest",
|
||||
"brain_answer", "brain_classify", "session_log",
|
||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||
"session_log",
|
||||
}, names)
|
||||
}
|
||||
|
||||
@@ -91,3 +96,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) {
|
||||
assert.Equal(t, float64(-32601), errObj["code"])
|
||||
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
||||
}
|
||||
|
||||
type stubTracker struct{}
|
||||
|
||||
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func TestWithIssueTrackerInjects(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
assert.Nil(t, srv.IssueTracker(), "tracker is off by default")
|
||||
srv = srv.WithIssueTracker(stubTracker{})
|
||||
assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface")
|
||||
}
|
||||
|
||||
@@ -77,9 +77,22 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if s.reranker != nil && len(results) > 0 {
|
||||
results, err = rerankResults(ctx, s.reranker, a.Query, results, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rerank: %w", err)
|
||||
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 {
|
||||
@@ -96,6 +109,29 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
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)
|
||||
@@ -107,6 +143,25 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -98,6 +98,42 @@ func TestBrainAnswer_RerankerFiltersBeforeLLM(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
)
|
||||
|
||||
// principalKey is the context key under which the authenticated principal
|
||||
// (re-derived in ServeHTTP) is stashed for the capture tool.
|
||||
type principalKeyT struct{}
|
||||
|
||||
var principalKey principalKeyT
|
||||
|
||||
type principalInfo struct {
|
||||
principal string
|
||||
viaStatic bool
|
||||
}
|
||||
|
||||
func withPrincipal(ctx context.Context, principal string, viaStatic bool) context.Context {
|
||||
return context.WithValue(ctx, principalKey, principalInfo{principal: principal, viaStatic: viaStatic})
|
||||
}
|
||||
|
||||
// captureToolDescriptor is the tools/list entry for the capture relay.
|
||||
// Appended only when WithCapture has wired the tool.
|
||||
func captureToolDescriptor() map[string]any {
|
||||
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
insightItem := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": str("the insight body"), "wing": str("brain wing"),
|
||||
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
||||
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
||||
},
|
||||
"required": []string{"text", "wing", "hall"},
|
||||
}
|
||||
ticketItem := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
||||
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
||||
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
||||
},
|
||||
"required": []string{"repo", "action"},
|
||||
}
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"context": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
||||
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
||||
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
||||
},
|
||||
},
|
||||
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
return map[string]any{
|
||||
"name": "capture",
|
||||
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
||||
"inputSchema": json.RawMessage(b),
|
||||
}
|
||||
}
|
||||
|
||||
// brainCapture is the MCP capture tool: the #55 relay for MCP-native
|
||||
// harnesses. It re-uses the same CaptureService, principal-derivation, and
|
||||
// origin resolver as POST /capture — only the transport differs. It holds
|
||||
// no state and retains nothing beyond the I5 audit record.
|
||||
func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
if s.capture == nil {
|
||||
return nil, fmt.Errorf("capture tool not configured")
|
||||
}
|
||||
info, ok := ctx.Value(principalKey).(principalInfo)
|
||||
if !ok || info.principal == "" {
|
||||
// No authenticated principal ⇒ cannot derive origin ⇒ cannot gate.
|
||||
return nil, fmt.Errorf("capture requires an authenticated principal")
|
||||
}
|
||||
|
||||
in, err := capturehttp.DecodeRequest(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid capture request: %w", err)
|
||||
}
|
||||
// Principal and origin are server-derived — never taken from the body.
|
||||
in.Context.Principal = info.principal
|
||||
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
||||
|
||||
rec, err := s.capture.svc.Capture(ctx, in)
|
||||
if err != nil {
|
||||
// Surface I1/I5 refusals and validation failures verbatim; errors.Is
|
||||
// markers (ErrSovereigntyRefused / ErrAuditUnavailable) ride in the message.
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(rec)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const capStaticTok = "cap-static-tok"
|
||||
|
||||
type capFakeTracker struct{}
|
||||
|
||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
type capFakeValidator struct {
|
||||
subject string
|
||||
err error
|
||||
}
|
||||
|
||||
func (v capFakeValidator) Validate(context.Context, string) (string, error) {
|
||||
return v.subject, v.err
|
||||
}
|
||||
|
||||
func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []string) (*mcp.Server, string) {
|
||||
t.Helper()
|
||||
brainDir := t.TempDir()
|
||||
cfg, err := classification.Load(brainDir)
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||
return srv, brainDir
|
||||
}
|
||||
|
||||
func captureCall(t *testing.T, srv http.Handler, authz string, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": "capture", "arguments": args},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCaptureToolListedWhenWired(t *testing.T) {
|
||||
srv, _ := captureServer(t, nil, nil)
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
assert.Contains(t, rr.Body.String(), `"capture"`)
|
||||
}
|
||||
|
||||
func TestCaptureToolNotListedByDefault(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil) // no WithCapture
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
assert.NotContains(t, rr.Body.String(), `"capture"`)
|
||||
}
|
||||
|
||||
func TestCaptureToolForwardsViaStaticPrincipal(t *testing.T) {
|
||||
srv, brainDir := captureServer(t, nil, nil)
|
||||
resp := captureCall(t, srv, "Bearer "+capStaticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||
})
|
||||
require.Nil(t, resp["error"], "got error: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
// Forwarded to the real brain store.
|
||||
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/hyperguild/facts"))
|
||||
require.NoError(t, statErr)
|
||||
}
|
||||
|
||||
func TestCaptureToolRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||
// JWT principal not in the sovereign allowlist ⇒ us-nexus origin.
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||
}
|
||||
|
||||
func TestCaptureToolAllowsConfidentialViaSovereignJWT(t *testing.T) {
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
|
||||
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Nil(t, resp["error"], "sovereign JWT principal should be allowed: %v", resp["error"])
|
||||
}
|
||||
|
||||
func TestCaptureToolRejectsUnauthenticated(t *testing.T) {
|
||||
srv, _ := captureServer(t, capFakeValidator{err: errors.New("no jwt")}, nil)
|
||||
resp := captureCall(t, srv, "", map[string]any{ // no Authorization
|
||||
"context": map[string]any{"harness": "x", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "authenticated principal")
|
||||
}
|
||||
|
||||
func TestCaptureToolCallerCannotForgeOrigin(t *testing.T) {
|
||||
// Body asserts sovereign harness, but the us-nexus JWT principal governs.
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||
resp := captureCall(t, srv, "Bearer jwt", map[string]any{
|
||||
"context": map[string]any{"harness": "sovereign-soil", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user