Compare commits

..
5 Commits
Author SHA1 Message Date
eSliderandGitHub d894c6609f feat: generate brain tools skill from OpenAPI; rename db-yaml to postgres (#21)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Cursor skills stay in lockstep with serve handlers. CI checks every bin/
path named in SKILL.md exists.
2026-08-13 21:32:57 +01:00
eSliderandGitHub ff80359684 feat: OpenAPI and MCP from the same serve handlers (#20)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Agents get GET /openapi.json and POST /mcp. Tool names match
search/get/stats/audit paths so PicoClaw does not need shebangs.
2026-08-13 21:26:06 +01:00
eSliderandGitHub 36976d9b53 feat: facts audit/extract/crm shebang wrappers (D14) (#19)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Python stays the implementation. Commands are bin/facts/{audit,extract,crm}.go
like postgres/query.go.
2026-08-13 21:20:28 +01:00
eSliderandGitHub 8e6f67cc97 feat: brain get/stats/eval call internal/brain, not Python (#18)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Read path is cgo like search. Control questions live in rank so CI can
test them without ladybug. Python bin/kb/{get,stats,eval} stays the
runner fallback.
2026-08-13 21:16:48 +01:00
eSliderandGitHub aca05626bd feat: escalate brain search to web when facts cannot confirm (#17)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
2026-08-13 20:24:30 +01:00
35 changed files with 1476 additions and 93 deletions
+7 -3
View File
@@ -77,11 +77,15 @@ bin/brain/index.go --rebuild # rebuil
## Tools ## Tools
```bash ```bash
bin/facts/audit ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate bin/facts/audit.go ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
bin/facts/crm [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT) bin/facts/crm.go [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT)
bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go
bin/brain/search.go "query" [--root facts|info] # deduction search → YAML bin/brain/search.go "query" [--root facts|info] # deduction search → YAML
bin/brain/get.go <id> [--body] bin/brain/search.go "query" --no-web # local graph only
bin/brain/get.go <id> [--body] [--json] # Go read; Python bin/kb/get CI fallback
bin/brain/stats.go [--json]
bin/brain/eval.go [--json] # recall@5; questions in internal/brain/rank
bin/brain/serve.go # HTTP :8630; GET /openapi.json POST /mcp
bin/markdown/import.go [dir] # mistune leaves → YAML bin/markdown/import.go [dir] # mistune leaves → YAML
bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs
bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
+13 -10
View File
@@ -29,7 +29,7 @@ detective method: **a fact needs ≥2 independent sources or it is
| D3 | web search | Go client `bin/web/search.go` (`internal/websearch`). SearXNG URL is config (`BRAIN_SEARCH_URL`). Optional Compose profile `searxng` (sanitized settings). Do not run a second copy on a host that already has one. Empty/`throttled` ≠ “nothing exists”. | | D3 | web search | Go client `bin/web/search.go` (`internal/websearch`). SearXNG URL is config (`BRAIN_SEARCH_URL`). Optional Compose profile `searxng` (sanitized settings). Do not run a second copy on a host that already has one. Empty/`throttled` ≠ “nothing exists”. |
| D4 | embeddings | **model2vec** `minishlab/potion-multilingual-128M` instead of embeddinggemma. | | D4 | embeddings | **model2vec** `minishlab/potion-multilingual-128M` instead of embeddinggemma. |
| D5 | parser | **mistune** for MD → leaf extraction (duckdb-md documented as future optional SQL/export layer, not v1). | | D5 | parser | **mistune** for MD → leaf extraction (duckdb-md documented as future optional SQL/export layer, not v1). |
| D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`); Python remains for index/write until the Go write path is safe. | | D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`). Read path (`get.go` / `stats.go` / `eval.go`) is Go + cgo. Python `bin/kb/{get,stats,eval}` is the CI fallback (GitHub runners have no ladybug cgo). Index/write stays Python until the Go write path is safe. |
| D7 | db access | `db-yaml`/`psql-yq`-style, read-only, YAML out. OnlyOffice Postgres via SSH tunnel (`127.0.0.1:5433`). | | D7 | db access | `db-yaml`/`psql-yq`-style, read-only, YAML out. OnlyOffice Postgres via SSH tunnel (`127.0.0.1:5433`). |
| D8 | evidence | detective method: ≥2 independent sources or `(not confirmed)`. Auto-pair docker ps × compose × ssh-config × docs. | | D8 | evidence | detective method: ≥2 independent sources or `(not confirmed)`. Auto-pair docker ps × compose × ssh-config × docs. |
| D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. | | D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. |
@@ -40,9 +40,10 @@ detective method: **a fact needs ≥2 independent sources or it is
| D14 | tooling style | `bin/{subject}/{method}.go` shebang (e.g. `bin/brain/search.go`). Shared code in `internal/`. One root `go.mod` + `go.work`. No `bin/*/main.go`, no nested modules. | | D14 | tooling style | `bin/{subject}/{method}.go` shebang (e.g. `bin/brain/search.go`). Shared code in `internal/`. One root `go.mod` + `go.work`. No `bin/*/main.go`, no nested modules. |
| D15 | repo | Gitea [`eSlider/2dph`](https://git.produktor.io/eSlider/2dph) is origin + [issues](https://git.produktor.io/eSlider/2dph/issues). GitHub `eSlider/2dph` is the public clone (PRs + Actions CI). No direct `main` pushes. TDD → PR → CI green → merge. | | D15 | repo | Gitea [`eSlider/2dph`](https://git.produktor.io/eSlider/2dph) is origin + [issues](https://git.produktor.io/eSlider/2dph/issues). GitHub `eSlider/2dph` is the public clone (PRs + Actions CI). No direct `main` pushes. TDD → PR → CI green → merge. |
| D16 | contradictions | ≥2 yes vs ≥2 no → unrelated sources conflict → hypothesis → `(not confirmed)`. Resolution (authority, staleness adjudication) = **v2**, tracked as open question. | | D16 | contradictions | ≥2 yes vs ≥2 no → unrelated sources conflict → hypothesis → `(not confirmed)`. Resolution (authority, staleness adjudication) = **v2**, tracked as open question. |
| D17 | assertion gate | Fact-check every *claim* (facts → info → live sources → web), not every edit. Missing graph ≠ “does not exist”. | | D17 | assertion gate | Fact-check every *claim* (facts → info → live → web), not every edit. `bin/brain/search.go` adds a `web` block when there is no facts hit (`throttled`/`skipped`/`refused` ≠ absence). `--root` and `--no-web` stay local. Missing graph ≠ “does not exist”. |
| D18 | reasoner | Pluggable OpenAI-compatible URL. RAM: Qwen3.5-9B. Quality: Bonsai-27B or Qwen3.6-27B. No official Qwen3.6-9B. | | D18 | reasoner | Pluggable OpenAI-compatible URL. RAM: Qwen3.5-9B. Quality: Bonsai-27B or Qwen3.6-27B. No official Qwen3.6-9B. |
| D19 | git history | [go-git](https://github.com/go-git/go-git) via `bin/git/import.go`. No subprocess of the git binary. Conversion prints commit leafs; brain write is `bin/brain/index.go`. | | D19 | git history | [go-git](https://github.com/go-git/go-git) via `bin/git/import.go`. No subprocess of the git binary. Conversion prints commit leafs; brain write is `bin/brain/index.go`. |
| D20 | agent API | OpenAPI + MCP are generated from the same `internal/httpapi.Ops` table as `bin/brain/serve.go` handlers. `GET /openapi.json`, `POST /mcp` (JSON-RPC tools/list + tools/call). Tool names match OpenAPI paths (`search`/`get`/`stats`/`audit`). |
## Architecture ## Architecture
@@ -50,15 +51,15 @@ detective method: **a fact needs ≥2 independent sources or it is
2dph/ 2dph/
PLAN.md / AGENTS.md PLAN.md / AGENTS.md
docs/ published docs (this conversation → docs/ as md) docs/ published docs (this conversation → docs/ as md)
skills/ in-project skills (web-search, db-yaml, brain, diataxis-docs) skills/ in-project skills (web-search, postgres, brain, diataxis-docs)
bin/ bin/
facts/extract auto-pair 2 sources → lexicon yaml + graph facts/extract.go audit.go crm.go # D14 shebang; Python implementation
facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate
kb/index Python write path (called by bin/brain/index.go) kb/index Python write path (called by bin/brain/index.go)
brain/index.go rebuild FTS + HNSW (incl. --with-mail) brain/index.go rebuild FTS + HNSW (incl. --with-mail)
brain/get.go stats.go eval.go watch.go brain/get.go stats.go eval.go # Go read (cgo); Python bin/kb/* CI fallback
brain/watch.go
brain/search.go deduction: facts → info → web-search brain/search.go deduction: facts → info → web-search
brain/serve.go HTTP API in-process (internal/httpapi + internal/brain) brain/serve.go HTTP API in-process + OpenAPI/MCP (D20)
mail/import.go JSON → markdown (no brain write) mail/import.go JSON → markdown (no brain write)
markdown/import.go mistune leaves markdown/import.go mistune leaves
postgres/query.go read-only YAML (wraps bin/db/psql-yq) postgres/query.go read-only YAML (wraps bin/db/psql-yq)
@@ -132,8 +133,10 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
1. go vet + go test ./... (root module; packages without ladybug cgo) 1. go vet + go test ./... (root module; packages without ladybug cgo)
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser) 2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
3. python -m unittest discover -s bin/tools (includes published-docs SoT) 3. python -m unittest discover -s bin/tools (includes published-docs SoT)
4. bin/facts/audit self (lexicon internal consistency) 4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
5. bin/brain/eval.go (recall@5 ≥ 0.95, gates index regressions) 5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go`; CI uses
the Python twin until the runner has ladybug cgo. Questions live in
`internal/brain/rank`.
6. md-docs build/lint if docs tooling arrives. 6. md-docs build/lint if docs tooling arrives.
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
@@ -143,7 +146,7 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci) 1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci)
2. gh repo create eSlider/2dph --private + initial commit + CI 2. gh repo create eSlider/2dph --private + initial commit + CI
3. vendored skill integration (web-search, db-yaml, brain, diataxis-docs) — no remote links 3. vendored skill integration (web-search, postgres, brain, diataxis-docs) — no remote links
4. .venv: ladybug + model2vec + mistune 4. .venv: ladybug + model2vec + mistune
5. schema + tools with TDD (kb + md + facts + brain) 5. schema + tools with TDD (kb + md + facts + brain)
6. ~/.config/brain config 6. ~/.config/brain config
+9 -7
View File
@@ -28,8 +28,8 @@ graph TB
end end
subgraph dph["2dph tools"] subgraph dph["2dph tools"]
EX["bin/facts/extract<br/>2-source pairing"] EX["bin/facts/extract.go<br/>2-source pairing"]
AU["bin/facts/audit<br/>confidence + staleness"] AU["bin/facts/audit.go<br/>confidence + staleness"]
IDX["bin/brain/index.go<br/>chunk + embed"] IDX["bin/brain/index.go<br/>chunk + embed"]
MD["bin/markdown/import.go<br/>mistune leaves"] MD["bin/markdown/import.go<br/>mistune leaves"]
SR["bin/brain/search.go<br/>deduction"] SR["bin/brain/search.go<br/>deduction"]
@@ -85,9 +85,10 @@ fact; conflicting sources or a single source → `hypothesis` → `(not confirme
## Deduction search ## Deduction search
```bash ```bash
bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web-search bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web
bin/brain/search.go "onlyoffice postgres" --root facts bin/brain/search.go "onlyoffice postgres" --root facts
bin/brain/search.go "where is cs-lexicon" --json | yq '.' bin/brain/search.go "where is cs-lexicon" --json | yq '.'
bin/brain/search.go "upstream flag" --no-web # local graph only
bin/brain/get.go <id> --body # full chunk on demand bin/brain/get.go <id> --body # full chunk on demand
bin/brain/stats.go # index health bin/brain/stats.go # index health
bin/brain/eval.go # recall@5 gate bin/brain/eval.go # recall@5 gate
@@ -125,7 +126,8 @@ bin/brain/search.go "invoice from last week" # same s
- **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25 - **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25
in one engine, embedded (no server), ACID, read-only-safe for concurrent in one engine, embedded (no server), ACID, read-only-safe for concurrent
readers. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves readers. Read tools (`get` / `stats` / `eval`) are Go + cgo; Python
`bin/kb/{get,stats,eval}` is the CI fallback. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves
ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while
`SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` + `SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` +
`bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts. `bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts.
@@ -139,14 +141,14 @@ bin/brain/search.go "invoice from last week" # same s
`bin/{subject}/{method}.go` — self-describing: shebang on line 1, usage comment `bin/{subject}/{method}.go` — self-describing: shebang on line 1, usage comment
from line 2. Shared code in `internal/`. YAML default output, `--json` for from line 2. Shared code in `internal/`. YAML default output, `--json` for
machines. Tests gate every commit. HTTP: `bin/brain/serve.go` calls machines. Tests gate every commit. HTTP: `bin/brain/serve.go` calls
`internal/brain` in-process (`/health` `/search` `/get` `/stats` `/audit` `/ingest`). `internal/brain` in-process (`/health` `/search` `/get` `/stats` `/audit` `/ingest` `/openapi.json` `/mcp`).
## Development ## Development
```bash ```bash
uv venv .venv # Python 3.12, uv-managed uv venv .venv # Python 3.12, uv-managed
uv pip install -r requirements.lock.txt # pinned toolchain uv pip install -r requirements.lock.txt # pinned toolchain
bin/facts/audit self # lexicon consistency gate bin/facts/audit.go self # lexicon consistency gate
go test ./... && python -m unittest discover -s bin/tools -t . go test ./... && python -m unittest discover -s bin/tools -t .
``` ```
@@ -164,7 +166,7 @@ docker compose up brain-watch # auto re-index on change
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier - [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
Neo4j + Qdrant + Matrix RAG brain Neo4j + Qdrant + Matrix RAG brain
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream - [agent-skills](https://github.com/eSlider/agent-skills) — upstream
skills (`web-search`, `db-yaml`, …) that 2dph integrates skills (`web-search`, `postgres`, …) that 2dph integrates
- detective method — the two-source method - detective method — the two-source method
Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues). Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
+6 -4
View File
@@ -1,20 +1,22 @@
//usr/bin/env go run -tags=brain_eval "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_eval "$0" "$@"; exit
//go:build brain_eval //go:build cgo && system_ladybug && brain_eval
// //
// bin/brain/eval.go - recall@5 gate. // bin/brain/eval.go - recall@5 gate.
// //
// ./bin/brain/eval.go // ./bin/brain/eval.go
// ./bin/brain/eval.go --json // ./bin/brain/eval.go --json
// //
// Needs CGO + libladybug. Python bin/kb/eval is the CI fallback (no cgo).
// Control questions live in internal/brain/rank (cgo-free).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/eval", os.Args[1:])) os.Exit(brain.MainEval(os.Args[1:]))
} }
+6 -4
View File
@@ -1,20 +1,22 @@
//usr/bin/env go run -tags=brain_get "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_get "$0" "$@"; exit
//go:build brain_get //go:build cgo && system_ladybug && brain_get
// //
// bin/brain/get.go - read one leaf by id. // bin/brain/get.go - read one leaf by id.
// //
// ./bin/brain/get.go <id> // ./bin/brain/get.go <id>
// ./bin/brain/get.go <id> --body // ./bin/brain/get.go <id> --body
// ./bin/brain/get.go <id> --json
// //
// Needs CGO + libladybug. Python bin/kb/get is the CI fallback (no cgo).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/get", os.Args[1:])) os.Exit(brain.MainGet(os.Args[1:]))
} }
+1 -1
View File
@@ -3,7 +3,7 @@
// //
// bin/brain/search.go - deduction search over the 2dph brain. // bin/brain/search.go - deduction search over the 2dph brain.
// //
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json] // ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json] [--no-web]
// ./bin/brain/search.go serve [port] // ./bin/brain/search.go serve [port]
// ./bin/brain/search.go --list-model // ./bin/brain/search.go --list-model
// //
+3
View File
@@ -6,6 +6,9 @@
// KB_ROOT=/path/to/2dph ./bin/brain/serve.go // KB_ROOT=/path/to/2dph ./bin/brain/serve.go
// KB_WORKERS=4 KB_PORT=8630 ./bin/brain/serve.go // KB_WORKERS=4 KB_PORT=8630 ./bin/brain/serve.go
// //
// GET /openapi.json same Ops table as the handlers
// POST /mcp JSON-RPC tools/list + tools/call
//
// Needs CGO + libladybug (same as bin/brain/search.go). // Needs CGO + libladybug (same as bin/brain/search.go).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
+5 -4
View File
@@ -1,20 +1,21 @@
//usr/bin/env go run -tags=brain_stats "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_stats "$0" "$@"; exit
//go:build brain_stats //go:build cgo && system_ladybug && brain_stats
// //
// bin/brain/stats.go - index health. // bin/brain/stats.go - index health.
// //
// ./bin/brain/stats.go // ./bin/brain/stats.go
// ./bin/brain/stats.go --json // ./bin/brain/stats.go --json
// //
// Needs CGO + libladybug. Python bin/kb/stats is the CI fallback (no cgo).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/stats", os.Args[1:])) os.Exit(brain.MainStats(os.Args[1:]))
} }
+21
View File
@@ -0,0 +1,21 @@
//usr/bin/env go run -tags=facts_audit "$0" "$@"; exit
//go:build facts_audit
//
// bin/facts/audit.go - 2-source + lexicon checks.
//
// ./bin/facts/audit.go self
// ./bin/facts/audit.go db
//
// Python bin/facts/audit is the implementation (CI runs it directly).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"os"
"github.com/eSlider/2dph/internal/cmdbin"
)
func main() {
os.Exit(cmdbin.ExecFile("bin/facts/audit", os.Args[1:]))
}
+20
View File
@@ -0,0 +1,20 @@
//usr/bin/env go run -tags=facts_crm "$0" "$@"; exit
//go:build facts_crm
//
// bin/facts/crm.go - prove person↔company / company↔project (ooCRM × corpus).
//
// ./bin/facts/crm.go [--dry-run] [--mismatches]
//
// Python bin/facts/crm is the implementation. Graph write stays Python.
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"os"
"github.com/eSlider/2dph/internal/cmdbin"
)
func main() {
os.Exit(cmdbin.ExecFile("bin/facts/crm", os.Args[1:]))
}
+20
View File
@@ -0,0 +1,20 @@
//usr/bin/env go run -tags=facts_extract "$0" "$@"; exit
//go:build facts_extract
//
// bin/facts/extract.go - acquire confirmed facts (2-source each).
//
// ./bin/facts/extract.go [--json] [--dry-run]
//
// Python bin/facts/extract is the implementation. Graph write stays Python.
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"os"
"github.com/eSlider/2dph/internal/cmdbin"
)
func main() {
os.Exit(cmdbin.ExecFile("bin/facts/extract", os.Args[1:]))
}
+38
View File
@@ -77,6 +77,44 @@ class BinLayoutTest(unittest.TestCase):
for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"): for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"):
self._assert_shebang(f"bin/brain/{method}") self._assert_shebang(f"bin/brain/{method}")
def test_brain_get_stats_eval_are_not_python_exec(self) -> None:
for method in ("get.go", "stats.go", "eval.go"):
text = (ROOT / "bin" / "brain" / method).read_text()
self.assertNotIn(
"ExecFile",
text,
f"bin/brain/{method} must call internal/brain, not ExecFile Python",
)
self.assertNotIn(
"cmdbin",
text,
f"bin/brain/{method} must not import internal/cmdbin",
)
self.assertIn(
"system_ladybug",
text.splitlines()[0],
f"bin/brain/{method} shebang must pass -tags=system_ladybug",
)
self.assertIn(
"github.com/eSlider/2dph/internal/brain",
text,
)
def test_eval_control_questions_live_in_rank(self) -> None:
rank = (ROOT / "internal" / "brain" / "rank" / "evalq.go").read_text()
py = (ROOT / "bin" / "kb" / "eval").read_text()
for frag in ("BM25", "DevOps", "LadybugDB"):
self.assertIn(frag, rank)
self.assertIn(frag, py)
self.assertIn("0.95", rank)
def test_facts_methods_are_shebangs(self) -> None:
for method in ("audit.go", "extract.go", "crm.go"):
self._assert_shebang(f"bin/facts/{method}")
text = (ROOT / "bin" / "facts" / method).read_text()
self.assertIn("cmdbin.ExecFile", text)
self.assertIn(f"bin/facts/{method.removesuffix('.go')}", text)
def test_mail_import_is_shebang_not_brain_write(self) -> None: def test_mail_import_is_shebang_not_brain_write(self) -> None:
self._assert_shebang("bin/mail/import.go") self._assert_shebang("bin/mail/import.go")
index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text() index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text()
+25
View File
@@ -59,6 +59,31 @@ class PublishedDocsTest(unittest.TestCase):
self.assertNotIn("password", settings.lower()) self.assertNotIn("password", settings.lower())
self.assertIn("json", settings) self.assertIn("json", settings)
def test_readme_read_path_is_go(self) -> None:
plan = (ROOT / "PLAN.md").read_text()
self.assertIn("get.go", plan)
self.assertIn("CI fallback", plan)
design = (ROOT / "docs" / "design.md").read_text()
self.assertIn("internal/brain/rank", design)
self.assertIn("They do not exec Python", design)
def test_openapi_mcp_from_same_handlers(self) -> None:
plan = (ROOT / "PLAN.md").read_text()
self.assertIn("D20", plan)
self.assertIn("/openapi.json", (ROOT / "README.md").read_text())
self.assertIn("/mcp", (ROOT / "README.md").read_text())
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
self.assertIn("/mcp", skill)
self.assertFalse((ROOT / "skills" / "db-yaml").exists())
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
def test_readme_search_escalates_web(self) -> None:
text = (ROOT / "README.md").read_text()
self.assertIn("--no-web", text)
self.assertIn("D17", (ROOT / "PLAN.md").read_text())
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
self.assertIn("`web` block", skill)
def test_docs_do_not_claim_hop_walks(self) -> None: def test_docs_do_not_claim_hop_walks(self) -> None:
paths = [ paths = [
ROOT / "README.md", ROOT / "README.md",
+39
View File
@@ -0,0 +1,39 @@
"""Skills must name live commands; every bin/ path in SKILL.md must exist."""
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
BIN_PATH = re.compile(r"(bin/[A-Za-z0-9_./-]+)")
class SkillsTest(unittest.TestCase):
def test_db_yaml_renamed_to_postgres(self) -> None:
self.assertFalse(
(ROOT / "skills" / "db-yaml").exists(),
"skills/db-yaml must be skills/postgres",
)
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
text = (ROOT / "skills" / "postgres" / "SKILL.md").read_text()
self.assertIn("bin/postgres/query.go", text)
self.assertNotIn("search.ops.io", text)
def test_every_bin_path_in_skills_exists(self) -> None:
missing: list[str] = []
for path in (ROOT / "skills").rglob("SKILL.md"):
text = path.read_text()
for m in BIN_PATH.finditer(text):
rel = m.group(1).rstrip(")`.,;")
candidate = ROOT / rel
if not candidate.exists():
missing.append(f"{path.relative_to(ROOT)}: {rel}")
self.assertEqual(missing, [], "skill bin paths must exist")
def test_brain_skill_lists_generated_tools(self) -> None:
tools = (ROOT / "skills" / "brain" / "tools.md").read_text()
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
self.assertIn("tools.md", skill)
for name in ("search", "get", "stats", "audit"):
self.assertIn(f"`{name}`", tools)
+17 -1
View File
@@ -21,7 +21,8 @@ bin/brain/search.go "question"
1. facts root — confirmed answers only → return with evidence links 1. facts root — confirmed answers only → return with evidence links
2. info root — supporting narrative → snippets, marked (not confirmed) 2. info root — supporting narrative → snippets, marked (not confirmed)
3. web-search — second independent source → upgrade hypothesis to confirmed 3. web-search — second independent source → upgrade hypothesis to confirmed
(`bin/web/search.go`; status `throttled` is not evidence of absence) (`web` block from `bin/web/search.go` when no facts hit; status `throttled`
is not evidence of absence; `--no-web` / `--root` skip it)
``` ```
`--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an `--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an
@@ -61,3 +62,18 @@ corpus HEAD.
Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`. Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`.
Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution). Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution).
## Read path
`bin/brain/get.go`, `stats.go`, and `eval.go` call `internal/brain` with cgo
(`system_ladybug`). They do not exec Python. Control questions for recall@5
live in `internal/brain/rank` so CI can test the table without libladybug.
Python `bin/kb/{get,stats,eval}` remain for GitHub Actions until the runner
has ladybug cgo. Index/write is still `bin/kb/index`.
## Agent API (D20)
`bin/brain/serve.go` exposes the same `internal/httpapi.Ops` table as OpenAPI
(`GET /openapi.json`) and MCP (`POST /mcp` JSON-RPC `tools/list` +
`tools/call`). Tool names match paths: `search`, `get`, `stats`, `audit`.
Agents should use these endpoints instead of shebang CLIs.
+7 -2
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/eSlider/2dph/internal/brain/rank"
) )
// Ready opens the Ladybug file for the life of the serve process. // Ready opens the Ladybug file for the life of the serve process.
@@ -17,7 +19,7 @@ func Ready() error {
// HTTP is the in-process API used by bin/brain/serve.go. // HTTP is the in-process API used by bin/brain/serve.go.
type HTTP struct{} type HTTP struct{}
func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) { func (HTTP) Search(ctx context.Context, query string, limit int) ([]byte, error) {
hits, err := searchHits(query, "", "", limit) hits, err := searchHits(query, "", "", limit)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -31,10 +33,13 @@ func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
hits[i].Snippet = string(runes) hits[i].Snippet = string(runes)
} }
} }
webOut := rank.Deduce(hits, query, "", false, func(q string) rank.SecondSource {
return lookupWeb(ctx, q)
})
var buf bytes.Buffer var buf bytes.Buffer
enc := json.NewEncoder(&buf) enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
if err := enc.Encode(toJSONOut(hits, query, "")); err != nil { if err := enc.Encode(toJSONOut(hits, query, "", webOut)); err != nil {
return nil, err return nil, err
} }
return buf.Bytes(), nil return buf.Bytes(), nil
+3
View File
@@ -0,0 +1,3 @@
package brain
const ModelID = "minishlab/potion-multilingual-128M"
+4 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
) )
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
bin/brain/search.go serve [port] bin/brain/search.go serve [port]
bin/brain/search.go --list-model` bin/brain/search.go --list-model`
@@ -17,6 +17,7 @@ type Options struct {
Limit int Limit int
JSONOut bool JSONOut bool
ListModel bool ListModel bool
NoWeb bool
} }
// ParseArgs reads flags. Unknown flags are an error: silently dropping them // ParseArgs reads flags. Unknown flags are an error: silently dropping them
@@ -54,6 +55,8 @@ func ParseArgs(args []string) (Options, error) {
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)") return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
case "--json": case "--json":
opt.JSONOut = true opt.JSONOut = true
case "--no-web":
opt.NoWeb = true
case "--list-model": case "--list-model":
opt.ListModel = true opt.ListModel = true
default: default:
+43
View File
@@ -0,0 +1,43 @@
package rank
// SecondSource is the web-search block on a deduction answer.
// Kept apart from graph hits so "ours" and "not ours" stay visible.
type SecondSource struct {
Status string `json:"status"`
Note string `json:"note,omitempty"`
Cached bool `json:"cached,omitempty"`
Results []SecondSourceHit `json:"results,omitempty"`
}
type SecondSourceHit struct {
Rank int `json:"rank"`
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Engine string `json:"engine"`
}
type WebFn func(query string) SecondSource
// ShouldEscalate is true when the default deduction path has no facts hit.
// `--root facts|info` is a single-root ask: do not mix in the web.
func ShouldEscalate(hits []Hit, rootFilter string) bool {
if rootFilter != "" {
return false
}
for _, h := range hits {
if h.Root == "facts" {
return false
}
}
return true
}
// Deduce returns the second-source block, or nil when web must not run.
func Deduce(hits []Hit, query, rootFilter string, noWeb bool, web WebFn) *SecondSource {
if noWeb || web == nil || !ShouldEscalate(hits, rootFilter) {
return nil
}
out := web(query)
return &out
}
+78
View File
@@ -0,0 +1,78 @@
package rank
import (
"strings"
"testing"
)
func TestShouldEscalateWhenNoFacts(t *testing.T) {
if !ShouldEscalate(nil, "") {
t.Fatal("empty local graph must escalate")
}
if !ShouldEscalate([]Hit{h("i", "info", "docs/a.md")}, "") {
t.Fatal("info-only must escalate (not confirmed)")
}
}
func TestShouldNotEscalateWhenFactsConfirm(t *testing.T) {
hits := []Hit{h("f", "facts", "docker ps x compose"), h("i", "info", "docs/a.md")}
if ShouldEscalate(hits, "") {
t.Fatal("facts hit is already confirmed; do not mix web")
}
}
func TestShouldNotEscalateWhenRootFilterSet(t *testing.T) {
if ShouldEscalate(nil, "facts") {
t.Fatal("--root facts must stay local")
}
if ShouldEscalate([]Hit{h("i", "info", "x")}, "info") {
t.Fatal("--root info must stay local")
}
}
func TestDeduceCallsWebOnlyWhenEscalating(t *testing.T) {
called := 0
web := func(q string) SecondSource {
called++
if q != "LadybugDB" {
t.Fatalf("query = %q", q)
}
return SecondSource{Status: "ok", Results: []SecondSourceHit{{Title: "t", URL: "http://example.com"}}}
}
got := Deduce([]Hit{h("i", "info", "x")}, "LadybugDB", "", false, web)
if called != 1 || got == nil || got.Status != "ok" {
t.Fatalf("got %+v called=%d", got, called)
}
}
func TestDeduceNilWhenFactsOrNoWeb(t *testing.T) {
web := func(string) SecondSource {
t.Fatal("web must not run")
return SecondSource{}
}
if Deduce([]Hit{h("f", "facts", "x")}, "q", "", false, web) != nil {
t.Fatal("facts")
}
if Deduce([]Hit{h("i", "info", "x")}, "q", "", true, web) != nil {
t.Fatal("--no-web")
}
if Deduce(nil, "q", "facts", false, web) != nil {
t.Fatal("--root facts")
}
if Deduce(nil, "q", "", false, nil) != nil {
t.Fatal("nil web fn")
}
}
func TestParseNoWeb(t *testing.T) {
opt, err := ParseArgs([]string{"query", "--no-web", "--json"})
if err != nil || !opt.NoWeb || !opt.JSONOut || opt.Query != "query" {
t.Fatalf("got %+v err=%v", opt, err)
}
}
func TestUsageNamesNoWeb(t *testing.T) {
if !strings.Contains(Usage, "--no-web") {
t.Fatalf("usage must name --no-web, got:\n%s", Usage)
}
}
+16
View File
@@ -0,0 +1,16 @@
package rank
// Eval control questions (recall@5). Kept here so CI can test the gate
// table without ladybug cgo. The runner lives in internal/brain (cgo).
const EvalRecallThreshold = 0.95
type EvalQuestion struct {
Query string
Fragment string
}
var EvalQuestions = []EvalQuestion{
{"hybrid search fts and vector", "BM25"},
{"eslider devops engineer", "DevOps"},
{"ladybugdb graph engine storage", "LadybugDB"},
}
+17
View File
@@ -0,0 +1,17 @@
package rank
import "testing"
func TestEvalQuestionsAreThreeAndThreshold(t *testing.T) {
if EvalRecallThreshold != 0.95 {
t.Fatalf("threshold = %v", EvalRecallThreshold)
}
if len(EvalQuestions) != 3 {
t.Fatalf("questions = %d, want 3", len(EvalQuestions))
}
for _, q := range EvalQuestions {
if q.Query == "" || q.Fragment == "" {
t.Fatalf("empty control: %+v", q)
}
}
}
+281
View File
@@ -0,0 +1,281 @@
//go:build cgo && system_ladybug
package brain
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"unicode/utf8"
"github.com/eSlider/2dph/internal/brain/rank"
)
func MainGet(args []string) int {
id, body, jsonOut := "", false, false
for _, a := range args {
switch {
case a == "--body":
body = true
case a == "--json":
jsonOut = true
case a == "-h" || a == "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/get.go <id> [--body] [--json]`)
return 0
case strings.HasPrefix(a, "-"):
fmt.Fprintf(os.Stderr, "brain/get: unknown flag %s\n", a)
return 2
default:
id = a
}
}
if id == "" {
fmt.Fprintln(os.Stderr, "brain/get: id required")
return 2
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
meta, text, err := lookupLeaf(id)
if err != nil {
fmt.Fprintf(os.Stderr, "brain/get: %v\n", err)
return 1
}
out := Dict{
{"id", meta["id"]},
{"root", meta["root"]},
{"confidence", meta["confidence"]},
{"source", meta["source"]},
{"type", meta["type"]},
}
if body {
out = append(out, KV{"text", text})
} else {
out = append(out, KV{"snippet", clip(text, 280)})
}
if jsonOut {
m := map[string]any{}
for _, kv := range out {
m[kv.K] = kv.V
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return b2i(enc.Encode(m))
}
fmt.Print(toYAML(out, 0))
return 0
}
func MainStats(args []string) int {
jsonOut := false
for _, a := range args {
switch a {
case "--json":
jsonOut = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/stats.go [--json]`)
return 0
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(os.Stderr, "brain/stats: unknown flag %s\n", a)
return 2
}
}
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
s, err := leafStats()
if err != nil {
fmt.Fprintf(os.Stderr, "brain/stats: %v\n", err)
return 1
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return b2i(enc.Encode(s))
}
by := s["by_root"].(map[string]int)
keys := make([]string, 0, len(by))
for k := range by {
keys = append(keys, k)
}
sort.Strings(keys)
byRoot := make(Dict, 0, len(keys))
for _, k := range keys {
byRoot = append(byRoot, KV{k, by[k]})
}
out := Dict{
{"total", s["total"]},
{"by_root", byRoot},
{"db", s["db"]},
{"model", s["model"]},
}
fmt.Print(toYAML(out, 0))
return 0
}
func MainEval(args []string) int {
jsonOut := false
for _, a := range args {
switch a {
case "--json":
jsonOut = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/eval.go [--json]`)
return 0
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(os.Stderr, "brain/eval: unknown flag %s\n", a)
return 2
}
}
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
recalled := 0
details := make([]any, 0, len(rank.EvalQuestions))
jsDetails := make([]map[string]any, 0, len(rank.EvalQuestions))
for _, q := range rank.EvalQuestions {
hits, err := queryFTS(q.Query, 5)
ok := false
if err == nil {
frag := strings.ToLower(q.Fragment)
for _, h := range hits {
if strings.Contains(strings.ToLower(h.Text), frag) {
ok = true
break
}
}
}
if ok {
recalled++
}
details = append(details, Dict{
{"q", q.Query},
{"fragment", q.Fragment},
{"in_top5", ok},
})
jsDetails = append(jsDetails, map[string]any{
"q": q.Query, "fragment": q.Fragment, "in_top5": ok,
})
}
n := len(rank.EvalQuestions)
recall := 0.0
if n > 0 {
recall = float64(recalled) / float64(n)
}
passed := recall >= rank.EvalRecallThreshold
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
_ = enc.Encode(map[string]any{
"recall@5": round3(recall),
"passed": passed,
"gate": n,
"details": jsDetails,
})
} else {
out := Dict{
{"recall@5", round3(recall)},
{"passed", passed},
{"gate", n},
{"details", details},
}
fmt.Print(toYAML(out, 0))
}
if !passed {
return 2
}
return 0
}
func lookupLeaf(id string) (map[string]string, string, error) {
if conn == nil {
return nil, "", fmt.Errorf("brain not open")
}
stmt, err := conn.Prepare(
"MATCH (l:Leaf {id:$id}) RETURN l.id, l.text, l.root, l.confidence, l.source, l.type",
)
if err != nil {
return nil, "", err
}
defer stmt.Close()
res, err := conn.Execute(stmt, map[string]any{"id": id})
if err != nil {
return nil, "", err
}
if !res.HasNext() {
return nil, "", fmt.Errorf("no leaf %s", id)
}
row, err := res.Next()
if err != nil {
return nil, "", err
}
vals, err := row.GetAsSlice()
if err != nil || len(vals) < 6 {
return nil, "", fmt.Errorf("leaf row")
}
meta := map[string]string{
"id": fmt.Sprint(vals[0]),
"root": fmt.Sprint(vals[2]),
"confidence": fmt.Sprint(vals[3]),
"source": fmt.Sprint(vals[4]),
"type": fmt.Sprint(vals[5]),
}
return meta, fmt.Sprint(vals[1]), nil
}
func leafStats() (map[string]any, error) {
if conn == nil {
return nil, fmt.Errorf("brain not open")
}
res, err := conn.Query("MATCH (l:Leaf) RETURN l.root, count(*)")
if err != nil {
return nil, err
}
byRoot := map[string]int{}
total := 0
for res.HasNext() {
row, err := res.Next()
if err != nil {
return nil, err
}
vals, err := row.GetAsSlice()
if err != nil || len(vals) < 2 {
continue
}
n := int(asInt(vals[1]))
byRoot[fmt.Sprint(vals[0])] = n
total += n
}
return map[string]any{
"total": total,
"by_root": byRoot,
"db": dbPath(),
"model": ModelID,
}, nil
}
func clip(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
func round3(f float64) float64 {
return float64(int(f*1000+0.5)) / 1000
}
+11 -2
View File
@@ -68,18 +68,25 @@ func runSearch(args []string) int {
} }
} }
webOut := rank.Deduce(results, query, root, opt.NoWeb, func(q string) rank.SecondSource {
return lookupWeb(context.Background(), q)
})
out := Dict{ out := Dict{
{"query", query}, {"query", query},
{"root_filter", root}, {"root_filter", root},
{"count", len(results)}, {"count", len(results)},
{"results", resultsToDicts(results)}, {"results", resultsToDicts(results)},
} }
if webOut != nil {
out = append(out, KV{"web", secondToDict(*webOut)})
}
if jsonOut { if jsonOut {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
return b2i(enc.Encode(toJSONOut(results, query, root))) return b2i(enc.Encode(toJSONOut(results, query, root, webOut)))
} }
fmt.Print(toYAML(out, 0)) fmt.Print(toYAML(out, 0))
return 0 return 0
@@ -172,6 +179,7 @@ type jsonOut struct {
RootFilter string `json:"root_filter"` RootFilter string `json:"root_filter"`
Count int `json:"count"` Count int `json:"count"`
Results []jsonHit `json:"results"` Results []jsonHit `json:"results"`
Web *rank.SecondSource `json:"web,omitempty"`
} }
type jsonHit struct { type jsonHit struct {
@@ -182,7 +190,7 @@ type jsonHit struct {
Snippet string `json:"snippet,omitempty"` Snippet string `json:"snippet,omitempty"`
} }
func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut { func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
out := make([]jsonHit, len(hits)) out := make([]jsonHit, len(hits))
for i, h := range hits { for i, h := range hits {
out[i] = jsonHit{ out[i] = jsonHit{
@@ -198,6 +206,7 @@ func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
RootFilter: rootFilter, RootFilter: rootFilter,
Count: len(hits), Count: len(hits),
Results: out, Results: out,
Web: web,
} }
} }
+56
View File
@@ -0,0 +1,56 @@
package brain
import (
"context"
"github.com/eSlider/2dph/internal/brain/rank"
"github.com/eSlider/2dph/internal/websearch"
)
func lookupWeb(ctx context.Context, query string) rank.SecondSource {
o := websearch.Lookup(ctx, query, websearch.LookupOpt{Limit: 5})
return toSecond(o)
}
func toSecond(o websearch.Output) rank.SecondSource {
hits := make([]rank.SecondSourceHit, 0, len(o.Results))
for _, h := range o.Results {
hits = append(hits, rank.SecondSourceHit{
Rank: h.Rank,
Title: h.Title,
URL: h.URL,
Snippet: h.Snippet,
Engine: h.Engine,
})
}
return rank.SecondSource{
Status: o.Status,
Note: o.Note,
Cached: o.Cached,
Results: hits,
}
}
func secondToDict(w rank.SecondSource) Dict {
d := Dict{
{"status", w.Status},
}
if w.Note != "" {
d = append(d, KV{"note", w.Note})
}
if w.Cached {
d = append(d, KV{"cached", true})
}
rows := make([]any, 0, len(w.Results))
for _, h := range w.Results {
rows = append(rows, Dict{
{"rank", h.Rank},
{"title", h.Title},
{"url", h.URL},
{"snippet", h.Snippet},
{"engine", h.Engine},
})
}
d = append(d, KV{"results", rows})
return d
}
+185
View File
@@ -0,0 +1,185 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
type rpcReq struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, OpenAPI())
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST JSON-RPC"})
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "read body"})
return
}
var req rpcReq
if err := json.Unmarshal(raw, &req); err != nil {
writeJSON(w, http.StatusOK, rpcResult(nil, nil, &rpcErr{-32700, "parse error"}))
return
}
result, rpcErrv, callErr := s.mcpDispatch(r, req)
if callErr != nil {
writeJSON(w, http.StatusOK, rpcResult(req.ID, nil, &rpcErr{-32603, callErr.Error()}))
return
}
writeJSON(w, http.StatusOK, rpcResult(req.ID, result, rpcErrv))
}
func (s *Server) mcpDispatch(r *http.Request, req rpcReq) (any, *rpcErr, error) {
switch req.Method {
case "initialize":
return map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "2dph", "version": "1"},
}, nil, nil
case "notifications/initialized", "notifications/cancelled":
return map[string]any{}, nil, nil
case "tools/list":
return map[string]any{"tools": MCPTools()}, nil, nil
case "tools/call":
out, err := s.mcpCall(r, req.Params)
return out, nil, err
case "ping":
return map[string]any{}, nil, nil
default:
return nil, &rpcErr{-32601, "method not found"}, nil
}
}
func (s *Server) mcpCall(r *http.Request, params json.RawMessage) (any, error) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("params")
}
if p.Arguments == nil {
p.Arguments = map[string]any{}
}
var (
body []byte
err error
)
switch p.Name {
case "search":
q := strings.TrimSpace(fmt.Sprint(p.Arguments["q"]))
if q == "" || q == "<nil>" {
return mcpText(`{"error":"q required"}`, true), nil
}
limit := 10
if raw, ok := p.Arguments["n"]; ok {
switch n := raw.(type) {
case float64:
limit = int(n)
case string:
if v, e := strconv.Atoi(n); e == nil {
limit = v
}
}
}
if limit < 1 || limit > 100 {
return mcpText(`{"error":"n must be int 1..100"}`, true), nil
}
if !s.tryAcquire(r) {
return nil, fmt.Errorf("cancelled")
}
defer s.release()
body, err = s.api.Search(r.Context(), q, limit)
case "get":
id := strings.TrimSpace(fmt.Sprint(p.Arguments["id"]))
if id == "" || id == "<nil>" {
return mcpText(`{"error":"id required"}`, true), nil
}
full := false
switch v := p.Arguments["body"].(type) {
case bool:
full = v
case string:
full = v == "1" || v == "true"
}
if !s.tryAcquire(r) {
return nil, fmt.Errorf("cancelled")
}
defer s.release()
body, err = s.api.Get(r.Context(), id, full)
case "stats":
if !s.tryAcquire(r) {
return nil, fmt.Errorf("cancelled")
}
defer s.release()
body, err = s.api.Stats(r.Context())
case "audit":
if !s.tryAcquire(r) {
return nil, fmt.Errorf("cancelled")
}
defer s.release()
body, err = s.api.Audit(r.Context())
case "ingest":
if !s.tryAcquire(r) {
return nil, fmt.Errorf("cancelled")
}
defer s.release()
body, err = s.api.Ingest(r.Context())
default:
return nil, fmt.Errorf("unknown tool %s", p.Name)
}
if err != nil {
return mcpText(err.Error(), true), nil
}
return mcpText(string(body), false), nil
}
func mcpText(text string, isError bool) map[string]any {
return map[string]any{
"content": []any{map[string]any{"type": "text", "text": text}},
"isError": isError,
}
}
type rpcResp struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcErr `json:"error,omitempty"`
}
func rpcResult(id json.RawMessage, result any, err *rpcErr) rpcResp {
out := rpcResp{JSONRPC: "2.0", ID: id}
if len(id) == 0 {
out.ID = []byte("null")
}
if err != nil {
out.Error = err
return out
}
if result == nil {
result = map[string]any{}
}
out.Result = result
return out
}
+20 -6
View File
@@ -48,18 +48,22 @@ func NewServer(api API, workers int) http.Handler {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/health": case PathHealth:
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
case "/search": case PathSearch:
s.handleSearch(w, r) s.handleSearch(w, r)
case "/get": case PathGet:
s.handleGet(w, r) s.handleGet(w, r)
case "/stats": case PathStats:
s.handleJSON(w, r, s.api.Stats) s.handleJSON(w, r, s.api.Stats)
case "/audit": case PathAudit:
s.handleJSON(w, r, s.api.Audit) s.handleJSON(w, r, s.api.Audit)
case "/ingest": case PathIngest:
s.handleJSON(w, r, s.api.Ingest) s.handleJSON(w, r, s.api.Ingest)
case PathOpenAPI:
s.handleOpenAPI(w, r)
case PathMCP:
s.handleMCP(w, r)
default: default:
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"}) writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
} }
@@ -112,6 +116,16 @@ func (s *Server) handleJSON(w http.ResponseWriter, r *http.Request, fn func(cont
writeAPI(w, body, err) writeAPI(w, body, err)
} }
func (s *Server) tryAcquire(r *http.Request) bool {
return s.acquire(nopWriter{}, r)
}
type nopWriter struct{}
func (nopWriter) Header() http.Header { return http.Header{} }
func (nopWriter) Write([]byte) (int, error) { return 0, nil }
func (nopWriter) WriteHeader(int) {}
func (s *Server) acquire(w http.ResponseWriter, r *http.Request) bool { func (s *Server) acquire(w http.ResponseWriter, r *http.Request) bool {
select { select {
case s.semaphore <- struct{}{}: case s.semaphore <- struct{}{}:
+144
View File
@@ -0,0 +1,144 @@
package httpapi
import "strings"
// Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops.
// ServeHTTP must keep the same path strings.
type Param struct {
Name, In, Type, Description string
Required bool
}
type Op struct {
Path, Method, ID, Summary string
Params []Param
MCP bool
}
const (
PathHealth = "/health"
PathSearch = "/search"
PathGet = "/get"
PathStats = "/stats"
PathAudit = "/audit"
PathIngest = "/ingest"
PathOpenAPI = "/openapi.json"
PathMCP = "/mcp"
)
var Ops = []Op{
{Path: PathHealth, Method: "get", ID: "health", Summary: "liveness"},
{
Path: PathSearch, Method: "get", ID: "search", Summary: "deduction search (facts → info → web)",
MCP: true,
Params: []Param{
{Name: "q", In: "query", Type: "string", Description: "search query", Required: true},
{Name: "n", In: "query", Type: "integer", Description: "hit limit 1..100 (default 10)"},
},
},
{
Path: PathGet, Method: "get", ID: "get", Summary: "read one leaf by id",
MCP: true,
Params: []Param{
{Name: "id", In: "query", Type: "string", Description: "leaf id", Required: true},
{Name: "body", In: "query", Type: "boolean", Description: "include full text"},
},
},
{Path: PathStats, Method: "get", ID: "stats", Summary: "index health", MCP: true},
{Path: PathAudit, Method: "get", ID: "audit", Summary: "facts confidence histogram", MCP: true},
{Path: PathIngest, Method: "get", ID: "ingest", Summary: "rebuild hint (write is v2)", MCP: true},
{Path: PathOpenAPI, Method: "get", ID: "openapi", Summary: "OpenAPI 3 document for this server"},
}
func OpenAPI() map[string]any {
paths := map[string]any{}
for _, op := range Ops {
params := make([]any, 0, len(op.Params))
for _, p := range op.Params {
params = append(params, map[string]any{
"name": p.Name,
"in": p.In,
"required": p.Required,
"description": p.Description,
"schema": map[string]any{"type": p.Type},
})
}
item := map[string]any{
"operationId": op.ID,
"summary": op.Summary,
"responses": map[string]any{
"200": map[string]any{
"description": "JSON",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"type": "object"},
},
},
},
},
}
if len(params) > 0 {
item["parameters"] = params
}
paths[op.Path] = map[string]any{op.Method: item}
}
return map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": "2dph brain",
"version": "1",
"description": "Same handlers as bin/brain/serve.go. MCP tools at POST /mcp match these paths.",
},
"paths": paths,
}
}
type MCPTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
func MCPTools() []MCPTool {
out := make([]MCPTool, 0, len(Ops))
for _, op := range Ops {
if !op.MCP {
continue
}
props := map[string]any{}
var required []string
for _, p := range op.Params {
props[p.Name] = map[string]any{"type": p.Type, "description": p.Description}
if p.Required {
required = append(required, p.Name)
}
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
schema["required"] = required
}
out = append(out, MCPTool{
Name: op.ID,
Description: op.Summary,
InputSchema: schema,
})
}
return out
}
// SkillMarkdown is the Cursor skill fragment generated from Ops/MCPTools.
func SkillMarkdown() string {
var b strings.Builder
b.WriteString("# brain HTTP / MCP tools\n\n")
b.WriteString("Generated from `internal/httpapi.Ops`. Do not edit by hand.\n\n")
b.WriteString("Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).\n\n")
for _, t := range MCPTools() {
b.WriteString("- `")
b.WriteString(t.Name)
b.WriteString("` — ")
b.WriteString(t.Description)
b.WriteString("\n")
}
return b.String()
}
+99
View File
@@ -0,0 +1,99 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestOpenAPIIncludesCorePaths(t *testing.T) {
doc := OpenAPI()
raw, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
paths, _ := doc["paths"].(map[string]any)
for _, p := range []string{"/search", "/get", "/stats", "/audit"} {
if _, ok := paths[p]; !ok {
t.Fatalf("openapi missing path %s (%s)", p, raw)
}
}
}
func TestMCPToolsMatchOpenAPIPaths(t *testing.T) {
paths, _ := OpenAPI()["paths"].(map[string]any)
tools := MCPTools()
if len(tools) == 0 {
t.Fatal("no MCP tools")
}
names := map[string]bool{}
for _, tool := range tools {
names[tool.Name] = true
path := "/" + tool.Name
if _, ok := paths[path]; !ok {
t.Fatalf("MCP tool %s has no OpenAPI path %s", tool.Name, path)
}
}
for _, need := range []string{"search", "get", "stats", "audit"} {
if !names[need] {
t.Fatalf("MCP tools missing %s: %v", need, names)
}
}
}
func TestOpenAPIHTTP(t *testing.T) {
h := NewServer(&fakeSearcher{}, 1)
code, body := get(t, h, "/openapi.json")
if code != http.StatusOK {
t.Fatalf("code = %d body=%s", code, body)
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("not json: %v", err)
}
if doc["openapi"] == nil {
t.Fatalf("missing openapi version: %s", body)
}
}
func TestMCPToolsListAndCall(t *testing.T) {
h := NewServer(&fakeSearcher{}, 1)
code, body := postJSON(t, h, "/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if code != http.StatusOK {
t.Fatalf("list code = %d body=%s", code, body)
}
if !strings.Contains(string(body), `"search"`) {
t.Fatalf("tools/list missing search: %s", body)
}
code, body = postJSON(t, h, "/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"q":"matrix","n":3}}}`)
if code != http.StatusOK {
t.Fatalf("call code = %d body=%s", code, body)
}
if !strings.Contains(string(body), "matrix") {
t.Fatalf("search call body %s", body)
}
}
func TestSkillMarkdownMatchesCommittedFile(t *testing.T) {
want, err := os.ReadFile(filepath.Join("..", "..", "skills", "brain", "tools.md"))
if err != nil {
t.Fatal(err)
}
got := SkillMarkdown()
if got != string(want) {
t.Fatalf("skills/brain/tools.md stale; regenerate from SkillMarkdown()\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func postJSON(t *testing.T, h http.Handler, path, raw string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.Bytes()
}
+122
View File
@@ -0,0 +1,122 @@
package websearch
import (
"context"
"fmt"
"net/http"
"os"
"time"
"golang.org/x/sys/unix"
)
const (
StatusSkipped = "skipped"
StatusRefused = "refused"
)
type LookupOpt struct {
Limit int
Timeout time.Duration
EnvPath string
CachePath string
Client *http.Client
Now func() float64
Sleep func(context.Context, time.Duration) error
}
func Lookup(ctx context.Context, query string, opt LookupOpt) Output {
if ctx == nil {
ctx = context.Background()
}
if opt.Limit <= 0 {
opt.Limit = DefaultLimit
}
if opt.Timeout <= 0 {
opt.Timeout = 25 * time.Second
}
nowFn := opt.Now
if nowFn == nil {
nowFn = func() float64 { return float64(time.Now().Unix()) }
}
sleepFn := opt.Sleep
if sleepFn == nil {
sleepFn = func(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
}
if reason := PHIReason(query); reason != "" {
return Output{Query: query, Status: StatusRefused, Note: reason}
}
cachePath := opt.CachePath
if cachePath == "" {
cachePath = os.Getenv("BRAIN_SEARCH_CACHE")
}
if cachePath == "" {
cachePath = os.Getenv("HOME") + "/.cache/brain/web-search.sqlite"
}
cache, err := OpenCache(cachePath)
if err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "cache: " + err.Error()}
}
defer cache.Close()
key := CacheKey(query, nil)
now := nowFn()
if cached, err := cache.Get(key, CacheTTL, now); err == nil && cached != nil {
out := Project(*cached, opt.Limit, DefaultSnippetChars)
out.Cached = true
return out
}
envPath := opt.EnvPath
if envPath == "" {
envPath = os.Getenv("BRAIN_SEARCH_ENV")
}
if envPath == "" {
envPath = os.Getenv("HOME") + "/.config/brain/search.env"
}
conf, err := LoadConfig(envPath)
if err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "no BRAIN_SEARCH_URL; second source not consulted"}
}
lock, err := os.OpenFile(cachePath+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "lock: " + err.Error()}
}
defer lock.Close()
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "lock: " + err.Error()}
}
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
last, err := cache.LastCall()
if err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "cache: " + err.Error()}
}
if delay := WaitFor(last, nowFn(), MinInterval); delay > 0 {
if err := sleepFn(ctx, time.Duration(delay*float64(time.Second))); err != nil {
return Output{Query: query, Status: StatusSkipped, Note: "cancelled"}
}
}
_ = cache.MarkCall(nowFn())
payload, err := Fetch(opt.Client, conf, query, nil, opt.Timeout)
if err != nil {
return Output{Query: query, Status: StatusThrottled, Note: fmt.Sprintf("request failed: %v", err)}
}
if Classify(payload) == StatusOK {
_ = cache.Put(key, payload, nowFn())
}
return Project(payload, opt.Limit, DefaultSnippetChars)
}
+97
View File
@@ -0,0 +1,97 @@
package websearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestLookupRefusesPIIWithoutFetch(t *testing.T) {
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
hits++
}))
defer srv.Close()
out := Lookup(context.Background(), "Personalnummer 12", LookupOpt{
EnvPath: writeEnv(t, srv.URL),
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
Client: srv.Client(),
Sleep: func(context.Context, time.Duration) error { return nil },
})
if out.Status != StatusRefused {
t.Fatalf("status = %s", out.Status)
}
if hits != 0 {
t.Fatal("PII query left the host")
}
}
func TestLookupSkipsWhenNoConfig(t *testing.T) {
out := Lookup(context.Background(), "LadybugDB", LookupOpt{
EnvPath: filepath.Join(t.TempDir(), "missing.env"),
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
Sleep: func(context.Context, time.Duration) error { return nil },
})
if out.Status != StatusSkipped {
t.Fatalf("status = %s", out.Status)
}
}
func TestLookupFetchesOnceAndCaches(t *testing.T) {
hits := 0
payload := Payload{Query: "x", Results: []RawHit{{Title: "t", URL: "http://example.com", Content: "c", Engine: "bing"}}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
opt := LookupOpt{
EnvPath: writeEnv(t, srv.URL),
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
Client: srv.Client(),
Now: func() float64 { return 1_000 },
Sleep: func(context.Context, time.Duration) error { return nil },
}
a := Lookup(context.Background(), "LadybugDB", opt)
b := Lookup(context.Background(), "LadybugDB", opt)
if a.Status != StatusOK || b.Status != StatusOK {
t.Fatalf("a=%s b=%s", a.Status, b.Status)
}
if hits != 1 {
t.Fatalf("hits = %d, want 1 (second from cache)", hits)
}
if !b.Cached {
t.Fatal("second lookup not cached")
}
}
func TestLookupEmptyIsThrottled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"query":"x","results":[]}`))
}))
defer srv.Close()
out := Lookup(context.Background(), "LadybugDB", LookupOpt{
EnvPath: writeEnv(t, srv.URL),
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
Client: srv.Client(),
Now: func() float64 { return 1_000 },
Sleep: func(context.Context, time.Duration) error { return nil },
})
if out.Status != StatusThrottled {
t.Fatalf("status = %s", out.Status)
}
}
func writeEnv(t *testing.T, url string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "search.env")
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_URL="+url+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return p
}
+8 -4
View File
@@ -28,7 +28,7 @@ bin/brain/search.go "onlyoffice postgres" --root facts # restrict to confirmed
bin/brain/search.go "where is cs-lexicon" --json | yq '.[].ref' bin/brain/search.go "where is cs-lexicon" --json | yq '.[].ref'
bin/brain/get.go <id> --body # full chunk only when needed bin/brain/get.go <id> --body # full chunk only when needed
bin/brain/stats.go # index health bin/brain/stats.go # index health
bin/brain/eval.go # recall@5 >= 0.95 gate bin/brain/eval.go # recall@5 >= 0.95 gate (Go; Python bin/kb/eval is CI fallback)
``` ```
`bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges `bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges
@@ -39,8 +39,12 @@ are not wired yet); do not treat it as a graph walk.
- Search before you read. Never grep a repo for a concept the graph covers. - Search before you read. Never grep a repo for a concept the graph covers.
- `--root facts` returns only confirmed evidence-linked answers. Default shows - `--root facts` returns only confirmed evidence-linked answers. Default shows
facts first, then info leafs clearly marked `(not confirmed)`. facts first, then info leafs clearly marked `(not confirmed)`.
- If there is no facts hit, `bin/brain/search.go` consults SearXNG and adds a
`web` block (kept apart from graph hits). `throttled` / `skipped` / `refused`
are not evidence of absence. `--root facts|info` and `--no-web` skip the web.
- If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and - If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and
should stay at or above 95% recall@5. should stay at or above 95% recall@5.
- Escalate to `web-search` (the `web-search` skill) as the independent second - Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same
source when both local roots cannot confirm; never report an unconfirmed handlers; tool names match paths `search`/`get`/`stats`/`audit`). Generated
single-source local answer as fact. list: [tools.md](tools.md).
- Never report an unconfirmed single-source local answer as fact.
+11
View File
@@ -0,0 +1,11 @@
# brain HTTP / MCP tools
Generated from `internal/httpapi.Ops`. Do not edit by hand.
Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).
- `search` — deduction search (facts → info → web)
- `get` — read one leaf by id
- `stats` — index health
- `audit` — facts confidence histogram
- `ingest` — rebuild hint (write is v2)
-39
View File
@@ -1,39 +0,0 @@
---
name: db-yaml
description: >-
Read any Postgres as compact YAML through db/psql-yq, with a read-only guard and
named profiles. Use when a task needs table contents, column types or a SELECT
against cs_brain or another project database.
---
# db-yaml
`bin/db/psql-yq` (vendored in this repo) talks to Postgres and returns YAML,
which is far cheaper than a psql ASCII table and easy to slice with `yq`.
```bash
bin/db/psql-yq --profile onlyoffice -s document_asset # column list
bin/db/psql-yq --profile onlyoffice -t task_result -l 20 # sample rows as YAML
bin/db/psql-yq --profile onlyoffice -c 'SELECT ...' # query -> YAML
```
Ad-hoc targets without a profile:
```bash
bin/db/psql-yq --container my-pg --db app -c 'SELECT 1'
bin/db/psql-yq --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
```
## Profiles
Connection details live in `~/.config/brain/db-profiles.yml` (mode 600), never in a
project repo. A profile names either a `container` or a `host`; passwords are read
from a separate `password_env_file` and never appear in argv.
## Rules
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
- **PII.** `cs_brain` holds client data. Aggregate and count freely; never copy
names or addresses into chat, issues or docs.
- Use `-l` to keep samples small. Twenty rows answer most questions.
+39
View File
@@ -0,0 +1,39 @@
---
name: postgres
description: >-
Read Postgres as compact YAML through bin/postgres/query.go (read-only
guard, named profiles). Use when a task needs table contents, column types,
or a SELECT against an ops database.
---
# postgres
`bin/postgres/query.go` wraps vendored `bin/db/psql-yq`. Output is YAML
(cheaper than psql ASCII, easy to slice with `yq`).
```bash
bin/postgres/query.go --profile onlyoffice -s document_asset # column list
bin/postgres/query.go --profile onlyoffice -t task_result -l 20 # sample rows
bin/postgres/query.go --profile onlyoffice -c 'SELECT ...' # query → YAML
```
Ad-hoc targets without a profile:
```bash
bin/postgres/query.go --container my-pg --db app -c 'SELECT 1'
bin/postgres/query.go --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
```
## Profiles
Connection details live in `$HOME/.config/brain/db-profiles.yml` (mode 600),
never in a project repo. A profile names either a `container` or a `host`;
passwords are read from a separate `password_env_file` and never appear in argv.
## Rules
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
- **PII.** Client CRM databases: aggregate and count freely; never copy names
or addresses into chat, issues or docs.
- Use `-l` to keep samples small. Twenty rows answer most questions.