Compare commits

...
Author SHA1 Message Date
eSlider 7db9e6b836 docs: record v1 detective epic and remaining gaps.
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Tests / Test (pull_request) Failing after 6s
Tests / Release (semver) (pull_request) Skipped
Read path and MCP are in; write, hops, corpus, and CI eval are still open. Board is Gitea epic #16.
2026-08-14 10:22:36 +01:00
eSliderandGitHub 1edb158f35 docs: portable runbook and Diataxis index (#27)
Tests / Test (push) Failing after 5s
Tests / Release (semver) (push) Skipped
Public face is a product, not a laptop tool. Run steps live in
docs/runbook.md; decisions D3/D6/D14/D15/D17/D18 stay in the docs index.
2026-08-13 23:31:41 +01:00
eSliderandGitHub 85caff90b9 feat: markdown import splits H2 leafs in Go (#26)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Drop the Python ExecFile wrapper. Conversion still does not write
Ladybug; add a dry-run fixture check for the index adapter split.
2026-08-13 23:27:42 +01:00
15 changed files with 701 additions and 58 deletions
+4 -3
View File
@@ -3,7 +3,8 @@
Evidence-first brain over the ops/eSlider stack. Facts need proof or they are
`(not confirmed)`.
Read first: [PLAN](PLAN.md) → [docs](docs/).
Read first: [PLAN](PLAN.md) → [docs](docs/) → [roadmap](docs/roadmap.md)
(epic [#16](https://git.produktor.io/eSlider/2dph/issues/16)).
## Method (detective, no fork)
@@ -42,7 +43,7 @@ bin/ self-describing tools bin/{subject}/{method}.go (shebang)
bin/brain/ search.go serve.go index.go get.go stats.go eval.go watch.go
bin/chats/ sync.go import.go facts.go apply.go; libs in internal/chats
bin/mail/ sync.go import.go (index_mail → brain/index.go)
bin/markdown/ import.go (mistune leafs)
bin/markdown/ import.go (H2 leaf split; Python bin/md/import fallback)
bin/postgres/ query.go (read-only YAML)
bin/git/ import.go (go-git history; Python shim execs it)
bin/web/ search.go (SearXNG; Python shim execs it)
@@ -92,7 +93,7 @@ bin/brain/get.go <id> [--body] [--json] # Go read; Python bin/kb/get CI
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] # H2 leafs → YAML; Python bin/md/import fallback
bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs
bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
bin/reasoner/bakeoff.go [--model ID] [--json] # D18 CPU tool-call bake-off
+31 -9
View File
@@ -4,7 +4,9 @@ A brain that loves facts and deduction. Evidence-first knowledge graph + hybrid
RAG over the operational Brain/ops/eSlider stack. Built like Sherlock
Holmes: nothing is asserted unless it has proof.
Status: **in progress**this file is the plan and the record of decisions.
Status: **in progress**read path + MCP work; v1 goal is [epic #16](https://git.produktor.io/eSlider/2dph/issues/16)
(milestone [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12)).
Gap: [docs/roadmap.md](docs/roadmap.md).
## What
@@ -41,7 +43,7 @@ detective method: **a fact needs ≥2 independent sources or it is
| 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. |
| 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 (`REASONER_BASE_URL`). RAM: `Qwen/Qwen3.5-9B`. Quality: `prism-ml/Bonsai-27B-gguf` or `Qwen/Qwen3.6-27B`. No official Qwen3.6-9B. CPU bake-off: `bin/reasoner/bakeoff.go` + compose profile `reasoner` (`OLLAMA_NUM_GPU=0`, `:11435`). PicoClaw is not shipped; tools are `search`/`get`/`audit`. Weights are not copied into the 2dph image. |
| D18 | reasoner | Pluggable OpenAI-compatible URL (`REASONER_BASE_URL`). RAM: `Qwen/Qwen3.5-9B`. Quality: `prism-ml/Bonsai-27B-gguf` or `Qwen/Qwen3.6-27B`. No official Qwen3.6-9B. CPU bake-off: `bin/reasoner/bakeoff.go` + compose profile `reasoner` (`OLLAMA_NUM_GPU=0`, `:11435`). PicoClaw is compose profile `picoclaw`; tools are `search`/`get`/`audit`. Weights are not copied into the 2dph image. Agent lever/loop: [#15](https://git.produktor.io/eSlider/2dph/issues/15). |
| 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`). |
| D21 | CGO | Ladybug/tokenizers CGO is compiled with **Zig** (`bin/cgo/zcc``zig cc -target …-linux-gnu`), not gcc. `bin/cgo/zig` pins Zig 0.14.1 + liblbug 0.19.1 + libtokenizers 1.27.0. Compose `target: api` has no CPython; write/rebuild is profile `index`. |
@@ -63,7 +65,7 @@ detective method: **a fact needs ≥2 independent sources or it is
brain/serve.go HTTP API in-process + OpenAPI/MCP (D20); Zig CGO (D21)
cgo/zig zcc zc++ CGO toolchain (zig cc, not gcc)
mail/import.go JSON → markdown (no brain write)
markdown/import.go mistune leaves
markdown/import.go H2 leaf split (Go); Python bin/md/import fallback
postgres/query.go read-only YAML (wraps bin/db/psql-yq)
git/import.go go-git history (no git binary; conversion only)
web/search.go SearXNG client (throttled ≠ absence)
@@ -83,7 +85,8 @@ detective method: **a fact needs ≥2 independent sources or it is
Node tables: `Person, Service, Host, Container, Repo, File, Commit, Leaf`.
`Leaf(embedding FLOAT[N])` — FTS on `text`, HNSW vector index on `embedding`.
Edges: `RUNS / USES / HAS_VERSION / AUTHORED / ABOUT / ASSOCIATED / SIMILAR_0.85`.
Edges: `RUNS / USES / FROM_FILE / HAS_VERSION / AUTHORED / ABOUT / ASSOCIATED / SIMILAR_0.85`.
`FROM_FILE` / `HAS_VERSION` exist in schema; search `--hop` does not walk them yet ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
`where`, `when`, `source_rev`.
@@ -108,9 +111,9 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
## Open questions (v2)
- OQ1: mutually-contradicting evidence — how to resolve (authority weighting,
temporal freshness, audit adjudication).
- OQ2: OCR pipeline for pdfs/images/docs — mostly solved: poppler pdftotext
fast-path for born-digital PDFs, docling fallback for the ~5% textless ones.
temporal freshness, audit adjudication). **v2**; does not block epic #16.
- OQ2: OCR — poppler `pdftotext` fast-path exists; scans still docling.
[#6](https://git.produktor.io/eSlider/2dph/issues/6) (v2, does not block #16).
- OQ3: optional duckdb-md layer for `SELECT … FORMAT MARKDOWN` export/write-back.
- OQ4: YAML-first storage for leafs — deferred: JSON is ~10x faster to
serialize and unambiguous; YAML only where humans edit files.
@@ -138,6 +141,7 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
3. python -m unittest discover -s bin/tools (includes published-docs SoT)
4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go` via Zig CGO.
CI SoT switch: [#19](https://git.produktor.io/eSlider/2dph/issues/19).
6. `bin/cgo/zig go build -tags system_ladybug` (compile search with zig cc; fetches pinned zig+libs).
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
@@ -151,5 +155,23 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
4. .venv: ladybug + model2vec + mistune
5. schema + tools with TDD (kb + md + facts + brain)
6. ~/.config/brain config
7. corpus extraction (facts/info)
8. verify: web-search smoke, onlyoffice pg, md-db round-trip, eval, audit
7. corpus extraction (facts/info)**open**: [#18](https://git.produktor.io/eSlider/2dph/issues/18)
8. verify: web-search smoke, onlyoffice pg, md-db round-trip, eval, audit
## Gap to v1 (epic #16)
Read path + MCP are in. The detective brain is not closed until the graph is
**writable incrementally** and search can **walk** it. Board:
[epic #16](https://git.produktor.io/eSlider/2dph/issues/16),
milestone [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12).
Narrative: [docs/roadmap.md](docs/roadmap.md).
| Order | Issue | Gap |
|-------|-------|-----|
| 1 | [#14](https://git.produktor.io/eSlider/2dph/issues/14) | Write stays Python rebuild; `brain/add` / `POST /ingest` are hints. Ladybug 0.19 WAL corrupts if new leafs land while FTS/HNSW exist. |
| 2 | [#17](https://git.produktor.io/eSlider/2dph/issues/17) | `--hop` errors. `FROM_FILE` / `HAS_VERSION` are in schema; search does not walk them. |
| 3 | [#18](https://git.produktor.io/eSlider/2dph/issues/18) | Rebuild is mostly `info` (repo md + mail). `facts/extract` and chats are not a first-class index input. WhatsApp sync is a stub. |
| 4 | [#15](https://git.produktor.io/eSlider/2dph/issues/15) | Lever = 2dph fact-check. Loop = PicoClaw/MCP `search``get``audit`. Specify in-repo, not only live config. |
| 5 | [#19](https://git.produktor.io/eSlider/2dph/issues/19) | GitHub CI recall still runs Python `bin/kb/eval`. |
Does **not** block epic close: [#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR, OQ1, OQ3, OQ4.
+30 -26
View File
@@ -7,14 +7,16 @@
[![Latest Release](https://img.shields.io/github/v/tag/eSlider/2dph?sort=semver&label=release)](https://github.com/eSlider/2dph/releases)
[![GitHub Stars](https://img.shields.io/github/stars/eSlider/2dph?style=social)](https://github.com/eSlider/2dph/stargazers)
An evidence-first brain over the operational eSlider stack. **Facts need two
independent sources, or they are `(not confirmed)`.**
An evidence-first brain. **Facts need two independent sources, or they are
`(not confirmed)`.** Cursor is not the runtime.
`2dph` is a single embedded knowledge graph (LadybugDB = Kuzu successor) with
native **HNSW vector** + **BM25 full-text** indexes, built from markdown,
compose files, ssh config, docker state, and git history. Search is
*deduction*: confirmed facts first, supporting info second, `web-search` as
the independent second source when the local graph cannot confirm.
`2dph` is a single embedded knowledge graph (LadybugDB) with native **HNSW
vector** + **BM25 full-text** indexes. Search is *deduction*: confirmed facts
first, supporting info second, `web-search` as the independent second source
when the local graph cannot confirm.
Run it: [docs/runbook.md](docs/runbook.md). Design: [docs/design.md](docs/design.md).
Docs index: [docs/README.md](docs/README.md).
## Architecture
@@ -31,7 +33,7 @@ graph TB
EX["bin/facts/extract.go<br/>2-source pairing"]
AU["bin/facts/audit.go<br/>confidence + staleness"]
IDX["bin/brain/index.go<br/>chunk + embed"]
MD["bin/markdown/import.go<br/>mistune leaves"]
MD["bin/markdown/import.go<br/>H2 leaf split"]
SR["bin/brain/search.go<br/>deduction"]
end
@@ -74,7 +76,7 @@ graph TB
## The method
Every assertion is `Who / What / How / Where / When + evidence + confidence`,
mirroring the detective detective skill: **≥2 independent sources confirm a
mirroring the detective method: **≥2 independent sources confirm a
fact; conflicting sources or a single source → `hypothesis``(not confirmed)`.**
| root | meaning | used for answers |
@@ -124,17 +126,15 @@ bin/brain/search.go "invoice from last week" # same s
## Storage
- **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25
in one engine, embedded (no server), ACID, read-only-safe for concurrent
readers. Read tools (`get` / `stats` / `eval`) are Go + Zig CGO (`bin/cgo/zcc`); 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
`SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` +
`bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts.
- **model2vec** — `potion-multilingual-128M` static embeddings (256-dim),
CPU-fast, deterministic, no Ollama runtime dependency.
- facts and info split semantically by `root` column but written inside the
same transaction.
- **LadybugDB** — single `var/kb.lbug`, Cypher + HNSW + BM25, embedded.
Read tools (`get` / `stats` / `eval`) are Go + Zig CGO (`bin/cgo/zcc`).
Python fallbacks stay for CI until the runner fetches Zig. Write is
Compose profile `index` (`bin/brain/index.go`).
- **model2vec** — `potion-multilingual-128M` (256-dim), CPU, no Ollama
runtime dependency.
- facts and info split by `root` but written in the same transaction.
Ladybug 0.19 DROP INDEX warning: [docs/runbook.md](docs/runbook.md).
## Tooling conventions
@@ -145,11 +145,13 @@ machines. Tests gate every commit. HTTP: `bin/brain/serve.go` calls
## Development
See the portable runbook: [docs/runbook.md](docs/runbook.md).
```bash
uv venv .venv # Python 3.12, uv-managed
uv pip install -r requirements.lock.txt # pinned toolchain
bin/facts/audit.go self # lexicon consistency gate
go test ./... && python -m unittest discover -s bin/tools -t .
uv venv .venv
uv pip install -r requirements.lock.txt
bin/facts/audit.go self
go test ./... && uv run python -m unittest discover -s bin/tools -t .
```
Docker (optional, cached model + var volumes):
@@ -170,7 +172,9 @@ docker compose up brain-watch # auto re-index on change
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).
Work board (issues): [epic #16](https://git.produktor.io/eSlider/2dph/issues/16)
on [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
PRs and CI: GitHub [`eSlider/2dph`](https://github.com/eSlider/2dph).
See [PLAN.md](PLAN.md) for decisions, execution status, and v2 open questions.
See [PLAN.md](PLAN.md) for decisions, [docs/roadmap.md](docs/roadmap.md) for
the gap to v1, and v2 open questions.
+85 -5
View File
@@ -1,20 +1,100 @@
//usr/bin/env go run -tags=markdown_import "$0" "$@"; exit
//go:build markdown_import
//usr/bin/env go run "$0" "$@"; exit
//
// bin/markdown/import.go - split markdown into leafs (mistune).
// bin/markdown/import.go - split markdown into leafs (H2 boundaries).
//
// ./bin/markdown/import.go [dir]
// ./bin/markdown/import.go --files a.md,b.md --json
//
// Conversion only. Brain write is bin/brain/index.go.
// Python bin/md/import remains as a fallback.
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"fmt"
"os"
"strings"
"github.com/eSlider/2dph/internal/cmdbin"
"github.com/eSlider/2dph/internal/mdleaves"
)
func main() {
os.Exit(cmdbin.ExecFile("bin/md/import", os.Args[1:]))
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
jsonOut := false
files := ""
root := "."
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--json":
jsonOut = true
case a == "--files" && i+1 < len(args):
i++
files = args[i]
case strings.HasPrefix(a, "--files="):
files = strings.TrimPrefix(a, "--files=")
case a == "-h" || a == "--help":
fmt.Fprintln(os.Stderr, "bin/markdown/import.go [dir] [--files a.md,b.md] [--json]")
return 0
case strings.HasPrefix(a, "-"):
fmt.Fprintln(os.Stderr, "unknown arg:", a)
return 2
default:
root = a
}
}
var paths []string
if files != "" {
for _, f := range strings.Split(files, ",") {
f = strings.TrimSpace(f)
if f != "" {
paths = append(paths, f)
}
}
} else {
st, err := os.Stat(root)
if err != nil {
fmt.Fprintf(os.Stderr, "md/import: no such path %s\n", root)
return 2
}
if !st.IsDir() {
paths = []string{root}
} else {
var err error
paths, err = mdleaves.WalkMarkdown(root)
if err != nil {
fmt.Fprintf(os.Stderr, "md/import: %v\n", err)
return 1
}
}
}
if len(paths) == 0 {
fmt.Fprintln(os.Stderr, "md/import: no markdown files")
return 1
}
var all []mdleaves.Leaf
for _, p := range paths {
raw, err := os.ReadFile(p)
if err != nil {
fmt.Fprintf(os.Stderr, "md/import: %s: %v\n", p, err)
continue
}
all = append(all, mdleaves.ToAll(string(raw), p, "")...)
}
if jsonOut {
s, err := mdleaves.EncodeJSON(all)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Print(s)
return 0
}
fmt.Print(mdleaves.EncodeYAML(all))
return 0
}
+21 -1
View File
@@ -125,8 +125,28 @@ class BinLayoutTest(unittest.TestCase):
"index_mail must point at bin/brain/index.go",
)
def test_markdown_import_is_shebang(self) -> None:
def test_markdown_import_is_go_not_python_exec(self) -> None:
self._assert_shebang("bin/markdown/import.go")
text = (ROOT / "bin" / "markdown" / "import.go").read_text()
self.assertNotIn("ExecFile", text)
self.assertNotIn("cmdbin", text)
self.assertIn("internal/mdleaves", text)
self.assertNotIn("kb.lbug", text)
def test_import_adapters_do_not_write_ladybug(self) -> None:
for rel in (
"bin/mail/import.go",
"bin/mail/import",
"bin/markdown/import.go",
"bin/chats/import.go",
"bin/git/import.go",
):
text = (ROOT / rel).read_text()
self.assertNotIn("upsert_leaf", text, rel)
self.assertNotIn("kb.lbug", text, rel)
self.assertNotIn("var/brain.lbug", text, rel)
index = (ROOT / "bin" / "brain" / "index.go").read_text()
self.assertIn("bin/kb/index", index)
def test_postgres_query_is_shebang(self) -> None:
self._assert_shebang("bin/postgres/query.go")
+41
View File
@@ -0,0 +1,41 @@
"""Import adapters write files only. Index rebuild is brain/index (D14 / Gitea #7)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
class IndexAdapterTest(unittest.TestCase):
def test_dry_run_fixture_corpus_does_not_write_lbug(self) -> None:
tmp = Path(tempfile.mkdtemp())
(tmp / "note.md").write_text("# Fixture\n\n## Leaf\n\nhello corpus\n", encoding="utf-8")
lbug = tmp / "kb.lbug"
try:
import ladybug # noqa: F401
except ImportError:
venv_py = ROOT / ".venv" / "bin" / "python"
if not venv_py.is_file():
self.skipTest("ladybug missing")
py = str(venv_py)
else:
py = sys.executable
proc = subprocess.run(
[py, str(ROOT / "bin" / "kb" / "index"), "--dry-run", "--json", "--corpus", str(tmp)],
cwd=ROOT,
capture_output=True,
text=True,
env=os.environ.copy(),
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
msg = json.loads(proc.stdout)
self.assertTrue(msg.get("dry_run"))
self.assertGreaterEqual(msg.get("corpus_total", 0), 1)
self.assertFalse(lbug.exists(), "dry-run must not create a Ladybug file")
+35
View File
@@ -145,6 +145,9 @@ class PublishedDocsTest(unittest.TestCase):
ROOT / "docs" / "design.md",
ROOT / "skills" / "brain" / "SKILL.md",
ROOT / "skills" / "diataxis-docs" / "SKILL.md",
ROOT / "docs" / "runbook.md",
ROOT / "docs" / "README.md",
ROOT / "docs" / "roadmap.md",
]
# Command-style `--hop 1` / `--hop N` plus follow/walk = the old lie.
# Honest "not implemented" notes must not match.
@@ -155,3 +158,35 @@ class PublishedDocsTest(unittest.TestCase):
lie.search(text),
f"{path.relative_to(ROOT)} still claims --hop walks the graph",
)
def test_docs_are_portable_diataxis(self) -> None:
index = (ROOT / "docs" / "README.md").read_text()
self.assertIn("type: reference", index)
for d in ("D3", "D6", "D14", "D15", "D17", "D18"):
self.assertIn(d, index)
runbook = (ROOT / "docs" / "runbook.md").read_text()
self.assertIn("type: howto", runbook)
self.assertIn("bin/brain/search.go", runbook)
self.assertIn("bin/brain/index.go", runbook)
self.assertNotIn("search.ops.io", runbook)
self.assertNotIn("/mnt/", runbook)
self.assertNotIn("/home/", runbook)
readme = (ROOT / "README.md").read_text()
self.assertIn("docs/runbook.md", readme)
self.assertNotIn("search.ops.io", readme)
def test_v1_epic_is_named_in_docs(self) -> None:
plan = (ROOT / "PLAN.md").read_text()
self.assertIn("Gap to v1", plan)
self.assertIn("eSlider/2dph/issues/16", plan)
self.assertIn("eSlider/2dph/issues/17", plan)
self.assertIn("eSlider/2dph/milestone/12", plan)
road = (ROOT / "docs" / "roadmap.md").read_text()
self.assertIn("type: explanation", road)
self.assertIn("issues/16", road)
self.assertIn("issues/14", road)
index = (ROOT / "docs" / "README.md").read_text()
self.assertIn("roadmap.md", index)
self.assertIn("epic #16", index)
agents = (ROOT / "AGENTS.md").read_text()
self.assertIn("roadmap.md", agents)
+31 -9
View File
@@ -1,16 +1,38 @@
# 2dph (deductionphile)
---
type: reference
status: current
related:
- docs/runbook.md
- docs/design.md
- PLAN.md
- docs/roadmap.md
---
Evidence-first knowledge graph + hybrid RAG over the operational
Brain/ops/eSlider stack. Facts need proof or they are
# 2dph docs (Diataxis)
Evidence-first knowledge graph. Facts need proof or they are
`(not confirmed)`.
- [PLAN.md](../PLAN.md) — decisions, execution order, open questions (v2)
- [design](design.md) — schema, deduction model, sources
- [reasoner](reasoner.md) — D18 CPU bake-off (Qwen3.5-9B vs Bonsai / Qwen3.6-27B)
- [Gitea issues](https://git.produktor.io/eSlider/2dph/issues) — work board (origin)
| Type | Doc |
|------|-----|
| tutorial / howto | [runbook](runbook.md) — run anywhere (uv, Go, Docker) |
| explanation | [design](design.md) — two roots, deduction, D17/D20/D18 |
| explanation | [roadmap](roadmap.md) — gap to v1 (epic #16) |
| howto | [picoclaw](picoclaw.md) — MCP agent profile |
| howto | [reasoner](reasoner.md) — CPU bake-off (D18) |
| reference | [PLAN.md](../PLAN.md) — decisions D1D21 |
Decisions the public face must name: **D3** SearXNG compose, **D6** Go service /
Python write sidecar, **D14** `bin/{subject}/{method}.go`, **D15** Gitea origin,
**D17** assertion gate (facts → info → web), **D18** pluggable reasoner.
Search: `bin/brain/search.go "query"` (HTTP: `bin/brain/serve.go`
`/health` `/search` `/get` `/stats` `/audit` `/ingest`). `--hop` is
not a walk; the flag errors until File/FROM_FILE edges exist.
not a walk; the flag errors. Schema has `FROM_FILE`; search does not
use it ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
Published docs live here and mirror the project state.
Work board: [Gitea issues](https://git.produktor.io/eSlider/2dph/issues)
([epic #16](https://git.produktor.io/eSlider/2dph/issues/16)).
PRs and CI: GitHub [`eSlider/2dph`](https://github.com/eSlider/2dph).
Published docs live here and match live commands.
+16 -3
View File
@@ -1,3 +1,12 @@
---
type: explanation
status: current
related:
- docs/README.md
- docs/runbook.md
- docs/roadmap.md
---
# Design — facts, info, deduction
## Two roots, one transaction
@@ -25,8 +34,9 @@ bin/brain/search.go "question"
is not evidence of absence; `--no-web` / `--root` skip it)
```
`--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an
error; it is not a graph walk.
`--hop` is not implemented. `FROM_FILE` / `HAS_VERSION` exist in schema;
search does not walk them ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
The flag is an error; it is not a graph walk.
## Who / What / How / Where / When + evidence
@@ -86,4 +96,7 @@ Pluggable OpenAI-compatible URL. RAM: `Qwen/Qwen3.5-9B`. Quality:
`prism-ml/Bonsai-27B-gguf` or `Qwen/Qwen3.6-27B`. No official Qwen3.6-9B.
CPU sidecar: compose profile `reasoner` (`OLLAMA_NUM_GPU=0`,
`127.0.0.1:11435`). Bake-off: `bin/reasoner/bakeoff.go`. Weights stay out
of the 2dph image. See [docs/reasoner.md](reasoner.md).
of the 2dph image. See [docs/reasoner.md](reasoner.md).
Gap to v1 (write, hops, corpus, agent loop): [roadmap](roadmap.md),
[epic #16](https://git.produktor.io/eSlider/2dph/issues/16).
+67
View File
@@ -0,0 +1,67 @@
---
type: explanation
status: current
related:
- PLAN.md
- docs/design.md
- docs/runbook.md
---
# Gap to v1 — detective brain
Goal: a brain that does not assert without proof. Search is deduction
(`facts` ≥2 sources → `info``web`). `confirmed` only from the facts root.
**v1 is a living graph the agent can write and walk**, not “more RAG”.
Epic: [Gitea #16](https://git.produktor.io/eSlider/2dph/issues/16).
Milestone: [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12).
Decisions: [PLAN.md](../PLAN.md).
## In (do not reopen)
Read path Go + Zig CGO (D21). HTTP + OpenAPI + MCP (D20). PicoClaw compose
profile + CPU reasoner (D18). Mail sync → import → rebuild. D14 shebangs.
Compose `api` (no CPython) / `index` (Python write). Issues #1#5, #7#13.
`POST /ingest` is a rebuild **hint**. `add` is not implemented.
## Blockers
```
question
├─ FTS + HNSW ← in
├─ facts / info roots ← in
├─ web (D17) ← in
├─ Cypher hop ← #17 schema yes, search no
├─ brain/add ACID ← #14 rebuild only
└─ facts+chats corpus ← #18
```
1. **[#14](https://git.produktor.io/eSlider/2dph/issues/14) write** —
`bin/brain/index.go --rebuild` (Python `kblib`). No incremental
`brain/add`. Watch/mail/git cannot become facts “now”.
2. **[#17](https://git.produktor.io/eSlider/2dph/issues/17) hops** —
`Leaf-[:FROM_FILE]->File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person`
exists; `--hop` still errors. Without a walk, D9/D10 are paper.
3. **[#18](https://git.produktor.io/eSlider/2dph/issues/18) corpus** —
rebuild loads repo markdown + mail as `info`. `facts/extract` pairing
and `bin/chats` are not indexed. WhatsApp is a stub. PII stays in `var/`.
4. **[#15](https://git.produktor.io/eSlider/2dph/issues/15) lever/loop** —
2dph is the lever (`search``get``audit`). PicoClaw is the loop.
Document the contour in-repo; CPU turns need a large context window.
5. **[#19](https://git.produktor.io/eSlider/2dph/issues/19) CI eval** —
recall SoT should be `bin/brain/eval.go` via Zig, not Python `bin/kb/eval`.
## Not v1
[#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR (OQ2), OQ1
contradiction resolution, OQ3 duckdb-md export, OQ4 YAML-first leafs.
## Close epic #16 when
- facts+info can be written without a full rebuild for every leaf
- `--hop` stops erroring and runs a Cypher path from search hits
- ops pairing + chat import land as leafs on rebuild
- MCP tool order is documented and still gated by tests
+82
View File
@@ -0,0 +1,82 @@
---
type: howto
status: current
related:
- docs/README.md
- PLAN.md
---
# Run 2dph (portable)
No laptop-absolute paths. Config lives in env files under `$HOME/.config/brain/`
(mode 0600), not in git.
## Toolchain
- Go (see `go.mod`)
- Python 3.12 + [uv](https://docs.astral.sh/uv)
- Optional: Docker, Zig CGO via `bin/cgo/zig` (not gcc)
```bash
uv venv .venv
uv pip install -r requirements.lock.txt
eval "$(bin/cgo/zig env)" # when compiling Ladybug read tools
go test ./...
uv run python -m unittest discover -s bin/tools -t .
```
## Config
| File / env | Purpose |
|------------|---------|
| `$BRAIN_SEARCH_ENV` (default `$HOME/.config/brain/search.env`) | `BRAIN_SEARCH_URL` (SearXNG). Optional Basic Auth. |
| `$HOME/.config/brain/db-profiles.yml` | read-only Postgres profiles (OnlyOffice via tunnel) |
If the host already runs SearXNG, point `BRAIN_SEARCH_URL` at it. Do not start
a second copy (D3). Optional Compose instance:
```bash
SEARXNG_SECRET=$(openssl rand -hex 32) docker compose --profile searxng up -d
```
That binds `127.0.0.1:8888`. JSON format must stay enabled.
## Index then search
Write path is Compose profile `index` (Python Ladybug rebuild) until
`brain/add` is v2. The operator command is `bin/brain/index.go`.
```bash
bin/brain/index.go --rebuild
bin/brain/search.go "LadybugDB vector index" # facts → info → web (D17)
bin/brain/search.go "upstream flag" --no-web
bin/brain/get.go <id> --body
bin/brain/stats.go
```
`--hop` is not implemented. Empty web results are `throttled`, not absence.
Gap to v1: [roadmap](roadmap.md) / [epic #16](https://git.produktor.io/eSlider/2dph/issues/16).
Ladybug 0.19: never `DROP INDEX` FTS/VECTOR (ghost catalog). Fresh indexes =
delete `var/kb.lbug` then `--rebuild`.
## HTTP / MCP
```bash
docker compose up -d brain # :8630 Zig CGO serve
docker compose --profile index run --rm index # rebuild
docker compose --profile picoclaw up brain-mcp # MCP 127.0.0.1:8630
```
`GET /openapi.json`, `POST /mcp`. Agent tool order: `search``get``audit`.
## Reasoner (optional, D18)
CPU sidecar on `127.0.0.1:11435`. Weights are not in the 2dph image.
```bash
docker compose --profile reasoner up -d reasoner
REASONER_BASE_URL=http://127.0.0.1:11435/v1 ./bin/reasoner/bakeoff.go --json
```
See [reasoner.md](reasoner.md).
+146
View File
@@ -0,0 +1,146 @@
package mdleaves
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
)
type Leaf struct {
Source string `json:"source"`
Repo string `json:"repo"`
Heading string `json:"heading"`
Text string `json:"text"`
Type string `json:"type"`
Status string `json:"status"`
Related string `json:"related"`
}
type chunk struct {
Heading string
Text string
}
var (
h1 = regexp.MustCompile(`^# \S`)
h2 = regexp.MustCompile(`^## \S`)
)
func ExtractFrontmatter(text string) (map[string]string, string) {
if !strings.HasPrefix(text, "---") {
return map[string]string{}, text
}
end := strings.Index(text[3:], "\n---")
if end == -1 {
return map[string]string{}, text
}
fm := strings.TrimSpace(text[3 : 3+end])
body := text[3+end+4:]
meta := map[string]string{}
for _, line := range strings.Split(fm, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
meta[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
}
return meta, body
}
func SplitLeafs(meta map[string]string, body string) []chunk {
lines := strings.Split(body, "\n")
title := ""
type hdr struct {
heading string
start int
}
var headers []hdr
for i, line := range lines {
switch {
case h1.MatchString(line):
title = strings.TrimSpace(strings.TrimLeft(line, "#"))
case h2.MatchString(line):
headers = append(headers, hdr{strings.TrimSpace(strings.TrimLeft(line, "#")), i})
}
}
if len(headers) == 0 {
var kept []string
for _, l := range lines {
if strings.TrimSpace(l) != "" {
kept = append(kept, l)
}
}
return []chunk{{Heading: title, Text: strings.TrimSpace(strings.Join(kept, "\n"))}}
}
out := make([]chunk, 0, len(headers))
for idx, h := range headers {
end := len(lines)
if idx+1 < len(headers) {
end = headers[idx+1].start
}
var kept []string
for _, l := range lines[h.start:end] {
if strings.TrimSpace(l) != "" {
kept = append(kept, l)
}
}
text := strings.Join(kept, "\n")
if idx == 0 && title != "" {
text = title + "\n\n" + text
}
out = append(out, chunk{Heading: h.heading, Text: strings.TrimSpace(text)})
}
return out
}
func ToAll(text, path, repo string) []Leaf {
meta, body := ExtractFrontmatter(text)
if meta["type"] == "" {
meta["type"] = "reference"
}
if meta["status"] == "" {
meta["status"] = "current"
}
chunks := SplitLeafs(meta, body)
out := make([]Leaf, 0, len(chunks))
for _, c := range chunks {
out = append(out, Leaf{
Source: path,
Repo: repo,
Heading: c.Heading,
Text: c.Text,
Type: meta["type"],
Status: meta["status"],
Related: meta["related"],
})
}
return out
}
func WalkMarkdown(root string) ([]string, error) {
var out []string
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(p))
if ext == ".md" || ext == ".markdown" {
out = append(out, p)
}
return nil
})
return out, err
}
func EncodeJSON(leafs []Leaf) (string, error) {
raw, err := json.MarshalIndent(leafs, "", " ")
if err != nil {
return "", err
}
return string(raw) + "\n", nil
}
+73
View File
@@ -0,0 +1,73 @@
package mdleaves
import (
"strings"
"testing"
)
func TestSplitLeafsOnH2(t *testing.T) {
body := "# Title\n\n## One\n\nalpha\n\n## Two\n\nbeta\n"
leafs := SplitLeafs(map[string]string{}, body)
if len(leafs) != 2 {
t.Fatalf("n=%d", len(leafs))
}
if leafs[0].Heading != "One" || !strings.Contains(leafs[0].Text, "Title") {
t.Fatalf("first=%+v", leafs[0])
}
if leafs[1].Heading != "Two" || strings.Contains(leafs[1].Text, "Title") {
t.Fatalf("second=%+v", leafs[1])
}
}
func TestSplitLeafsNoH2IsWholeDoc(t *testing.T) {
body := "# Title\n\njust a paragraph\n"
leafs := SplitLeafs(nil, body)
if len(leafs) != 1 {
t.Fatalf("n=%d", len(leafs))
}
if leafs[0].Heading != "Title" {
t.Fatalf("heading=%q", leafs[0].Heading)
}
if !strings.Contains(leafs[0].Text, "just a paragraph") {
t.Fatalf("text=%q", leafs[0].Text)
}
}
func TestFrontmatterAndToAll(t *testing.T) {
raw := "---\ntype: howto\nstatus: current\nrelated: docs/design.md\n---\n# Doc\n\n## Step\n\ndo it\n"
got := ToAll(raw, "docs/x.md", "eSlider/2dph")
if len(got) != 1 {
t.Fatalf("n=%d", len(got))
}
if got[0].Type != "howto" || got[0].Status != "current" {
t.Fatalf("%+v", got[0])
}
if got[0].Source != "docs/x.md" || got[0].Repo != "eSlider/2dph" {
t.Fatalf("%+v", got[0])
}
if got[0].Related != "docs/design.md" {
t.Fatalf("related=%q", got[0].Related)
}
}
func TestEncodeJSONAndYAML(t *testing.T) {
leafs := ToAll("# Hi\n\nbody\n", "a.md", "")
js, err := EncodeJSON(leafs)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(js, `"heading": "Hi"`) {
t.Fatalf("json=%s", js)
}
y := EncodeYAML(leafs)
if !strings.Contains(y, "heading: Hi") {
t.Fatalf("yaml=%s", y)
}
}
func TestDefaultsTypeAndStatus(t *testing.T) {
got := ToAll("# Hi\n\nbody\n", "a.md", "")
if got[0].Type != "reference" || got[0].Status != "current" {
t.Fatalf("%+v", got[0])
}
}
+36
View File
@@ -0,0 +1,36 @@
package mdleaves
import (
"fmt"
"strconv"
"strings"
)
func EncodeYAML(leafs []Leaf) string {
if len(leafs) == 0 {
return "[]\n"
}
var b strings.Builder
for _, lf := range leafs {
b.WriteString("-\n")
writeKV(&b, "source", lf.Source)
writeKV(&b, "repo", lf.Repo)
writeKV(&b, "heading", lf.Heading)
writeKV(&b, "text", lf.Text)
writeKV(&b, "type", lf.Type)
writeKV(&b, "status", lf.Status)
writeKV(&b, "related", lf.Related)
}
return b.String()
}
func writeKV(b *strings.Builder, k, v string) {
fmt.Fprintf(b, " %s: %s\n", k, yamlScalar(v))
}
func yamlScalar(s string) string {
if strings.Contains(s, "\n") || s == "" || strings.ContainsAny(s, ":#'\"[]{}&*!|>%@`") || s != strings.TrimSpace(s) {
return strconv.Quote(s)
}
return s
}
+3 -2
View File
@@ -31,8 +31,9 @@ bin/brain/stats.go # index health
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
are not wired yet); do not treat it as a graph walk.
`bin/kb/search` is a deprecated wrapper. `--hop` errors (schema has
`FROM_FILE`; search does not walk it yet, [#17](https://git.produktor.io/eSlider/2dph/issues/17));
do not treat it as a graph walk.
## Rules