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.
This commit is contained in:
@@ -51,7 +51,7 @@ 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.go audit.go crm.go # D14 shebang; Python implementation
|
facts/extract.go audit.go crm.go # D14 shebang; Python implementation
|
||||||
kb/index Python write path (called by bin/brain/index.go)
|
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)
|
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
|
||||||
|
|||||||
@@ -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
|
- [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).
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class PublishedDocsTest(unittest.TestCase):
|
|||||||
self.assertIn("/mcp", (ROOT / "README.md").read_text())
|
self.assertIn("/mcp", (ROOT / "README.md").read_text())
|
||||||
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
||||||
self.assertIn("/mcp", skill)
|
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:
|
def test_readme_search_escalates_web(self) -> None:
|
||||||
text = (ROOT / "README.md").read_text()
|
text = (ROOT / "README.md").read_text()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops.
|
// Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops.
|
||||||
// ServeHTTP must keep the same path strings.
|
// ServeHTTP must keep the same path strings.
|
||||||
|
|
||||||
@@ -124,3 +126,19 @@ func MCPTools() []MCPTool {
|
|||||||
}
|
}
|
||||||
return out
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func postJSON(t *testing.T, h http.Handler, path, raw string) (int, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw))
|
||||||
|
|||||||
@@ -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
|
- 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.
|
||||||
- Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same
|
- 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.
|
- Never report an unconfirmed single-source local answer as fact.
|
||||||
@@ -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)
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user