Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
|
||||||
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
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
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
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
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -83,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
|
- **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
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -162,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 |
|
| **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 |
|
| **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":"..."}` |
|
| **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`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -224,15 +268,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
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:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| 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
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: cd
|
name: cd
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI"]
|
workflows: ["CI"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
@@ -13,9 +13,9 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||||
environment: staging
|
environment: staging
|
||||||
env:
|
env:
|
||||||
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||||
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
|
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
||||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -71,17 +71,17 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||||
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
cd /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"
|
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
|
|
||||||
sed -i "s|gitea.d-ma.be/mathias/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
sed -i "s|git.d-ma.be/mathias/routing:.*|git.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
||||||
"k3s/apps/routing/deployment.yaml"
|
"k3s/apps/routing/deployment.yaml"
|
||||||
|
|
||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Wait for Flux to apply new ingestion image
|
- name: Wait for Flux to apply new ingestion image
|
||||||
run: |
|
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
|
for i in $(seq 1 60); do
|
||||||
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Wait for Flux to apply new routing image
|
- name: Wait for Flux to apply new routing image
|
||||||
run: |
|
run: |
|
||||||
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
|
EXPECTED="git.d-ma.be/mathias/routing:${{ github.sha }}"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
CURRENT=$(kubectl get deploy routing -n routing \
|
CURRENT=$(kubectl get deploy routing -n routing \
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
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
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
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
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -78,6 +102,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
|
- **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
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -157,7 +201,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 |
|
| **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 |
|
| **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":"..."}` |
|
| **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`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -219,15 +263,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
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:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| 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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -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
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
into a searchable brain.
|
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
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
Your Claude Code session (in any project)
|
Your Claude Code session (in any project)
|
||||||
│
|
│
|
||||||
│ MCP over HTTP (Tailscale)
|
│ MCP over HTTP (Tailscale)
|
||||||
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
├──▶ routing :3210 (NodePort 30310 on koala) — review, debug, retrospective, trainer
|
||||||
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
|
||||||
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
||||||
│
|
│
|
||||||
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
||||||
@@ -20,34 +37,28 @@ Your Claude Code session (in any project)
|
|||||||
▼
|
▼
|
||||||
brain/
|
brain/
|
||||||
├── sessions/ — JSONL log, one file per session_id
|
├── sessions/ — JSONL log, one file per session_id
|
||||||
├── wiki/ — searchable knowledge (full-text)
|
├── wiki/ — searchable knowledge (wing/hall layout)
|
||||||
│ ├── concepts/
|
│ ├── homelab/
|
||||||
│ ├── entities/
|
│ ├── claude-sessions/
|
||||||
│ └── sources/
|
│ └── ...
|
||||||
├── raw/ — retrospective output, staged for review
|
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||||
└── training-data/ — SFT/DPO/RL data (Phase 2)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| 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 |
|
| `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_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) |
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
## Start the servers
|
> **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).
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connect a project
|
## Connect a project
|
||||||
|
|
||||||
@@ -56,9 +67,9 @@ Create `.mcp.json` in your project root:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"supervisor": {
|
"routing": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://koala:30320/mcp"
|
"url": "http://koala:30310/mcp"
|
||||||
},
|
},
|
||||||
"brain": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
@@ -68,33 +79,29 @@ 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`,
|
- **`routing`** at `koala:30310` — skill workers (`review`, `debug`, `retrospective`, `trainer`).
|
||||||
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
|
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`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
|
||||||
`brain_ingest`, `brain_ingest_raw`) and `session_log`. Hosted by the ingestion
|
`brain_ingest`, `brain_ingest_raw`, `brain_answer`, `brain_classify`) and `session_log`.
|
||||||
service directly, no separate pod.
|
|
||||||
|
|
||||||
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
|
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.
|
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
|
1. Call review → brain context injected + local model review → findings
|
||||||
2. Call tdd_green → test path in, implementation out
|
2. Call session_log → log each phase result
|
||||||
3. Call tdd_refactor → impl + test in, cleaned code out
|
3. Call retrospective → extracts learnings → brain
|
||||||
4. Call session_log → log each phase result
|
4. Future sessions: call brain_query / brain_answer to retrieve relevant context
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tier detection
|
## Tier detection
|
||||||
|
|
||||||
The supervisor probes connectivity at call time:
|
The routing pod probes connectivity at call time:
|
||||||
|
|
||||||
| Tier | Label | Condition |
|
| Tier | Label | Condition |
|
||||||
|------|-------|-----------|
|
|------|-------|-----------|
|
||||||
@@ -102,31 +109,47 @@ The supervisor probes connectivity at call time:
|
|||||||
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
||||||
| 3 | airplane | No external connectivity |
|
| 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
|
## Key env vars
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
||||||
| `INGEST_PORT` | `3300` | Ingestion server port |
|
| `INGEST_PORT` | `3300` | Ingestion server port |
|
||||||
| `SUPERVISOR_CONFIG_DIR` | `./config/supervisor` | Skill discipline files |
|
| `INGEST_BASE_URL` | `http://localhost:3300` | Routing pod → brain |
|
||||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
|
||||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
|
||||||
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
| `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_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) |
|
| `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_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_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_FLOOR` | `0.90` | Fast model threshold |
|
||||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
|
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Thinking model threshold |
|
||||||
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
|
| `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
|
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||||
- `debug` skill — hypothesis-driven debugging sessions
|
|
||||||
- `spec` skill — generates specs from conversations
|
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||||
- `trainer` — extracts SFT/DPO pairs from session logs for fine-tuning
|
- **#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
|
||||||
|
|||||||
+2
-4
@@ -17,8 +17,6 @@ tasks:
|
|||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
context:sync:agents:
|
context:sync:agents:
|
||||||
cmds: [bash scripts/context-sync.sh agents]
|
cmds: [bash scripts/context-sync.sh agents]
|
||||||
context:sync:cursor:
|
|
||||||
cmds: [bash scripts/context-sync.sh cursor]
|
|
||||||
|
|
||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -90,12 +88,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- task: context:sync
|
- task: context:sync
|
||||||
- cmd: |
|
- 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
|
if [ -n "$drift" ]; then
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
echo "$drift" >&2
|
echo "$drift" >&2
|
||||||
echo "" >&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
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -5,6 +5,15 @@ FROM golang:1.26-bookworm AS builder
|
|||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
WORKDIR /src
|
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
|||||||
+202
-11
@@ -12,11 +12,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
|
"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/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/llm"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
@@ -25,6 +35,50 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// claudeSink converts each claudewatcher.Batch into one wiki note under
|
||||||
|
// brain/wiki/claude-sessions/facts/. v1 emits one note per session
|
||||||
|
// keyed by host + session id; classifier-driven hall routing is a
|
||||||
|
// follow-up (hyperguild#27 v2).
|
||||||
|
type claudeSink struct {
|
||||||
|
brainDir string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
||||||
|
if len(b.Turns) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# Claude session %s (%s)\n\n", b.SessionID, b.Host)
|
||||||
|
fmt.Fprintf(&sb, "_Project: `%s`. File: `%s`. Turns: %d._\n\n", b.ProjectID, b.FilePath, len(b.Turns))
|
||||||
|
for _, t := range b.Turns {
|
||||||
|
fmt.Fprintf(&sb, "## %s — %s\n\n", t.Type, t.Timestamp.UTC().Format(time.RFC3339))
|
||||||
|
if t.ToolName != "" {
|
||||||
|
fmt.Fprintf(&sb, "_tool: `%s`_\n\n", t.ToolName)
|
||||||
|
}
|
||||||
|
// Cap per-turn excerpt to keep page size bounded; the full
|
||||||
|
// transcript lives on disk under ~/.claude/projects/ already.
|
||||||
|
content := t.Content
|
||||||
|
if len(content) > 2000 {
|
||||||
|
content = content[:2000] + "…"
|
||||||
|
}
|
||||||
|
sb.WriteString(content)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
slug := "session-" + b.Host + "-" + b.SessionID
|
||||||
|
if _, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
|
Filename: slug,
|
||||||
|
Wing: "claude-sessions",
|
||||||
|
Hall: "facts",
|
||||||
|
Type: "source",
|
||||||
|
Domain: b.ProjectID,
|
||||||
|
Content: sb.String(),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("write claude session note: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// redactDSN parses a Postgres URL and replaces its password with `***`
|
// redactDSN parses a Postgres URL and replaces its password with `***`
|
||||||
// for safe inclusion in logs. Falls back to a non-leaking placeholder
|
// for safe inclusion in logs. Falls back to a non-leaking placeholder
|
||||||
// if parsing fails — we never log a raw DSN.
|
// if parsing fails — we never log a raw DSN.
|
||||||
@@ -69,6 +123,28 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
@@ -116,6 +192,15 @@ func main() {
|
|||||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
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
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
// be set together for the path to wire on; otherwise BM25-only.
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
var vectorStore *vectorstore.PGStore
|
var vectorStore *vectorstore.PGStore
|
||||||
@@ -140,6 +225,32 @@ func main() {
|
|||||||
logger.Info("brain hybrid retrieval enabled",
|
logger.Info("brain hybrid retrieval enabled",
|
||||||
"pg", redactDSN(pgDSN),
|
"pg", redactDSN(pgDSN),
|
||||||
"embed_url", embedURL, "embed_model", embedModel)
|
"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 == "":
|
case pgDSN == "" && embedURL == "":
|
||||||
// disabled — fine
|
// disabled — fine
|
||||||
default:
|
default:
|
||||||
@@ -161,6 +272,56 @@ func main() {
|
|||||||
Pipeline: pipelineCfg,
|
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)
|
||||||
|
}
|
||||||
if vectorStore != nil {
|
if vectorStore != nil {
|
||||||
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||||
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||||
@@ -180,16 +341,13 @@ func main() {
|
|||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
var jwtValidator *auth.Validator
|
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
||||||
audience := os.Getenv("MCP_AUDIENCE")
|
|
||||||
v, err := auth.NewValidator(dexURL, audience)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("build jwt validator", "err", err)
|
logger.Error("build jwt validator", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
jwtValidator = v
|
if jwtValidator != nil {
|
||||||
logger.Info("jwt auth enabled", "issuer", dexURL)
|
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
@@ -199,13 +357,37 @@ func main() {
|
|||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
if resourceURL != "" {
|
if resourceURL != "" {
|
||||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
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): 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 slog
|
||||||
|
// audit sink (the loki+buffer sink lands in #54). 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)
|
||||||
|
}
|
||||||
|
captureSvc := capture.NewService(
|
||||||
|
mcpSrv.BrainStore(), tracker, nil, classCfg, audit.NewSlogSink(logger))
|
||||||
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli",
|
||||||
|
capturehttp.NewOriginResolver(sovereign))
|
||||||
|
mux.Handle("POST /capture", captureH)
|
||||||
|
logger.Info("capture endpoint enabled", "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
|
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||||
// integration UI, which has no static-Bearer field. Setting both
|
// integration UI, which has no static-Bearer field. Setting both
|
||||||
@@ -235,6 +417,15 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
addr := ":" + port
|
||||||
watchIntervalLog := "disabled"
|
watchIntervalLog := "disabled"
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
@@ -249,7 +440,7 @@ func main() {
|
|||||||
"watch_interval", watchIntervalLog,
|
"watch_interval", watchIntervalLog,
|
||||||
"mcp_enabled", true,
|
"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)
|
logger.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||||
|
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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,48 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlogSink records audit entries to an slog.Logger. It never fails, so it
|
||||||
|
// does not exercise the I5 floor (refuse-if-unauditable) — that is #54's
|
||||||
|
// loki+buffer sink. A nil logger falls back to 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) 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"},
|
||||||
|
})
|
||||||
|
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{})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,143 @@
|
|||||||
|
// 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,
|
||||||
|
// optional summary → ai-sessions — 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary is an optional session summary bound for ai-sessions.
|
||||||
|
type Summary struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
ReposTouched []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureInput is the whole capture request.
|
||||||
|
type CaptureInput struct {
|
||||||
|
Context CaptureContext
|
||||||
|
Insights []Insight
|
||||||
|
Tickets []Ticket
|
||||||
|
Summary *Summary
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryResult is the summary outcome in the receipt.
|
||||||
|
type SummaryResult struct {
|
||||||
|
Path string `json:"path,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"`
|
||||||
|
Summary *SummaryResult `json:"summary,omitempty"`
|
||||||
|
Errors []ItemError `json:"errors"`
|
||||||
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryWriter is the ai-sessions summary port.
|
||||||
|
type SummaryWriter interface {
|
||||||
|
WriteFile(ctx context.Context, repo, path, content string) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditSink records the audit entry. The classification-aware
|
||||||
|
// degradation/refusal policy (confidential fails closed, internal
|
||||||
|
// degrades) is the caller's concern in #54; this port just records.
|
||||||
|
type AuditSink interface {
|
||||||
|
Record(ctx context.Context, e AuditEntry) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"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
|
||||||
|
summaries SummaryWriter
|
||||||
|
policy ClassificationPolicy
|
||||||
|
audit AuditSink
|
||||||
|
|
||||||
|
// now is the clock, injectable for deterministic summary paths and
|
||||||
|
// audit timestamps in tests.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service from its ports. summaries may be nil
|
||||||
|
// when no summary persistence is wired; a CaptureInput with a Summary
|
||||||
|
// then fails that item rather than panicking.
|
||||||
|
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
return &Service{brain: b, issues: tr, summaries: sw, 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")
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
})
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
if in.Summary != nil {
|
||||||
|
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Summary != nil {
|
||||||
|
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
||||||
|
receipt.Summary = &res
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
||||||
|
} else {
|
||||||
|
landed = append(landed, "summary:"+res.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// I5: emit a request-level audit record of exactly what landed.
|
||||||
|
// Best-effort here; the classification-aware refusal/degradation
|
||||||
|
// policy is #54.
|
||||||
|
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,
|
||||||
|
}); err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if in.Summary != nil {
|
||||||
|
for _, repo := range in.Summary.ReposTouched {
|
||||||
|
consider(classification.RepoTarget, 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 (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
||||||
|
if s.summaries == nil {
|
||||||
|
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
||||||
|
}
|
||||||
|
path := s.summaryPath(c, sum)
|
||||||
|
content := s.renderSummary(c, sum)
|
||||||
|
repo := "ai-sessions"
|
||||||
|
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
||||||
|
return SummaryResult{Path: path, OK: false}, err
|
||||||
|
}
|
||||||
|
return SummaryResult{Path: path, OK: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
||||||
|
// The ref8 disambiguator is derived from the session_ref (or the title
|
||||||
|
// when no ref is present) so distinct sessions never collide.
|
||||||
|
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
||||||
|
t := s.now().UTC()
|
||||||
|
slug := brain.Sanitise(sum.Title)
|
||||||
|
if slug == "" {
|
||||||
|
slug = "summary"
|
||||||
|
}
|
||||||
|
seed := c.SessionRef
|
||||||
|
if seed == "" {
|
||||||
|
seed = sum.Title + sum.Body
|
||||||
|
}
|
||||||
|
sum8 := shortHash(seed)
|
||||||
|
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
||||||
|
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
||||||
|
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
||||||
|
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("---\n")
|
||||||
|
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
||||||
|
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
||||||
|
if c.SessionRef != "" {
|
||||||
|
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
||||||
|
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
||||||
|
if len(sum.ReposTouched) > 0 {
|
||||||
|
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
||||||
|
}
|
||||||
|
b.WriteString("---\n\n")
|
||||||
|
b.WriteString(sum.Body)
|
||||||
|
if !strings.HasSuffix(sum.Body, "\n") {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortHash(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])[:8]
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSummary struct {
|
||||||
|
paths []string
|
||||||
|
content []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.paths = append(f.paths, path)
|
||||||
|
f.content = append(f.content, content)
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.entries = append(f.entries, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
s := NewService(b, tr, sw, 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, nil, 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{}, nil, 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, nil, 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{}, nil, 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, nil, 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, nil, 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{}, nil, 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{}, nil, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
||||||
|
sw := &fakeSummary{}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Fidelity = "transcript-parse"
|
||||||
|
ctx.SessionRef = "abc123def456"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, rec.Summary)
|
||||||
|
assert.True(t, rec.Summary.OK)
|
||||||
|
require.Len(t, sw.paths, 1)
|
||||||
|
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
||||||
|
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||||
|
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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, nil, 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{}, nil, 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{}, nil, 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{}, nil, 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// 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"
|
||||||
|
"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"`
|
||||||
|
Summary *summaryBody `json:"summary,omitempty"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type summaryBody struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
ReposTouched []string `json:"repos_touched,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 := h.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req request
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
in := req.toInput()
|
||||||
|
// 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 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 wins, then
|
||||||
|
// JWT) but returns the resolved principal and whether the static path was
|
||||||
|
// taken — the chassis middleware hides both, and capture needs them to
|
||||||
|
// derive the origin.
|
||||||
|
func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) {
|
||||||
|
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !found || raw == "" {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
|
||||||
|
return h.staticPrincipal, true, true
|
||||||
|
}
|
||||||
|
if h.validator != nil {
|
||||||
|
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||||
|
return sub, false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if b.Summary != nil {
|
||||||
|
in.Summary = &capture.Summary{
|
||||||
|
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if rec.Summary != nil {
|
||||||
|
count(&ok, &fail, rec.Summary.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,178 @@
|
|||||||
|
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, nil, 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)
|
||||||
|
}
|
||||||
@@ -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,129 @@
|
|||||||
|
// 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/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
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request against the Gitea API and decodes the
|
||||||
|
// 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 {
|
||||||
|
reqBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
// Gitea's token scheme. Held here only; never logged.
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
if out != nil && len(respBody) > 0 {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"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/extract"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
@@ -60,6 +61,26 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"hall": enum("optional memory type (requires wing)", halls...),
|
"hall": enum("optional memory type (requires wing)", halls...),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_update",
|
||||||
|
"description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.",
|
||||||
|
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||||
|
"content": str("new full body (whole-note replace)"),
|
||||||
|
"slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"),
|
||||||
|
"wing": str("wing of the target (required unless slug/path is a full path)"),
|
||||||
|
"hall": enum("hall of the target (required unless slug/path is a full path)", halls...),
|
||||||
|
"path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"),
|
||||||
|
"reason": str("optional short note on why superseded — stamped into frontmatter"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_get",
|
||||||
|
"description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.",
|
||||||
|
"inputSchema": schema([]string{}, map[string]any{
|
||||||
|
"id": str("note id (brain-relative path) as returned by brain_write/brain_update"),
|
||||||
|
"path": str("brain-relative path to the note; equivalent to id"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_tunnel",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
@@ -108,6 +129,32 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"text": str("raw document text to classify (first 3000 chars used)"),
|
"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",
|
"name": "session_log",
|
||||||
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||||
@@ -172,7 +219,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.WriteNote(s.brainDir, 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,
|
Content: a.Content,
|
||||||
Filename: a.Filename,
|
Filename: a.Filename,
|
||||||
Type: a.Type,
|
Type: a.Type,
|
||||||
@@ -183,18 +234,97 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Auto-regenerate the wing _index.md when the write landed in the
|
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||||
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
}
|
||||||
// best-effort: the note is already written.
|
|
||||||
if a.Wing != "" && a.Hall != "" {
|
type brainUpdateArgs struct {
|
||||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
Slug string `json:"slug,omitempty"`
|
||||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
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 err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
if a.Content == "" {
|
||||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]string{"path": relPath})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainTunnelArgs struct {
|
type brainTunnelArgs struct {
|
||||||
@@ -213,6 +343,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 {
|
if err := brain.WriteTunnel(s.brainDir, a.Source, a.Target); err != nil {
|
||||||
return nil, fmt.Errorf("tunnel: %w", err)
|
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"})
|
return json.Marshal(map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +400,11 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
|||||||
if warnings == nil {
|
if warnings == nil {
|
||||||
warnings = []string{}
|
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})
|
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +495,11 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
|||||||
if pages == nil {
|
if pages == nil {
|
||||||
pages = []string{}
|
pages = []string{}
|
||||||
}
|
}
|
||||||
|
if !dryRun {
|
||||||
|
for _, p := range pages {
|
||||||
|
s.indexInGraph(ctx, "brain_ingest", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
warnings := result.Warnings
|
warnings := result.Warnings
|
||||||
if warnings == nil {
|
if warnings == nil {
|
||||||
warnings = []string{}
|
warnings = []string{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, session_log.
|
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
||||||
|
// brain_answer, brain_classify, brain_graph, brain_context, session_log.
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,6 +10,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"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/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -42,6 +47,9 @@ type Server struct {
|
|||||||
reranker *reranker.Client // nil = no rerank, BM25 top-10 → LLM
|
reranker *reranker.Client // nil = no rerank, BM25 top-10 → LLM
|
||||||
vector search.VectorSearcher // nil = BM25-only retrieval
|
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||||
embedder search.Embedder // 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
@@ -52,7 +60,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl
|
|||||||
if pipelineCfg != nil {
|
if pipelineCfg != nil {
|
||||||
cfg = *pipelineCfg
|
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,
|
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||||
@@ -73,6 +87,42 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
|||||||
return s
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
@@ -160,6 +210,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainQuery(ctx, args)
|
return s.brainQuery(ctx, args)
|
||||||
case "brain_write":
|
case "brain_write":
|
||||||
return s.brainWrite(ctx, args)
|
return s.brainWrite(ctx, args)
|
||||||
|
case "brain_update":
|
||||||
|
return s.brainUpdate(ctx, args)
|
||||||
|
case "brain_get":
|
||||||
|
return s.brainGet(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
@@ -174,6 +228,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainAnswer(ctx, args)
|
return s.brainAnswer(ctx, args)
|
||||||
case "brain_classify":
|
case "brain_classify":
|
||||||
return s.brainClassify(ctx, args)
|
return s.brainClassify(ctx, args)
|
||||||
|
case "brain_graph":
|
||||||
|
return s.brainGraph(ctx, args)
|
||||||
|
case "brain_context":
|
||||||
|
return s.brainContext(ctx, args)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package mcp_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -55,9 +57,11 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
names = append(names, t.(map[string]any)["name"].(string))
|
names = append(names, t.(map[string]any)["name"].(string))
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
|
"brain_index", "brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "session_log",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
|
"session_log",
|
||||||
}, names)
|
}, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +95,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) {
|
|||||||
assert.Equal(t, float64(-32601), errObj["code"])
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
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)
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
}
|
}
|
||||||
if s.reranker != nil && len(results) > 0 {
|
if s.reranker != nil && len(results) > 0 {
|
||||||
results, err = rerankResults(ctx, s.reranker, a.Query, results, 5)
|
reranked, rerr := rerankResults(ctx, s.reranker, a.Query, results, 5)
|
||||||
if err != nil {
|
if rerr != nil {
|
||||||
return nil, fmt.Errorf("rerank: %w", err)
|
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 {
|
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)
|
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)
|
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("llm: %w", err)
|
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 {
|
type brainClassifyArgs struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,42 @@ func TestBrainAnswer_RerankerFiltersBeforeLLM(t *testing.T) {
|
|||||||
assert.NotContains(t, sawSources, "noise.md")
|
assert.NotContains(t, sawSources, "noise.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainAnswer_RerankerKeepsNone_FallsBackToBM25(t *testing.T) {
|
||||||
|
brainDir := brainDirWithContent(t) // test.md BM25-matches "pass-rate logging"
|
||||||
|
|
||||||
|
// Reranker rejects every candidate ("no" to all) — models a
|
||||||
|
// web-search cross-encoder facing a conversational / personal-intent
|
||||||
|
// query, which is exactly when it wrongly scores on-topic notes as
|
||||||
|
// irrelevant. The answer must still synthesize from the BM25 hits, not
|
||||||
|
// collapse to "no relevant content".
|
||||||
|
rrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"response": "no", "done": true})
|
||||||
|
}))
|
||||||
|
defer rrSrv.Close()
|
||||||
|
|
||||||
|
var sawSources string
|
||||||
|
llm := func(_ context.Context, _, user string) (string, error) {
|
||||||
|
sawSources = user
|
||||||
|
return "fallback answer", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, llm).
|
||||||
|
WithReranker(reranker.New(rrSrv.URL, "qwen3"))
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "pass-rate logging"})
|
||||||
|
require.Nil(t, rpc["error"])
|
||||||
|
|
||||||
|
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||||
|
|
||||||
|
assert.Equal(t, "fallback answer", result["answer"])
|
||||||
|
assert.NotEmpty(t, result["sources"], "reranker keeping nothing must fall back to BM25, not empty")
|
||||||
|
assert.Contains(t, sawSources, "test.md")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainAnswer_NoLLM(t *testing.T) {
|
func TestBrainAnswer_NoLLM(t *testing.T) {
|
||||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
ts := httptest.NewServer(srv)
|
ts := httptest.NewServer(srv)
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// brainContextArgs is the input shape of brain_context. project_root is
|
||||||
|
// required; recent_files biases ranking when provided; limit caps the
|
||||||
|
// returned set (default 10).
|
||||||
|
type brainContextArgs struct {
|
||||||
|
ProjectRoot string `json:"project_root"`
|
||||||
|
RecentFiles []string `json:"recent_files,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextEntry is one returned brain entry: the slug, its title,
|
||||||
|
// frontmatter-stripped excerpt, source (bm25|graph), and a final score
|
||||||
|
// used for ranking before truncation to Limit.
|
||||||
|
type contextEntry struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DocPath string `json:"doc_path"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
EdgeType string `json:"edge_type"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainContext returns top-N brain entries relevant to a project context.
|
||||||
|
// It runs a BM25 query against the project name, takes the top-3 hits as
|
||||||
|
// seeds, expands each seed 2 hops in the brain graph (when configured),
|
||||||
|
// then merges and deduplicates by slug. recent_files optionally boosts
|
||||||
|
// entries whose doc_path matches a recent file basename.
|
||||||
|
func (s *Server) brainContext(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainContextArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.ProjectRoot == "" {
|
||||||
|
return nil, fmt.Errorf("project_root is required")
|
||||||
|
}
|
||||||
|
limit := a.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
projectName := filepath.Base(strings.TrimRight(a.ProjectRoot, "/"))
|
||||||
|
if projectName == "" || projectName == "." || projectName == "/" {
|
||||||
|
return nil, fmt.Errorf("project_root has no usable basename: %q", a.ProjectRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed BM25 hits on the project name. Take top-3 as graph expansion seeds.
|
||||||
|
bm25, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
|
||||||
|
Query: projectName,
|
||||||
|
Limit: 3,
|
||||||
|
Vector: s.vector,
|
||||||
|
Embedder: s.embedder,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup by slug while merging BM25 hits and graph neighbours.
|
||||||
|
bySlug := make(map[string]*contextEntry)
|
||||||
|
// BM25 score: highest rank gets the largest score, decaying linearly.
|
||||||
|
// Score 3.0 / 2.0 / 1.0 for ranks 0/1/2 respectively.
|
||||||
|
for i, r := range bm25 {
|
||||||
|
slug := slugFromPath(r.Path)
|
||||||
|
if slug == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := float64(len(bm25) - i)
|
||||||
|
bySlug[slug] = &contextEntry{
|
||||||
|
Slug: slug,
|
||||||
|
Title: r.Title,
|
||||||
|
DocPath: r.Path,
|
||||||
|
Excerpt: truncateExcerpt(r.Excerpt, 200),
|
||||||
|
EdgeType: "bm25",
|
||||||
|
Score: score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph expansion: for each BM25 hit, fetch its 2-hop subgraph and
|
||||||
|
// merge those neighbours in with a graph score that decays with hop
|
||||||
|
// distance. Failures are silently dropped — graph augmentation is
|
||||||
|
// best-effort.
|
||||||
|
if reader, ok := s.graph.(graphReader); ok {
|
||||||
|
for _, r := range bm25 {
|
||||||
|
seed := slugFromPath(r.Path)
|
||||||
|
if seed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ns, gerr := reader.Subgraph(ctx, seed, 2)
|
||||||
|
if gerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, n := range ns {
|
||||||
|
if n.Slug == "" || n.Slug == seed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Graph score: closer hops carry more signal. Distance 1
|
||||||
|
// scores 0.6, distance 2 scores 0.3.
|
||||||
|
gscore := 0.6 / float64(max1(n.Distance))
|
||||||
|
if existing, ok := bySlug[n.Slug]; ok {
|
||||||
|
// Already surfaced via BM25 — bump its score so that
|
||||||
|
// BM25 + graph evidence outranks BM25-only hits.
|
||||||
|
existing.Score += gscore
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySlug[n.Slug] = &contextEntry{
|
||||||
|
Slug: n.Slug,
|
||||||
|
Title: n.Title,
|
||||||
|
DocPath: n.DocPath,
|
||||||
|
Excerpt: readExcerpt(s.brainDir, n.DocPath, 200),
|
||||||
|
EdgeType: "graph",
|
||||||
|
Score: gscore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional recent_files boost: +1 to entries whose doc_path basename
|
||||||
|
// matches any recent file basename. v1 is intentionally simple.
|
||||||
|
if len(a.RecentFiles) > 0 {
|
||||||
|
recent := make(map[string]struct{}, len(a.RecentFiles))
|
||||||
|
for _, f := range a.RecentFiles {
|
||||||
|
recent[filepath.Base(f)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, e := range bySlug {
|
||||||
|
if _, hit := recent[filepath.Base(e.DocPath)]; hit {
|
||||||
|
e.Score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten and sort by score desc, slug asc as a stable tiebreaker.
|
||||||
|
entries := make([]contextEntry, 0, len(bySlug))
|
||||||
|
for _, e := range bySlug {
|
||||||
|
entries = append(entries, *e)
|
||||||
|
}
|
||||||
|
sort.SliceStable(entries, func(i, j int) bool {
|
||||||
|
if entries[i].Score != entries[j].Score {
|
||||||
|
return entries[i].Score > entries[j].Score
|
||||||
|
}
|
||||||
|
return entries[i].Slug < entries[j].Slug
|
||||||
|
})
|
||||||
|
if len(entries) > limit {
|
||||||
|
entries = entries[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(map[string]any{"entries": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateExcerpt clamps an already-stripped excerpt to maxLen characters
|
||||||
|
// without re-running the frontmatter parser. The ellipsis suffix matches
|
||||||
|
// the convention used in search.excerpt.
|
||||||
|
func truncateExcerpt(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// readExcerpt loads a doc relative to brainDir, strips its frontmatter,
|
||||||
|
// and returns the first maxLen chars. Returns "" on any error — the
|
||||||
|
// excerpt is informational, not load-bearing for correctness.
|
||||||
|
func readExcerpt(brainDir, relPath string, maxLen int) string {
|
||||||
|
if relPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||||
|
content, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(content), "---", 3)
|
||||||
|
body := string(content)
|
||||||
|
if len(parts) == 3 {
|
||||||
|
body = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
if len(body) > maxLen {
|
||||||
|
return body[:maxLen] + "…"
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// max1 returns the maximum of n and 1, used to guard against divide-by-zero
|
||||||
|
// on graph distance and to give self-references (distance 0) a sensible
|
||||||
|
// score instead of an infinity.
|
||||||
|
func max1(n int) int {
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGraph implements graphsync.Store + graphReader so it can be
|
||||||
|
// assigned to Server.graph and downcast by brainContext. Only Subgraph
|
||||||
|
// is exercised by brain_context today; the rest are no-op satisfiers.
|
||||||
|
type fakeGraph struct {
|
||||||
|
subgraph map[string][]graphstore.Neighbor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGraph) UpsertEntity(_ context.Context, _ graph.Entity) error { return nil }
|
||||||
|
func (f *fakeGraph) ReplaceEdgesForDoc(_ context.Context, _ string, _ []graph.Edge) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeGraph) DeleteByDoc(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
|
func (f *fakeGraph) Neighbors(_ context.Context, slug, _ string, _ int) ([]graphstore.Neighbor, error) {
|
||||||
|
return f.subgraph[slug], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGraph) Subgraph(_ context.Context, origin string, _ int) ([]graphstore.Neighbor, error) {
|
||||||
|
return f.subgraph[origin], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGraph) Path(_ context.Context, _, _ string, _ int) ([]graphstore.PathStep, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNote(t *testing.T, brainDir, relPath, title, body string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
content := "---\ntitle: " + title + "\n---\n\n" + body
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// callContext runs brainContext directly and decodes the JSON response.
|
||||||
|
func callContext(t *testing.T, s *Server, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
out, err := s.brainContext(context.Background(), raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &resp))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedSlugs(entries []any) []string {
|
||||||
|
slugs := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
slugs = append(slugs, e.(map[string]any)["slug"].(string))
|
||||||
|
}
|
||||||
|
sort.Strings(slugs)
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_RejectsMissingProjectRoot(t *testing.T) {
|
||||||
|
s := NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
_, err := s.brainContext(context.Background(), json.RawMessage(`{}`))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_RejectsUnusableBasename(t *testing.T) {
|
||||||
|
s := NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
_, err := s.brainContext(context.Background(), json.RawMessage(`{"project_root":"/"}`))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_BM25Only_NoGraph(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
// Two notes whose body contains the hyphenated project name. BM25
|
||||||
|
// uses literal substring matching after whitespace tokenisation, so
|
||||||
|
// the bodies must carry "azure-tiger" verbatim, not "Azure tiger".
|
||||||
|
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-routing.md",
|
||||||
|
"Azure Tiger Routing", "azure-tiger payment routing decisions.")
|
||||||
|
writeNote(t, brainDir, "wiki/finance/facts/iso20022.md",
|
||||||
|
"Azure Tiger ISO 20022 fields", "azure-tiger maps invoice fields to ISO 20022.")
|
||||||
|
|
||||||
|
s := NewServer(brainDir, nil, nil, nil)
|
||||||
|
// graph is nil — only BM25 hits should appear.
|
||||||
|
|
||||||
|
resp := callContext(t, s, map[string]any{
|
||||||
|
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||||
|
})
|
||||||
|
entries := resp["entries"].([]any)
|
||||||
|
require.NotEmpty(t, entries, "expected at least one BM25 hit on project name")
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
entry := e.(map[string]any)
|
||||||
|
assert.Equal(t, "bm25", entry["edge_type"], "no graph configured, every entry must be BM25")
|
||||||
|
assert.NotEmpty(t, entry["slug"])
|
||||||
|
assert.NotEmpty(t, entry["doc_path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_BM25PlusGraphExpansion(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
// BM25 seed — body carries the hyphenated project name verbatim.
|
||||||
|
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-routing.md",
|
||||||
|
"Azure Tiger Routing", "azure-tiger payment routing decisions.")
|
||||||
|
// Graph neighbour — does NOT match BM25 on "azure-tiger" so it can
|
||||||
|
// only arrive via the graph subgraph traversal.
|
||||||
|
writeNote(t, brainDir, "wiki/finance/facts/sepa-clearing.md",
|
||||||
|
"SEPA Clearing", "SEPA payment clearing rules and timing windows.")
|
||||||
|
|
||||||
|
graphFake := &fakeGraph{
|
||||||
|
subgraph: map[string][]graphstore.Neighbor{
|
||||||
|
"azure-tiger-routing": {
|
||||||
|
{
|
||||||
|
Slug: "sepa-clearing",
|
||||||
|
Title: "SEPA Clearing",
|
||||||
|
DocPath: "wiki/finance/facts/sepa-clearing.md",
|
||||||
|
EdgeType: "wikilink",
|
||||||
|
Distance: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := NewServer(brainDir, nil, nil, nil)
|
||||||
|
s.graph = graphFake
|
||||||
|
|
||||||
|
resp := callContext(t, s, map[string]any{
|
||||||
|
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||||
|
})
|
||||||
|
entries := resp["entries"].([]any)
|
||||||
|
require.GreaterOrEqual(t, len(entries), 2, "expected BM25 seed plus graph neighbour")
|
||||||
|
|
||||||
|
slugs := sortedSlugs(entries)
|
||||||
|
assert.Contains(t, slugs, "azure-tiger-routing", "BM25 seed must appear")
|
||||||
|
assert.Contains(t, slugs, "sepa-clearing", "graph neighbour must appear")
|
||||||
|
|
||||||
|
// Verify the graph-only entry carries edge_type="graph".
|
||||||
|
var sepaEntry map[string]any
|
||||||
|
for _, e := range entries {
|
||||||
|
m := e.(map[string]any)
|
||||||
|
if m["slug"] == "sepa-clearing" {
|
||||||
|
sepaEntry = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, sepaEntry)
|
||||||
|
assert.Equal(t, "graph", sepaEntry["edge_type"])
|
||||||
|
assert.NotEmpty(t, sepaEntry["excerpt"], "excerpt should be loaded from disk for graph neighbours")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_LimitClamps(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
// Five notes all matching "azure-tiger".
|
||||||
|
for i, name := range []string{"a", "b", "c", "d", "e"} {
|
||||||
|
writeNote(t, brainDir,
|
||||||
|
"wiki/finance/decisions/azure-tiger-"+name+".md",
|
||||||
|
"Azure Tiger "+name,
|
||||||
|
"azure-tiger note "+name+" with index "+string(rune('0'+i)))
|
||||||
|
}
|
||||||
|
s := NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := callContext(t, s, map[string]any{
|
||||||
|
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||||
|
"limit": 2,
|
||||||
|
})
|
||||||
|
entries := resp["entries"].([]any)
|
||||||
|
assert.LessOrEqual(t, len(entries), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_RecentFilesBoost(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
// Both notes BM25-match the project name, but azure-tiger-z has
|
||||||
|
// twice the term frequency so it naturally ranks above azure-tiger-a.
|
||||||
|
// The recent_files boost on azure-tiger-a should pull it level on
|
||||||
|
// score; the alphabetical slug tiebreaker (a < z) then promotes it
|
||||||
|
// to the top — exercising both the boost and the deterministic
|
||||||
|
// tiebreak.
|
||||||
|
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-a.md",
|
||||||
|
"A", "azure-tiger note about a.")
|
||||||
|
writeNote(t, brainDir, "wiki/finance/decisions/azure-tiger-z.md",
|
||||||
|
"Z", "azure-tiger azure-tiger note about z.")
|
||||||
|
|
||||||
|
s := NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
// Baseline ranking: azure-tiger-z must lead (higher term frequency).
|
||||||
|
baseline := callContext(t, s, map[string]any{
|
||||||
|
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||||
|
})
|
||||||
|
baselineEntries := baseline["entries"].([]any)
|
||||||
|
require.GreaterOrEqual(t, len(baselineEntries), 2)
|
||||||
|
baselineTop := baselineEntries[0].(map[string]any)
|
||||||
|
require.Equal(t, "azure-tiger-z", baselineTop["slug"],
|
||||||
|
"sanity: higher tf must rank first without a boost")
|
||||||
|
|
||||||
|
// With boost on azure-tiger-a — boosted entry must now lead.
|
||||||
|
boosted := callContext(t, s, map[string]any{
|
||||||
|
"project_root": "/home/mathias/dev/QKX/azure-tiger",
|
||||||
|
"recent_files": []string{"/some/where/azure-tiger-a.md"},
|
||||||
|
})
|
||||||
|
entries := boosted["entries"].([]any)
|
||||||
|
require.GreaterOrEqual(t, len(entries), 2)
|
||||||
|
top := entries[0].(map[string]any)
|
||||||
|
assert.Equal(t, "azure-tiger-a", top["slug"], "recent_files boost must promote the matching doc")
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphReader is the read-side surface of graphstore.PGStore the
|
||||||
|
// brain_graph handler needs. Splitting it out (vs. depending on the
|
||||||
|
// concrete *PGStore) lets tests inject a fake without standing up
|
||||||
|
// postgres, and keeps the write-side graphsync.Store interface free
|
||||||
|
// of query concerns.
|
||||||
|
type graphReader interface {
|
||||||
|
Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]graphstore.Neighbor, error)
|
||||||
|
Subgraph(ctx context.Context, origin string, depth int) ([]graphstore.Neighbor, error)
|
||||||
|
Path(ctx context.Context, src, dst string, maxDepth int) ([]graphstore.PathStep, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time check that *graphstore.PGStore satisfies graphReader.
|
||||||
|
var _ graphReader = (*graphstore.PGStore)(nil)
|
||||||
|
|
||||||
|
type brainGraphArgs struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Src string `json:"src,omitempty"`
|
||||||
|
Dst string `json:"dst,omitempty"`
|
||||||
|
EdgeType string `json:"edge_type,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Depth int `json:"depth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainGraph(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
reader, ok := s.graph.(graphReader)
|
||||||
|
if s.graph == nil || !ok {
|
||||||
|
return nil, fmt.Errorf("brain graph not configured: set BRAIN_GRAPH_ENABLED=true")
|
||||||
|
}
|
||||||
|
var a brainGraphArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch a.Op {
|
||||||
|
case "neighbors":
|
||||||
|
if a.Slug == "" {
|
||||||
|
return nil, fmt.Errorf("slug is required for op=neighbors")
|
||||||
|
}
|
||||||
|
ns, err := reader.Neighbors(ctx, a.Slug, a.EdgeType, a.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("neighbors: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"results": neighborsView(ns)})
|
||||||
|
|
||||||
|
case "subgraph":
|
||||||
|
if a.Slug == "" {
|
||||||
|
return nil, fmt.Errorf("slug is required for op=subgraph")
|
||||||
|
}
|
||||||
|
ns, err := reader.Subgraph(ctx, a.Slug, a.Depth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("subgraph: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"results": neighborsView(ns)})
|
||||||
|
|
||||||
|
case "path":
|
||||||
|
if a.Src == "" || a.Dst == "" {
|
||||||
|
return nil, fmt.Errorf("src and dst are required for op=path")
|
||||||
|
}
|
||||||
|
steps, err := reader.Path(ctx, a.Src, a.Dst, a.Depth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("path: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"steps": pathView(steps)})
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown op %q (want neighbors|subgraph|path)", a.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type neighborView struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Wing string `json:"wing,omitempty"`
|
||||||
|
Hall string `json:"hall,omitempty"`
|
||||||
|
DocPath string `json:"doc_path,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
EdgeType string `json:"edge_type"`
|
||||||
|
Distance int `json:"distance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func neighborsView(ns []graphstore.Neighbor) []neighborView {
|
||||||
|
out := make([]neighborView, 0, len(ns))
|
||||||
|
for _, n := range ns {
|
||||||
|
out = append(out, neighborView{
|
||||||
|
Slug: n.Slug, Type: n.Type, Wing: n.Wing, Hall: n.Hall,
|
||||||
|
DocPath: n.DocPath, Title: n.Title,
|
||||||
|
EdgeType: n.EdgeType, Distance: n.Distance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathStepView struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
EdgeType string `json:"edge_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathView(steps []graphstore.PathStep) []pathStepView {
|
||||||
|
out := make([]pathStepView, 0, len(steps))
|
||||||
|
for _, s := range steps {
|
||||||
|
out = append(out, pathStepView{From: s.FromSlug, To: s.ToSlug, EdgeType: s.EdgeType})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Package metrics is a tiny Prometheus exposition layer.
|
||||||
|
//
|
||||||
|
// Hand-rolled rather than pulling in github.com/prometheus/client_golang
|
||||||
|
// to keep ingestion's dependency surface minimal (stdlib + jwx + testify
|
||||||
|
// per the repo CLAUDE.md). The single histogram + counter it emits cover
|
||||||
|
// the canary alert wired in k3s/apps/monitoring/ — see infra#50.
|
||||||
|
//
|
||||||
|
// Wire format follows the OpenMetrics text exposition that
|
||||||
|
// kube-prometheus-stack scrapes by default.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// histogram buckets in seconds. Tuned for in-cluster HTTP API
|
||||||
|
// latencies: BM25 query is sub-10ms, hybrid retrieval + LLM-synthesis
|
||||||
|
// can run into seconds. +Inf catch-all is implicit.
|
||||||
|
var defaultBuckets = []float64{
|
||||||
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry holds one histogram (request latency) labeled by path + status
|
||||||
|
// and one counter (request total) with the same labels. Concurrent-safe.
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
series map[labelKey]*series
|
||||||
|
buckets []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelKey struct{ path, status string }
|
||||||
|
|
||||||
|
type series struct {
|
||||||
|
// One atomic counter per bucket (counts of observations ≤ bucket).
|
||||||
|
// counts[len(buckets)] = +Inf bucket (== total observations).
|
||||||
|
counts []atomic.Uint64
|
||||||
|
sumNs atomic.Uint64 // sum of durations in nanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Registry pre-populated with no series; the first
|
||||||
|
// observation per (path, status) lazy-creates one.
|
||||||
|
func New() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
series: make(map[labelKey]*series),
|
||||||
|
buckets: defaultBuckets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe records a single request duration for the given path + status.
|
||||||
|
func (r *Registry) Observe(path, status string, d time.Duration) {
|
||||||
|
key := labelKey{path: path, status: status}
|
||||||
|
|
||||||
|
r.mu.RLock()
|
||||||
|
s := r.series[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
r.mu.Lock()
|
||||||
|
s = r.series[key]
|
||||||
|
if s == nil {
|
||||||
|
s = &series{counts: make([]atomic.Uint64, len(r.buckets)+1)}
|
||||||
|
r.series[key] = s
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
secs := d.Seconds()
|
||||||
|
for i, b := range r.buckets {
|
||||||
|
if secs <= b {
|
||||||
|
s.counts[i].Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +Inf bucket always increments.
|
||||||
|
s.counts[len(r.buckets)].Add(1)
|
||||||
|
s.sumNs.Add(uint64(d.Nanoseconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware wraps next, observing every request's duration + status.
|
||||||
|
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
|
||||||
|
// falling back to the URL path if no Pattern is set. Pattern keeps
|
||||||
|
// cardinality bounded (one series per route, not one per unique URL).
|
||||||
|
func (r *Registry) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(rec, req)
|
||||||
|
path := req.Pattern
|
||||||
|
if path == "" {
|
||||||
|
path = req.URL.Path
|
||||||
|
}
|
||||||
|
r.Observe(path, statusClass(rec.code), time.Since(start))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler exposes /metrics in OpenMetrics text format.
|
||||||
|
func (r *Registry) Handler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
r.write(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) write(w http.ResponseWriter) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(w, "# HELP brain_query_duration_seconds Brain HTTP API request latency in seconds.")
|
||||||
|
_, _ = fmt.Fprintln(w, "# TYPE brain_query_duration_seconds histogram")
|
||||||
|
|
||||||
|
// Sort keys for stable output (helps diffing scrape responses).
|
||||||
|
keys := make([]labelKey, 0, len(r.series))
|
||||||
|
for k := range r.series {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].path != keys[j].path {
|
||||||
|
return keys[i].path < keys[j].path
|
||||||
|
}
|
||||||
|
return keys[i].status < keys[j].status
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
s := r.series[k]
|
||||||
|
labels := fmt.Sprintf(`path=%q,status=%q`, k.path, k.status)
|
||||||
|
for i, b := range r.buckets {
|
||||||
|
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_bucket{%s,le=%q} %d\n",
|
||||||
|
labels, formatBucket(b), s.counts[i].Load())
|
||||||
|
}
|
||||||
|
// +Inf bucket
|
||||||
|
inf := s.counts[len(r.buckets)].Load()
|
||||||
|
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, inf)
|
||||||
|
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_sum{%s} %s\n",
|
||||||
|
labels, formatSeconds(s.sumNs.Load()))
|
||||||
|
_, _ = fmt.Fprintf(w, "brain_query_duration_seconds_count{%s} %d\n", labels, inf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBucket(b float64) string {
|
||||||
|
// Match Prometheus convention: no trailing zeros.
|
||||||
|
s := fmt.Sprintf("%g", b)
|
||||||
|
if !strings.ContainsAny(s, ".e") {
|
||||||
|
s = s + ".0"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSeconds(ns uint64) string {
|
||||||
|
return fmt.Sprintf("%g", float64(ns)/1e9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusClass(code int) string {
|
||||||
|
switch {
|
||||||
|
case code >= 200 && code < 300:
|
||||||
|
return "2xx"
|
||||||
|
case code >= 300 && code < 400:
|
||||||
|
return "3xx"
|
||||||
|
case code >= 400 && code < 500:
|
||||||
|
return "4xx"
|
||||||
|
case code >= 500 && code < 600:
|
||||||
|
return "5xx"
|
||||||
|
default:
|
||||||
|
return "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusRecorder captures the response code so middleware can label
|
||||||
|
// the histogram by status class without buffering the body.
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
code int
|
||||||
|
wroteHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *statusRecorder) WriteHeader(code int) {
|
||||||
|
if r.wroteHeader {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.code = code
|
||||||
|
r.wroteHeader = true
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||||
|
if !r.wroteHeader {
|
||||||
|
r.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return r.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistry_ObserveAndExpose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
// Three observations on the same series; one falls into each
|
||||||
|
// representative band.
|
||||||
|
r.Observe("/query", "2xx", 4*time.Millisecond) // ≤ 5ms
|
||||||
|
r.Observe("/query", "2xx", 20*time.Millisecond) // ≤ 25ms
|
||||||
|
r.Observe("/query", "2xx", 600*time.Millisecond) // ≤ 1s
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
mustContain := []string{
|
||||||
|
`# TYPE brain_query_duration_seconds histogram`,
|
||||||
|
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="0.005"} 1`,
|
||||||
|
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="0.025"} 2`,
|
||||||
|
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="1.0"} 3`,
|
||||||
|
`brain_query_duration_seconds_bucket{path="/query",status="2xx",le="+Inf"} 3`,
|
||||||
|
`brain_query_duration_seconds_count{path="/query",status="2xx"} 3`,
|
||||||
|
}
|
||||||
|
for _, want := range mustContain {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("missing line: %q\n--- body ---\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||||
|
t.Errorf("content-type = %q, want text/plain prefix", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_LabelsByStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
r.Observe("/query", "2xx", time.Millisecond)
|
||||||
|
r.Observe("/query", "5xx", time.Millisecond)
|
||||||
|
r.Observe("/write", "2xx", time.Millisecond)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`brain_query_duration_seconds_count{path="/query",status="2xx"} 1`,
|
||||||
|
`brain_query_duration_seconds_count{path="/query",status="5xx"} 1`,
|
||||||
|
`brain_query_duration_seconds_count{path="/write",status="2xx"} 1`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("missing %q in body:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddleware_RecordsTiming(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
}))
|
||||||
|
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/query")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposition should now include /query.
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `path="/query"`) {
|
||||||
|
t.Errorf("expected /query series, got body:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `status="2xx"`) {
|
||||||
|
t.Errorf("expected 2xx status class, got body:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusRecorder_DefaultsTo200(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("code %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,30 @@ type Result struct {
|
|||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
|
// Tier is the DIKW classification used for retrieval weighting
|
||||||
|
// (infra#72). Read from frontmatter when present, otherwise
|
||||||
|
// inferred from the parent directory.
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tierWeight maps the DIKW tier to a score multiplier applied right
|
||||||
|
// before the final truncation. Knowledge entries (focused lessons that
|
||||||
|
// age well) get boosted; inbox entries (raw captures, sessions, clips)
|
||||||
|
// get demoted. Empty / unknown tiers keep the original BM25 score
|
||||||
|
// (multiplier 1.0). See infra#72 for the failure mode this addresses:
|
||||||
|
// short focused entries lose to long aggregate dump-files under
|
||||||
|
// raw BM25 ranking.
|
||||||
|
func tierWeight(tier string) float64 {
|
||||||
|
switch tier {
|
||||||
|
case "knowledge":
|
||||||
|
return 1.5
|
||||||
|
case "note":
|
||||||
|
return 1.0
|
||||||
|
case "inbox":
|
||||||
|
return 0.3
|
||||||
|
default:
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryOptions configures a search.
|
// QueryOptions configures a search.
|
||||||
@@ -120,6 +144,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
wing, hall := extractWingHall(string(content), rel)
|
wing, hall := extractWingHall(string(content), rel)
|
||||||
|
tier := extractTier(string(content), rel)
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Path: rel,
|
Path: rel,
|
||||||
Title: extractTitle(string(content), d.Name()),
|
Title: extractTitle(string(content), d.Name()),
|
||||||
@@ -127,6 +152,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
Score: score,
|
Score: score,
|
||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
|
Tier: tier,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -150,6 +176,15 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier-weighted final re-rank (infra#72). Knowledge tier entries
|
||||||
|
// boost ×1.5, inbox demote ×0.3, note stays at ×1.0. Applied after
|
||||||
|
// hybridMerge so RRF ranking still drives candidate generation;
|
||||||
|
// the tier weight only re-orders the merged set.
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
return float64(results[i].Score)*tierWeight(results[i].Tier) >
|
||||||
|
float64(results[j].Score)*tierWeight(results[j].Tier)
|
||||||
|
})
|
||||||
|
|
||||||
if len(results) > opts.Limit {
|
if len(results) > opts.Limit {
|
||||||
results = results[:opts.Limit]
|
results = results[:opts.Limit]
|
||||||
}
|
}
|
||||||
@@ -235,12 +270,14 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
wing, hall := extractWingHall(string(content), relPath)
|
wing, hall := extractWingHall(string(content), relPath)
|
||||||
|
tier := extractTier(string(content), relPath)
|
||||||
return Result{
|
return Result{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
Title: extractTitle(string(content), filepath.Base(relPath)),
|
Title: extractTitle(string(content), filepath.Base(relPath)),
|
||||||
Excerpt: excerpt(string(content), 300),
|
Excerpt: excerpt(string(content), 300),
|
||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
|
Tier: tier,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +306,55 @@ func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractTier reads the DIKW tier from frontmatter first, falling back
|
||||||
|
// to the path prefix mapping (infra#72). Mirrors graph.inferTierFromPath
|
||||||
|
// so the two callers stay in lockstep — frontmatter is canonical,
|
||||||
|
// path inference is the migration-window fallback.
|
||||||
|
func extractTier(content, relPath string) string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
inFrontmatter := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !inFrontmatter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key) == "tier" {
|
||||||
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "inbox", "raw", "sessions", "clips":
|
||||||
|
return "inbox"
|
||||||
|
case "notes":
|
||||||
|
return "note"
|
||||||
|
case "wiki":
|
||||||
|
// wiki/entities/ anchor pages map to knowledge (see
|
||||||
|
// graph.inferTierFromPath for the rationale).
|
||||||
|
if len(parts) >= 2 && parts[1] == "entities" {
|
||||||
|
return "knowledge"
|
||||||
|
}
|
||||||
|
return "note"
|
||||||
|
case "knowledge":
|
||||||
|
return "knowledge"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractWingHall reads wing/hall from frontmatter first, falling back to
|
// extractWingHall reads wing/hall from frontmatter first, falling back to
|
||||||
// path segments brain/wiki/<wing>/<hall>/.
|
// path segments brain/wiki/<wing>/<hall>/.
|
||||||
func extractWingHall(content, relPath string) (wing, hall string) {
|
func extractWingHall(content, relPath string) (wing, hall string) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -130,6 +131,29 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
assert.Contains(t, results[0].Excerpt, "Retry")
|
assert.Contains(t, results[0].Excerpt, "Retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearch_TierWeightingReordersResults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// A long note-tier dump mentions the keyword many times (high raw
|
||||||
|
// BM25 score); a short knowledge entry mentions it three times.
|
||||||
|
// Raw BM25 prefers the dump; tier weighting (knowledge ×1.5 vs
|
||||||
|
// note ×1.0) flips the order if the score gap is within reach.
|
||||||
|
// note raw = 5 × 2 terms = 10 hits, weight 1.0 → 10
|
||||||
|
// knowledge raw = 4 × 2 terms = 8 hits, weight 1.5 → 12 (overtakes)
|
||||||
|
noteBody := "---\ntier: note\n---\n" + strings.Repeat("scram trap. ", 5)
|
||||||
|
knowledgeBody := "---\ntier: knowledge\n---\n" + strings.Repeat("scram trap. ", 4)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "sources"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "sources", "dump.md"), []byte(noteBody), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "knowledge", "trap.md"), []byte(knowledgeBody), 0o644))
|
||||||
|
|
||||||
|
results, err := search.Query(dir, search.QueryOptions{Query: "scram trap", Limit: 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(results), 2)
|
||||||
|
assert.Equal(t, "knowledge/trap.md", results[0].Path, "knowledge tier weight should beat note tier")
|
||||||
|
assert.Equal(t, "knowledge", results[0].Tier)
|
||||||
|
assert.Equal(t, "note", results[1].Tier)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearch_WingHallScoping(t *testing.T) {
|
func TestSearch_WingHallScoping(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
for _, p := range []struct{ rel, body string }{
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vectorstore
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NumberedChunk pairs a chunk's body with the storage path it will use
|
// NumberedChunk pairs a chunk's body with the storage path it will use
|
||||||
@@ -66,6 +67,70 @@ func ChunkMarkdown(content string, maxBytes int) []string {
|
|||||||
}
|
}
|
||||||
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guarantee: no chunk exceeds maxBytes. A single heading-less,
|
||||||
|
// paragraph-less block (JSON-lines, minified content) survives the two
|
||||||
|
// passes above whole — splitAtParagraphs emits an over-budget paragraph
|
||||||
|
// rather than truncating prose. Hard-split any such chunk at line/rune
|
||||||
|
// boundaries so the embedder never rejects an over-context chunk.
|
||||||
|
final := make([]string, 0, len(out))
|
||||||
|
for _, c := range out {
|
||||||
|
if len(c) <= maxBytes {
|
||||||
|
final = append(final, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
final = append(final, hardSplit(c, maxBytes)...)
|
||||||
|
}
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardSplit slices s into pieces no larger than maxBytes, breaking at line
|
||||||
|
// boundaries where possible and otherwise mid-line at a UTF-8 rune boundary.
|
||||||
|
// Last resort for content that has neither headings nor blank-line paragraphs.
|
||||||
|
func hardSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.SplitAfter(s, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
out = append(out, runeSplit(line, maxBytes)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 && cur.Len()+len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
cur.WriteString(line)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeSplit slices s into <=maxBytes pieces without splitting a UTF-8 rune.
|
||||||
|
func runeSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
for len(s) > maxBytes {
|
||||||
|
cut := maxBytes
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
if cut == 0 { // single rune wider than the budget; emit it whole
|
||||||
|
cut = maxBytes
|
||||||
|
}
|
||||||
|
out = append(out, s[:cut])
|
||||||
|
s = s[cut:]
|
||||||
|
}
|
||||||
|
if len(s) > 0 {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ func TestChunkMarkdown_SplitsAtHeadings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChunkMarkdown_HardSplitsHeadinglessOversizedBlock(t *testing.T) {
|
||||||
|
// A document with no headings and no blank-line paragraph breaks (e.g.
|
||||||
|
// JSON-lines like wiki/telos/decisions/human-intent-column.md). The old
|
||||||
|
// chunker emitted it as one over-budget chunk → nomic-embed returned
|
||||||
|
// "input length exceeds the context length" (400). Every chunk must now
|
||||||
|
// fit the budget, with no content lost.
|
||||||
|
maxBytes := 200
|
||||||
|
src := strings.Repeat("x", 1000) // one 1000-byte blob, no headings, no \n\n
|
||||||
|
out := vectorstore.ChunkMarkdown(src, maxBytes)
|
||||||
|
|
||||||
|
require.Greater(t, len(out), 1, "oversized blob must be split")
|
||||||
|
for i, c := range out {
|
||||||
|
assert.LessOrEqual(t, len(c), maxBytes, "chunk %d over budget: %d bytes", i, len(c))
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1000, strings.Count(strings.Join(out, ""), "x"), "no content lost")
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
||||||
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
||||||
src := "## big\n\n" +
|
src := "## big\n\n" +
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
type RoutingConfig struct {
|
type RoutingConfig struct {
|
||||||
Port string // ROUTING_PORT, default 3210
|
Port string // ROUTING_PORT, default 3210
|
||||||
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://piguard:4000
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||||
@@ -41,7 +41,7 @@ func LoadRouting() (RoutingConfig, error) {
|
|||||||
cfg := RoutingConfig{
|
cfg := RoutingConfig{
|
||||||
Port: envOr("ROUTING_PORT", "3210"),
|
Port: envOr("ROUTING_PORT", "3210"),
|
||||||
MCPAuthToken: os.Getenv("ROUTING_MCP_TOKEN"),
|
MCPAuthToken: os.Getenv("ROUTING_MCP_TOKEN"),
|
||||||
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "http://piguard:4000"),
|
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "https://llm-api.d-ma.be"),
|
||||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
||||||
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestLoadRoutingDefaults(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "3210", cfg.Port)
|
assert.Equal(t, "3210", cfg.Port)
|
||||||
assert.Equal(t, "", cfg.MCPAuthToken)
|
assert.Equal(t, "", cfg.MCPAuthToken)
|
||||||
assert.Equal(t, "http://piguard:4000", cfg.LiteLLMBaseURL)
|
assert.Equal(t, "https://llm-api.d-ma.be", cfg.LiteLLMBaseURL)
|
||||||
assert.Equal(t, "http://ingestion.supervisor:3300", cfg.BrainURL)
|
assert.Equal(t, "http://ingestion.supervisor:3300", cfg.BrainURL)
|
||||||
assert.Equal(t, "koala/qwen35-9b-fast", cfg.FastModel)
|
assert.Equal(t, "koala/qwen35-9b-fast", cfg.FastModel)
|
||||||
assert.Equal(t, "iguana/gemma4-26b", cfg.ThinkingModel)
|
assert.Equal(t, "iguana/gemma4-26b", cfg.ThinkingModel)
|
||||||
|
|||||||
+3
-32
@@ -40,10 +40,9 @@ if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
|||||||
echo " Root context: $ROOT_CONTEXT"
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
else
|
else
|
||||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
||||||
# adapters (AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt)
|
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
||||||
# require the root context to regenerate correctly, so we skip them entirely
|
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
||||||
# and only regenerate CLAUDE.md (which is project-only and inherits root via
|
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
||||||
# tree walk in Claude Code itself).
|
|
||||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
||||||
echo "Syncing project context from $PROJECT_FILE..."
|
echo "Syncing project context from $PROJECT_FILE..."
|
||||||
cat "$PROJECT_FILE" > CLAUDE.md
|
cat "$PROJECT_FILE" > CLAUDE.md
|
||||||
@@ -78,30 +77,6 @@ generate_agents() {
|
|||||||
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Cursor ───────────────────────────────────────────────────
|
|
||||||
generate_cursor() {
|
|
||||||
{
|
|
||||||
echo "# Cursor rules — auto-generated"
|
|
||||||
echo "# Do not edit. Run: task context:sync"
|
|
||||||
echo ""
|
|
||||||
root_block
|
|
||||||
cat "$PROJECT_FILE"
|
|
||||||
} > .cursorrules
|
|
||||||
echo " → .cursorrules (root + project)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Aider ────────────────────────────────────────────────────
|
|
||||||
generate_aider() {
|
|
||||||
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
|
|
||||||
if [ ! -f .aider.conf.yml ]; then
|
|
||||||
cat > .aider.conf.yml << 'YAML'
|
|
||||||
read: .aider.conventions.md
|
|
||||||
auto-commits: false
|
|
||||||
YAML
|
|
||||||
fi
|
|
||||||
echo " → .aider.conventions.md (root + project)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
||||||
generate_system_prompt() {
|
generate_system_prompt() {
|
||||||
{
|
{
|
||||||
@@ -142,8 +117,6 @@ echo "Syncing project context from $PROJECT_FILE..."
|
|||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
generate_claude
|
generate_claude
|
||||||
generate_agents
|
generate_agents
|
||||||
generate_cursor
|
|
||||||
generate_aider
|
|
||||||
generate_system_prompt
|
generate_system_prompt
|
||||||
generate_mcp
|
generate_mcp
|
||||||
else
|
else
|
||||||
@@ -151,8 +124,6 @@ else
|
|||||||
case "$adapter" in
|
case "$adapter" in
|
||||||
claude) generate_claude ;;
|
claude) generate_claude ;;
|
||||||
agents) generate_agents ;;
|
agents) generate_agents ;;
|
||||||
cursor) generate_cursor ;;
|
|
||||||
aider) generate_aider ;;
|
|
||||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||||
mcp) generate_mcp ;;
|
mcp) generate_mcp ;;
|
||||||
*) echo "Unknown adapter: $adapter" ;;
|
*) echo "Unknown adapter: $adapter" ;;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
# Boot the routing binary and exercise its four tools against live deps.
|
# Boot the routing binary and exercise its four tools against live deps.
|
||||||
# Skipped when LITELLM_BASE_URL or BRAIN_URL is unreachable.
|
# Skipped when LITELLM_BASE_URL or BRAIN_URL is unreachable.
|
||||||
|
|
||||||
LITELLM_BASE_URL="${LITELLM_BASE_URL:-http://piguard:4000}"
|
LITELLM_BASE_URL="${LITELLM_BASE_URL:-https://llm-api.d-ma.be}"
|
||||||
BRAIN_URL="${BRAIN_URL:-http://koala:30330}"
|
BRAIN_URL="${BRAIN_URL:-http://koala:30330}"
|
||||||
|
|
||||||
if ! curl -sS --max-time 2 "${LITELLM_BASE_URL}/v1/models" >/dev/null 2>&1; then
|
if ! curl -sS --max-time 2 "${LITELLM_BASE_URL}/v1/models" >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: close-session
|
||||||
|
description: Disciplined end-of-session closeout for a Claude.ai chat before archiving it. Harvests the session's decisions, artifacts, and open threads and durably persists them to the brain MCP and the right Gitea repo so nothing is lost when context resets. Use this whenever the user signals they are wrapping up — phrases like "close this out", "let's wrap up", "before I archive", "session retro", "capture this before I go", "did we lose anything", or any end-of-session/handoff cue — even if they don't say the word "close". Also use when the user explicitly asks to retro, archive, or hand off a working session.
|
||||||
|
---
|
||||||
|
|
||||||
|
# close-session
|
||||||
|
|
||||||
|
Capture a finishing Claude.ai work session into durable storage before the chat is archived and its context is lost. The goal is simple and load-bearing: **after this runs, a fresh session (or another agent) can reconstruct what was decided, what was shipped, and what is still open — without the original chat.**
|
||||||
|
|
||||||
|
This skill is **batch**: one session in, findings out, done. It does not loop or re-read its own fresh output semantically (see Phase 5). Run the phases in order. Stop at any confirmation gate that says STOP.
|
||||||
|
|
||||||
|
## Operating constraints (read first)
|
||||||
|
|
||||||
|
- **Gitea owner is always `mathias`.** Never guess another owner.
|
||||||
|
- **Ground-truth at HEAD before acting.** Issue bodies and doc references rot — stale hostnames, retired services, moved endpoints. Before closing/commenting on any issue, `gitea:issue_get` it fresh. Before asserting an infra fact, verify it; do not copy it from memory or from a stale issue body.
|
||||||
|
- **Current infra truths** (verify rather than trust, but these are the known-good baseline): Gitea is `git.d-ma.be` (not `gitea.d-ma.be`). LiteLLM is `http://koala:30401/v1/` (public `https://llm-api.d-ma.be`); piguard runs NGINX Proxy Manager only — never reference `piguard:4000` or `koala:4000`. Identity provider is Authentik (Dex migration complete).
|
||||||
|
- **Side-effects need a confirmation gate.** Closing issues, committing files, and writing to the brain are all real writes. Surface exactly what will happen and get a clear yes before doing it. Reads are free; writes are gated.
|
||||||
|
- **Never fabricate.** If the session didn't produce a decision worth persisting, say so and skip that write. An empty-but-honest closeout beats an invented one.
|
||||||
|
|
||||||
|
## Phase 1 — Harvest
|
||||||
|
|
||||||
|
Reconstruct what actually happened this session from the conversation itself. Produce, in working memory:
|
||||||
|
|
||||||
|
- **Decisions taken** — what was decided and the reasoning, not just the outcome.
|
||||||
|
- **Artifacts produced** — issues filed/closed, PRs opened/merged, files committed, brain notes written, ADRs. Capture identifiers (issue numbers, PR numbers, paths, commit SHAs) as you go.
|
||||||
|
- **Open threads** — what was deferred, what's blocked, what the next session should pick up.
|
||||||
|
- **Generalizable learnings** — reusable patterns or footguns that would bite anyone again (these are brain-worthy; project status is not).
|
||||||
|
|
||||||
|
Be honest about fidelity: a long session compresses harder at the start than the end. Flag anything you're reconstructing rather than certain of.
|
||||||
|
|
||||||
|
## Phase 2 — Ground-truth Gitea state
|
||||||
|
|
||||||
|
For every repo touched this session, get its true current state before proposing any change. `gitea:repo_status` (owner `mathias`) gives branches + open PRs + protection in one call. For each issue you intend to close, comment on, or reference: `gitea:issue_get` it fresh and compare to what the session assumed. Note any drift (closed-already, body rotted, renamed) — you'll surface it in Phase 3.
|
||||||
|
|
||||||
|
Do not write anything in this phase. This is the read pass.
|
||||||
|
|
||||||
|
## Phase 3 — Confirm and act on issue changes
|
||||||
|
|
||||||
|
Present a single consolidated plan of issue actions: which to close (with closing comment), which to file (discovered-but-deferred work — token-budget gaps, recorded limitations, v2 follow-ups), which to comment on. Include the exact title/body for any new issue and the closing rationale for any close.
|
||||||
|
|
||||||
|
**GATE — STOP and get explicit confirmation before any issue write.** Issue closes and new issues are side-effects. Once confirmed, execute them (`gitea:issue_close`, `gitea:issue_create`, `gitea:issue_comment`, all owner `mathias`), correcting any rotted references you found in Phase 2 as you go.
|
||||||
|
|
||||||
|
## Phase 4 — Commit the canonical session summary
|
||||||
|
|
||||||
|
Write one summary file to `mathias/ai-sessions`, committed directly to `main` via `gitea:file_write_branch` (no PR — this repo is solo and unprotected; if branch protection is ever added, fall back to a branch + PR).
|
||||||
|
|
||||||
|
**Path:** `summaries/claudeai/<YYYY-MM>/<YYYY-MM-DD>-<topic-slug>-<chatid8>.md`
|
||||||
|
where `<chatid8>` is the first 8 chars of the chat's UUID if known, else a short stable slug. `claudeai` has no host segment — Claude.ai is Anthropic-side, not a homelab host.
|
||||||
|
|
||||||
|
**Frontmatter — the REDUCED live-capture schema.** A live close-session capture cannot populate the batch-export telemetry (token counts, message counts, duration_ms, permission_mode) — those only exist in the account export pipeline. Write only what's truthfully known, and mark fidelity so a reader (or the batch pipeline) can tell a live capture from an export:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "<concise session title>"
|
||||||
|
client: "claudeai"
|
||||||
|
interface: "claudeai-chat"
|
||||||
|
date: "<YYYY-MM-DD>"
|
||||||
|
repos_touched: [<repo slugs>]
|
||||||
|
topic_tags: [<tags>]
|
||||||
|
outcome: "<shipped|in-progress|abandoned>"
|
||||||
|
fidelity: "live-capture" # NOT an export; reconstructed live from chat
|
||||||
|
captured_by: "close-session-skill"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not invent the export-only fields. `fidelity: live-capture` is the honest signal; if the batch export later produces a richer summary for the same session, the export is source of truth and supersedes this.
|
||||||
|
|
||||||
|
**Body** (keep it reconstructable, not exhaustive):
|
||||||
|
```markdown
|
||||||
|
## One-paragraph summary
|
||||||
|
## Decisions
|
||||||
|
## Key artifacts
|
||||||
|
## Open threads
|
||||||
|
```
|
||||||
|
|
||||||
|
**GATE — STOP, show the full file (path + frontmatter + body), get explicit confirmation before committing.**
|
||||||
|
|
||||||
|
## Phase 5 — Brain orientation note (the durable "where we are" record)
|
||||||
|
|
||||||
|
Write one brain note so a fresh session can orient without the chat. This uses the `brain_update`/`brain_get` verbs (live since 2026-06).
|
||||||
|
|
||||||
|
**Target:** `wing: <domain>` (the project/topic domain, e.g. `hyperguild`, `jepa-fx`), `hall: decisions`. The note is a knowledge-type record (a decision/orientation), grouped by knowledge-type, not by interface surface.
|
||||||
|
|
||||||
|
**Batch read-after-write discipline (important — do these in order, do not interleave):**
|
||||||
|
|
||||||
|
1. **Read first, before any write.** Check whether an orientation note already exists for this wing/topic. Do your "does this already exist / what should I supersede" reads NOW, up front. BM25/keyword search and `brain_get` are immediate; semantic/vector search may lag up to ~5 min after a write, so never rely on a semantic query to find something you wrote earlier in this same run.
|
||||||
|
2. **Write or supersede:**
|
||||||
|
- **New note** → `brain_write` (wing, hall: decisions). Returns `{id, path, content_hash}`.
|
||||||
|
- **Superseding a prior orientation note** → `brain_update` (slug or path, wing, hall, content, reason). Whole-note replace; stamps `supersedes`/`updated_at`; returns `{id, path, content_hash, superseded}`. Use this instead of a second `brain_write` to the same slug — blind re-write creates duplicates/contradictions, which is the exact failure brain_update exists to prevent.
|
||||||
|
3. **Confirm it landed** via `brain_get(id)` and check the returned `content_hash` matches what the write returned. This is the read-after-write confirmation — do it with `brain_get`, never a semantic query.
|
||||||
|
|
||||||
|
**RULE: no semantic/vector brain query after the first `brain_update` in this run.** The batch shape makes this natural — read up front, write, confirm by id. If you ever find the skill wanting to semantic-search a just-superseded note, stop and flag it (that's the signal the staleness window matters and needs the synchronous-reembed follow-up).
|
||||||
|
|
||||||
|
**GATE — STOP, show the note (target wing/hall, new-vs-supersede, full content), get explicit confirmation before the brain write.**
|
||||||
|
|
||||||
|
After the note lands, if it relates to a note in another wing, create the cross-link inline with `brain_tunnel(source, target)` (idempotent; both paths brain-relative, must be in different wings). Optionally append a `session_log` entry (`session_id`, `skill: close-session`, `phase`, `final_status`) for telemetry. Both are now callable directly from Claude.ai — no Claude Code/Crush handoff needed.
|
||||||
|
|
||||||
|
## Phase 6 — Verdict
|
||||||
|
|
||||||
|
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||||
|
|
||||||
|
- **SAFE TO ARCHIVE** — list what landed (issues closed/filed with numbers, summary path, brain note id, any tunnels) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||||
|
- **NOT YET** — name the specific gate that wasn't passed or the write that failed, and what to do about it.
|
||||||
|
|
||||||
|
Never claim safe-to-archive if any gated write was declined or errored. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||||
|
|
||||||
|
## Why the gates and the batch discipline matter
|
||||||
|
|
||||||
|
The whole point is durability across a context reset. Every gate is a place where a wrong write would silently corrupt the record (close the wrong issue, overwrite a good brain note, commit a half-truth). The batch read-discipline in Phase 5 exists because the brain's vector index refreshes out-of-band: write-then-semantically-reread in the same run can read stale, so the skill front-loads reads and confirms writes by id. Get those right and the skill does what it promises — nothing important is lost when the chat goes away.
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Capture capability — use-case & BDD specification
|
||||||
|
|
||||||
|
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
||||||
|
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
||||||
|
**Tracks:** hyperguild #49.
|
||||||
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
||||||
|
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||||
|
`brain/wiki/homelab/decisions/no-centralized-cross-harness-observer-2026-06-17.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Use-case (Clean Architecture form)
|
||||||
|
|
||||||
|
**Name:** CaptureSession
|
||||||
|
**Actor:** A harness acting on the user's behalf (claude.ai Chat/Cowork/Code/Design, Claude Code
|
||||||
|
CLI, Crush, Pi, LLM Council, Agentsquad executor/reviewer) — or the user directly.
|
||||||
|
**Goal:** Durably persist a finished session's valuable output — insights → brain, action items →
|
||||||
|
Gitea tickets, optional summary → ai-sessions — with one uniform invocation, identical core
|
||||||
|
behaviour across harnesses.
|
||||||
|
|
||||||
|
**Primary success scenario (essential steps):**
|
||||||
|
1. Caller assembles capture input (insights, tickets, optional summary) + context (harness,
|
||||||
|
session_ref, fidelity, actor, **data-classification**).
|
||||||
|
2. System validates the whole request (fail-closed).
|
||||||
|
3. System resolves **effective classification** (stricter of caller-declared and target-derived)
|
||||||
|
and the **server-derived harness origin** (from the authenticated principal). It checks the
|
||||||
|
**sovereignty gate** (I1): if effective classification is confidential AND the origin is a
|
||||||
|
non-sovereign (us-nexus) surface, the capture is **refused** before any write.
|
||||||
|
4. System persists insights (write or supersede), tickets (create/close/comment), summary — each
|
||||||
|
best-effort, recording per-item outcome.
|
||||||
|
5. System emits an **audit record** (I5) of who/what captured what, when, via which principal.
|
||||||
|
6. System returns a structured, partial-aware receipt.
|
||||||
|
|
||||||
|
**Architectural shape:** the *logic* is a shared use-case (`CaptureService`), invoked **per-harness
|
||||||
|
against the caller's own credentials** (distributed consolidation — no high-degree observer node).
|
||||||
|
A central authenticated relay endpoint exists ONLY as a fallback for harnesses that cannot run the
|
||||||
|
use-case in-process (Crush/Pi/headless); the relay holds no standing visibility and retains nothing
|
||||||
|
beyond the I5 audit log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariant obligations (acceptance gates, not nice-to-haves)
|
||||||
|
|
||||||
|
| Invariant | Obligation on `capture` |
|
||||||
|
|---|---|
|
||||||
|
| **I1 sovereign containment** | A confidential-classified session MUST NOT be captured through a us-nexus harness. Harness origin is **server-derived from the authenticated principal** (not caller-asserted). Classification uses **model (C)**: caller declares, server cross-checks the target's tag, **stricter wins**, mismatch logged. See §4.1–4.2. |
|
||||||
|
| **I2 deliberate acceptance** | The *distributed-library* form opens no new acceptance. IF a central relay node is deployed, its cross-harness reach MUST be entered in `infra/docs/security-baseline.md` with Why-accepted / Revisit-if before it ships. |
|
||||||
|
| **I3 GitOps reconcilability** | IF `capture` runs as a deployed service, its manifest lives under `infra/k3s/apps/**` (sovereign source, Flux-reconciled). No untracked runtime. |
|
||||||
|
| **I4 decisions captured** | The distributed-vs-central decision and the intent-named-verb pattern are recorded (ADR + brain). |
|
||||||
|
| **I5 auditability** | Every capture emits a request-level audit record (actor/principal, harness, items written, timestamp) to the alloy/loki substrate. **Classification-aware degradation** (§4.4): confidential + sink-down → hard-refuse; internal/public + sink-down → durable local buffer + ntfy + reconcile. Floor: refuse if nothing can record the audit. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. BDD scenarios (Gherkin)
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Feature: Capture session value uniformly across harnesses
|
||||||
|
As an operator working across many AI harnesses
|
||||||
|
I want one uniform command to persist insights and file tickets
|
||||||
|
So that valuable session output is never lost and is always auditable
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given a brain store, a Gitea issue tracker, and an ai-sessions summary writer
|
||||||
|
And the caller is authenticated with a principal
|
||||||
|
And the session context declares a harness, a fidelity, and a data classification
|
||||||
|
|
||||||
|
# --- Core happy path ---
|
||||||
|
Scenario: Capture insights and tickets from a non-confidential session
|
||||||
|
Given a session classified as "internal"
|
||||||
|
And the capture input has 2 insights and 1 ticket to create
|
||||||
|
When capture is invoked
|
||||||
|
Then both insights are written to the brain and their ids and content hashes are returned
|
||||||
|
And the ticket is created in the named repo under owner "mathias"
|
||||||
|
And an audit record is emitted naming the principal, harness, and items written
|
||||||
|
And the receipt reports every item as ok
|
||||||
|
|
||||||
|
# --- I1: sovereignty gate (the load-bearing refusal) ---
|
||||||
|
# Harness origin is server-derived from the authenticated principal, never from context.harness.
|
||||||
|
Scenario: Refuse capture of a confidential session through a us-nexus harness
|
||||||
|
Given a session whose effective classification is "confidential"
|
||||||
|
And the authenticated principal resolves to a us-nexus harness origin
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused before any write
|
||||||
|
And no insight, ticket, or summary is persisted
|
||||||
|
And the refusal names the sovereignty invariant as the reason
|
||||||
|
|
||||||
|
Scenario: Allow capture of a confidential session through a sovereign harness
|
||||||
|
Given a session whose effective classification is "confidential"
|
||||||
|
And the authenticated principal resolves to a sovereign-soil harness origin
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture proceeds and persists normally
|
||||||
|
|
||||||
|
Scenario: Ignore a caller-asserted harness label and use the server-derived origin
|
||||||
|
Given the request context asserts harness "sovereign-soil"
|
||||||
|
But the authenticated principal resolves to a us-nexus origin
|
||||||
|
And the session classification is "confidential"
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused
|
||||||
|
And the server-derived origin is used, not the asserted label
|
||||||
|
And the asserted-vs-derived discrepancy is logged as a security event
|
||||||
|
|
||||||
|
# --- I1: classification model (C) — stricter of declared vs target-derived wins ---
|
||||||
|
Scenario: Take the stricter classification when caller and target disagree
|
||||||
|
Given the caller declares classification "internal"
|
||||||
|
But the target wing/repo is tagged "confidential"
|
||||||
|
When capture is invoked
|
||||||
|
Then the effective classification is "confidential"
|
||||||
|
And the declared-vs-derived mismatch is logged as a security event
|
||||||
|
And the I1 gate is evaluated against "confidential"
|
||||||
|
|
||||||
|
Scenario: Honour a caller raising sensitivity above the target's tag
|
||||||
|
Given the caller declares classification "confidential"
|
||||||
|
And the target wing/repo is tagged "internal"
|
||||||
|
When capture is invoked
|
||||||
|
Then the effective classification is "confidential"
|
||||||
|
And the capture is gated as confidential
|
||||||
|
|
||||||
|
# --- Supersession + staleness discipline (reuses #45 / #47 resolution) ---
|
||||||
|
Scenario: Supersede a prior insight rather than duplicating it
|
||||||
|
Given an insight whose context names an existing note to supersede
|
||||||
|
When capture is invoked
|
||||||
|
Then the existing note is updated in place, not duplicated
|
||||||
|
And the prior content hash is recorded in the superseding note
|
||||||
|
And read-after-write confirmation uses a direct fetch, never a semantic query
|
||||||
|
|
||||||
|
# --- Validation: fail-closed ---
|
||||||
|
Scenario: Reject a malformed request before any write
|
||||||
|
Given a capture input with an invalid wing/hall or unknown repo
|
||||||
|
When capture is invoked
|
||||||
|
Then the request is rejected with a validation error
|
||||||
|
And nothing is written to the brain, Gitea, or ai-sessions
|
||||||
|
|
||||||
|
# --- Partial failure: best-effort + honest receipt ---
|
||||||
|
Scenario: Report partial success when one item fails mid-capture
|
||||||
|
Given a capture input with 2 insights and 1 ticket
|
||||||
|
And the second insight write will fail
|
||||||
|
When capture is invoked
|
||||||
|
Then the first insight and the ticket are persisted
|
||||||
|
And the second insight is reported as failed in the receipt
|
||||||
|
And no rollback is attempted
|
||||||
|
And the audit record reflects exactly what landed
|
||||||
|
|
||||||
|
# --- Dry run ---
|
||||||
|
Scenario: Preview a capture without writing
|
||||||
|
Given a valid capture input with dry_run true
|
||||||
|
When capture is invoked
|
||||||
|
Then the would-be receipt is returned
|
||||||
|
And nothing is written anywhere
|
||||||
|
|
||||||
|
# --- I5: auditability is classification-aware (confidential fails closed) ---
|
||||||
|
Scenario: Confidential capture hard-refuses when the central audit sink is down
|
||||||
|
Given the effective classification is "confidential"
|
||||||
|
And the central audit substrate (loki) cannot be written to
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused before any write
|
||||||
|
And the reason names the auditability invariant
|
||||||
|
# Confidential work must be centrally auditable at write time — no buffered exception.
|
||||||
|
|
||||||
|
Scenario: Internal capture degrades to a durable local buffer when the sink is down
|
||||||
|
Given the effective classification is "internal" or "public"
|
||||||
|
And the central audit substrate (loki) cannot be written to
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture proceeds
|
||||||
|
And the audit record is written to a durable LOCAL fallback buffer
|
||||||
|
And an ntfy alert is emitted naming the degraded audit state
|
||||||
|
And the receipt flags that audit was buffered locally, not centrally recorded
|
||||||
|
|
||||||
|
Scenario: Locally buffered audit records reconcile to the central sink on recovery
|
||||||
|
Given internal-tier audit records were buffered locally during a sink outage
|
||||||
|
When the central audit substrate becomes reachable again
|
||||||
|
Then the buffered records are replayed to the central sink
|
||||||
|
And the local buffer is cleared only after confirmed central write
|
||||||
|
|
||||||
|
Scenario: Even internal capture refuses if neither sink nor local buffer can be written
|
||||||
|
Given the effective classification is "internal" or "public"
|
||||||
|
And neither the central sink nor the local fallback buffer can be written
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused
|
||||||
|
And the reason names the auditability invariant
|
||||||
|
# Degrade-and-warn has a floor: if NOTHING can record the audit, do not write.
|
||||||
|
|
||||||
|
# --- Summary fidelity (collision rule from the retro work) ---
|
||||||
|
Scenario: A richer-fidelity summary supersedes a thinner one for the same session
|
||||||
|
Given a summary already exists for session_ref X at fidelity "live-capture"
|
||||||
|
And a new summary arrives for session_ref X at fidelity "transcript-parse"
|
||||||
|
When capture is invoked
|
||||||
|
Then the transcript-parse summary supersedes the live-capture one
|
||||||
|
And the live-capture summary is not left as a contradicting duplicate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolved decisions (2026-06-22)
|
||||||
|
|
||||||
|
These were open questions at draft; resolved in the 2026-06-22 review session. Recorded here as
|
||||||
|
binding design decisions for the build.
|
||||||
|
|
||||||
|
1. **Classification trust — model (C): caller-declares + server-cross-checks, stricter wins.**
|
||||||
|
The caller declares `context.classification`; the server **independently derives** the target's
|
||||||
|
classification (from the target wing/repo's classification tag) and gates on the **stricter of
|
||||||
|
the two**. The caller can voluntarily *raise* sensitivity but can never *lower* it below the
|
||||||
|
target's floor. A declared-vs-derived **mismatch is logged as a security event** (I5).
|
||||||
|
- **Prerequisite (new build work):** a classification taxonomy (e.g. `public` /
|
||||||
|
`internal` / `confidential`) and a per-wing / per-repo classification tag the server can read.
|
||||||
|
This must exist before the I1 gate is load-bearing. Tracked as a sub-task of #49.
|
||||||
|
- **Implemented (#50):** taxonomy `public < internal < confidential` (ordered so "stricter wins"
|
||||||
|
is `max`) in `ingestion/internal/classification/`. Tags are read from an optional
|
||||||
|
`classification.yaml` at the brain root (`wings:` / `repos:` maps); absent entries fall to
|
||||||
|
built-in defaults (`client-*` → confidential; `hyperguild`/`homelab` → internal; everything
|
||||||
|
else → **confidential, fail-safe**). `Config.Derive(Target)` is the function the use-case
|
||||||
|
calls. See brain `wiki/hyperguild/decisions/capture-classification-taxonomy`.
|
||||||
|
- Rationale: composes with decision 2; fails safe; honours a caller flagging something *more*
|
||||||
|
sensitive than its destination. Pure caller-trust (A) was rejected — it makes the gate theatre.
|
||||||
|
|
||||||
|
2. **Sovereign-harness determination — server-derived, not caller-asserted.**
|
||||||
|
"Is this harness us-nexus / sovereign?" is derived from the **authenticated principal/origin**
|
||||||
|
(the OAuth2 identity), never from `context.harness`. `context.harness` survives only as a
|
||||||
|
self-reported label for the audit log — descriptive telemetry, **never a gate input**. A control
|
||||||
|
keyed on an attacker-suppliable value is not a control.
|
||||||
|
|
||||||
|
3. **Central relay — ships in v1, with the I2 ledger entry.**
|
||||||
|
The relay is required, not optional: claude.ai (Chat/Cowork/Design), Crush, Pi, and LLM Council
|
||||||
|
cannot run the use-case library in-process, and those are primary day-to-day surfaces. Deferring
|
||||||
|
the relay would ship a capability that doesn't work from the interfaces actually in use. Because
|
||||||
|
the relay is a (thin, no-standing-visibility, audit-only-retention) central node, its cross-harness
|
||||||
|
reach **must be entered in `infra/docs/security-baseline.md`** with Why-accepted / Revisit-if
|
||||||
|
**before it ships** (I2). That ledger entry is v1 work, not a follow-up.
|
||||||
|
|
||||||
|
4. **Audit-sink-down — classification-aware: confidential fails closed, internal/public degrades.**
|
||||||
|
The posture inherits from the effective classification (decision 1), so there is one coherent
|
||||||
|
sensitivity model rather than a separate availability policy:
|
||||||
|
- **Confidential + central audit sink unreachable → hard-refuse.** No buffer, no proceed.
|
||||||
|
Confidential work must be centrally auditable *at write time*; "buffer and reconcile later"
|
||||||
|
introduces a buffer-integrity question (can a write tamper with its own pending audit record?)
|
||||||
|
that must not exist for confidential data. The simplicity of "refuse" is itself the assurance
|
||||||
|
asset — trivially true, nothing to poke holes in.
|
||||||
|
- **Internal / public + central sink unreachable → degrade-and-warn** with a durable local buffer
|
||||||
|
+ ntfy alert + reconcile-on-recovery (the earlier Q4 design, now scoped to lower tiers). Keeps
|
||||||
|
capture available for your own homelab work during an observability outage; negligible risk
|
||||||
|
since the buffered record is still durable and the data isn't client-confidential.
|
||||||
|
- **Floor (all tiers):** if *nothing* — neither central sink nor (for internal/public) the local
|
||||||
|
buffer — can record the audit, capture **refuses**. No tier writes wholly un-audited.
|
||||||
|
- Rationale: matches assurance cost to data sensitivity, exactly as the I1/sovereignty model
|
||||||
|
does for placement. Presentable to a due-diligence client as "audit posture is
|
||||||
|
classification-aware: confidential fails closed, internal degrades gracefully" — which
|
||||||
|
demonstrates the judgment, not just a binary. Couples Q4 to Q1's classification machinery
|
||||||
|
(being built anyway) and removes the buffer-integrity rabbit hole for the only tier where it
|
||||||
|
mattered.
|
||||||
Reference in New Issue
Block a user