From 6b680bdb90118de6925bc75780f91d712f66c767 Mon Sep 17 00:00:00 2001 From: Andrey Oblivantsev Date: Thu, 13 Aug 2026 21:32:57 +0100 Subject: [PATCH] feat: generate brain tools skill from OpenAPI; rename db-yaml to postgres (#21) Cursor skills stay in lockstep with serve handlers. CI checks every bin/ path named in SKILL.md exists. --- PLAN.md | 4 ++-- README.md | 2 +- bin/tools/test_published_docs.py | 2 ++ bin/tools/test_skills.py | 39 ++++++++++++++++++++++++++++++++ internal/httpapi/spec.go | 18 +++++++++++++++ internal/httpapi/spec_test.go | 13 +++++++++++ skills/brain/SKILL.md | 3 ++- skills/brain/tools.md | 11 +++++++++ skills/db-yaml/SKILL.md | 39 -------------------------------- skills/postgres/SKILL.md | 39 ++++++++++++++++++++++++++++++++ 10 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 bin/tools/test_skills.py create mode 100644 skills/brain/tools.md delete mode 100644 skills/db-yaml/SKILL.md create mode 100644 skills/postgres/SKILL.md diff --git a/PLAN.md b/PLAN.md index 9ca2d5c..f0c5f18 100644 --- a/PLAN.md +++ b/PLAN.md @@ -51,7 +51,7 @@ detective method: **a fact needs ≥2 independent sources or it is 2dph/ PLAN.md / AGENTS.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/ facts/extract.go audit.go crm.go # D14 shebang; Python implementation kb/index Python write path (called by bin/brain/index.go) @@ -146,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) 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 5. schema + tools with TDD (kb + md + facts + brain) 6. ~/.config/brain config diff --git a/README.md b/README.md index 3e200d2..34c50fb 100644 --- a/README.md +++ b/README.md @@ -166,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 Neo4j + Qdrant + Matrix RAG brain - [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 Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues). diff --git a/bin/tools/test_published_docs.py b/bin/tools/test_published_docs.py index 486fe01..b892fbc 100644 --- a/bin/tools/test_published_docs.py +++ b/bin/tools/test_published_docs.py @@ -74,6 +74,8 @@ class PublishedDocsTest(unittest.TestCase): 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() diff --git a/bin/tools/test_skills.py b/bin/tools/test_skills.py new file mode 100644 index 0000000..330ffd2 --- /dev/null +++ b/bin/tools/test_skills.py @@ -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) diff --git a/internal/httpapi/spec.go b/internal/httpapi/spec.go index 3af32c4..37d4a7d 100644 --- a/internal/httpapi/spec.go +++ b/internal/httpapi/spec.go @@ -1,5 +1,7 @@ package httpapi +import "strings" + // Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops. // ServeHTTP must keep the same path strings. @@ -124,3 +126,19 @@ func MCPTools() []MCPTool { } 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() +} diff --git a/internal/httpapi/spec_test.go b/internal/httpapi/spec_test.go index 0c27a22..07286db 100644 --- a/internal/httpapi/spec_test.go +++ b/internal/httpapi/spec_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -76,6 +78,17 @@ func TestMCPToolsListAndCall(t *testing.T) { } } +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)) diff --git a/skills/brain/SKILL.md b/skills/brain/SKILL.md index 98f4668..d0afa3e 100644 --- a/skills/brain/SKILL.md +++ b/skills/brain/SKILL.md @@ -45,5 +45,6 @@ are not wired yet); do not treat it as a graph walk. - If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and should stay at or above 95% recall@5. - Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same - handlers; tool names match paths `search`/`get`/`stats`/`audit`). + handlers; tool names match paths `search`/`get`/`stats`/`audit`). Generated + list: [tools.md](tools.md). - Never report an unconfirmed single-source local answer as fact. \ No newline at end of file diff --git a/skills/brain/tools.md b/skills/brain/tools.md new file mode 100644 index 0000000..fd66429 --- /dev/null +++ b/skills/brain/tools.md @@ -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) diff --git a/skills/db-yaml/SKILL.md b/skills/db-yaml/SKILL.md deleted file mode 100644 index 7743b9b..0000000 --- a/skills/db-yaml/SKILL.md +++ /dev/null @@ -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. diff --git a/skills/postgres/SKILL.md b/skills/postgres/SKILL.md new file mode 100644 index 0000000..34e08d4 --- /dev/null +++ b/skills/postgres/SKILL.md @@ -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.