Compare commits

..
Author SHA1 Message Date
eSliderandGitHub 15d59054ff docs: delete agent-cost; rename kb-search skill to brain (#14)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
* docs: delete agent-cost; rename kb-search skill to brain.

bin/agents/cost does not exist. CI unittest now fails if a SKILL.md names a
missing bin/ path.

* test: gate SKILL.md bin/ paths; name the brain skill brain.

Follow-up to the agent-cost delete: unittest fails if a skill names a missing
tool. Frontmatter name is brain, not kb-search.
2026-08-13 17:55:41 +01:00
eSliderandGitHub 3f30052ea8 feat: in-process HTTP search; /get /stats /audit /ingest. (#13)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
bin/brain/serve.go (ladybug tags) calls internal/brain instead of exec.
HTTP tests inject a fake API so CI stays cgo-free. ExecSearcher remains
the fallback when the binary is built without system_ladybug.
2026-08-13 17:52:15 +01:00
eSliderandGitHub c1ee920b0a feat: brain/index.go shebang; mail import is not a brain write (D14). (#12)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Commands live at bin/brain/{index,get,stats,eval,watch}.go and
bin/mail/import.go, bin/markdown/import.go, bin/postgres/query.go.
Python remains the Ladybug write worker. index_mail is a deprecation
shim that rebuilds via --with-mail.
2026-08-13 17:46:25 +01:00
eSliderandGitHub cec0161ff6 refactor: chats method shebangs; drop chats index (D14). (#11)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Parsers and commands live in internal/chats. bin/chats/{sync,import,facts,apply}.go
are tagged shebang mains. Brain ingest is not a chats command.
2026-08-13 17:31:03 +01:00
eSliderandGitHub 46310f8773 docs: name bin/brain/search.go; --hop is not a graph walk. (#10)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Published docs and skills still taught bin/kb/search --hop 1. Search lives
at bin/brain/search.go; --hop errors until File edges exist. A unittest
gates the SoT so the lie cannot return.
2026-08-13 17:23:40 +01:00
eSliderandGitHub 7e0f3c9e06 feat: bin/brain/serve.go; search backend is Go not Python (#9)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
* feat(brain): HTTP serve from bin/brain/serve.go, default Go search binary.

Move the HTTP package to internal/httpapi. Default backend is
var/bin/brain-search, not Python. bin/serve.go stays as a deprecation shim.

* feat(httpapi): default search backend is var/bin/brain-search.

bin/brain/serve.go is the command; bin/serve.go stays as a tagged
deprecation shim. Tests fail if the default path still names Python.
2026-08-13 14:32:21 +01:00
eSliderandGitHub f14025304e refactor: one Go module; brain search in bin/brain + internal/brain. (#8)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Collapse nested kbsearch/chats go.mod into the root module. Ranking stays
cgo-free under internal/brain/rank so CI does not need ladybug. bin/kb/search
is a deprecation wrapper that still sets CGO and builds the binary.
2026-08-13 14:26:54 +01:00
eSliderandGitHub dd6d7e9395 docs: point issues at Gitea origin (D15). (#7)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
GitHub stays the public clone for PRs and Actions. Work board is
https://git.produktor.io/eSlider/2dph/issues.
2026-08-13 14:11:00 +01:00
eSliderandGitHub 68d478224f feat(chats): parse LinkedIn MCP v4.22 inbox/conversation blobs. (#6)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
get_inbox/get_conversation return a sections+references envelope, not a
message list. Parser is covered by synthetic Alice/Bob fixtures; CI now
runs the nested bin/chats tests. Session check no longer launches Chromium.
2026-08-13 12:25:51 +01:00
eSliderandGitHub 117f3c2cfd fix(kbsearch): rank FTS correctly, filter before -n, start the daemon. (#5)
Go search took worst BM25 hits (ORDER BY score), cut to -n before --root,
and never called ensureDaemon. Ranking and flag parsing move to a cgo-free
package so CI can fail those regressions without ladybug. --hop errors
instead of being swallowed into the query.
2026-08-13 12:19:26 +01:00
eSliderandGitHub 140d86a4b9 Add Gmail --query to mail/sync (default in:inbox) (#4)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
* Add --query to Gmail mail/sync instead of always listing in:inbox.

Callers keep the search string; default remains in:inbox.

* Document Gmail --query on the mail/sync pipeline.

* test(mail): assert Gmail --query reaches ListIDs, not only the CLI flag.

ParseCLI coverage left a hole: an empty query still has to become in:inbox
and a custom q has to be the string the client lists with.
2026-08-13 12:19:22 +01:00
eSlider c96c393a4a feat(chats): LinkedIn source — MCP client via get_inbox + get_conversation
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
- LinkedInMCPSource: MCP JSON-RPC, как TelegramMCPSource
- sync linkedin --limit N: выгрузка сообщений из LinkedIn
- Проверка сессии: uvx mcp-server-linkedin --status
- Вывод инструкции если сессия истекла
- JSONL в var/chats/linkedin/<thread_id>/messages.jsonl
2026-08-13 00:10:42 +01:00
eSlider 3d0d95cf00 docs: add edelweiss to GitHub safety rules
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
2026-08-13 00:07:01 +01:00
eSlider ed28fdbd2a chore: remove edelweiss references from public repo
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
2026-08-13 00:06:49 +01:00
eSlider 7e511d5b78 docs: GitHub safety rules — no absolute paths, PII, secrets, curasoft 2026-08-13 00:02:09 +01:00
eSlider 0d26519fab fix: resolve plan.md conflict, remove remaining /mnt/ paths 2026-08-13 00:00:48 +01:00
eSlider a429b823e5 chore: clean absolute paths, curasoft refs, secrets from history
- bin/chats/: env-based paths, no /mnt/ /home/ hardcodes
- bin/edelweiss-pilot: remove curasoft, use DOCS_BASE env var
- bin/facts/crm: use KNOWLEDGE_MESH_SEED env var
- compose.edelweiss.yml: remove curasoft volumes, use DOCS_BASE
- docs/chat-import-plan.md: link to Gitea issue, no secrets
- bin/seed-edelweiss-facts.py: removed (curasoft-only)
2026-08-13 00:00:15 +01:00
eSlider 6847233183 bin/chats: Phase 1 MVP — Telegram sync/import/index/facts/apply
- bin/chats/ — nested Go module (как bin/kbsearch/)
  - sync telegram — MCP JSON-RPC клиент, 31 личный чат, 922 сообщения
  - import — конвертация JSONL → MD с YAML frontmatter
  - index — делегирует bin/kb/index --corpus (132 leafs в brain)
  - facts — regex extraction phone/email/linkedin с валидацией
    (исключены: даты, суммы, номера карт, инвойсы)
  - apply — oo CLI cross-check + dry-run
- Source interface для будущих WhatsApp/LinkedIn
- 4 system tests (import, facts, empty, roundtrip) — синтетические данные
- bin/chat — build+exec wrapper
- docs/chat-import-plan.md — прогресс, пути к env (без секретов)

Безопасность: var/ в gitignore, credentials в env, тесты без реальных данных.
2026-08-12 23:59:29 +01:00
eSlider 6d7638ab73 docs: chat import pipeline plan — link to Gitea issue #1 2026-08-12 23:59:20 +01:00
eSliderandCursor bd1a91dab7 fix(kb): seed facts before CREATE indexes (FTS MERGE corruption)
Upsert under live FTS raises "document for node offset N is missing".
Add --skip-indexes; edelweiss-pilot index = write → seed → ensure_indexes.
Ship seed-edelweiss-facts.py (paired lexicon/OO/interview/QEMU facts).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:09:02 +01:00
eSliderandCursor a5a1f91d95 fix(kb): stop DROP INDEX killing HNSW via Ladybug ghost catalog
Ladybug 0.19 DROP INDEX leaves `_0_Leaf_vec_UPPER` / `0_id_docs` in catalog so
CREATE fails while SHOW_INDEXES omits the index; create_fts_and_vector used to
swallow that. Never drop FTS/VECTOR; ensure_indexes after upserts; rebuild =
delete kb.lbug. Add compose.edelweiss.yml + regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:05:14 +01:00
eSlider 80e3b7a1cf Remove curasoft references, rename to detective method
- PLAN.md: replace 'curasoft-detective' with 'detective method'
- README.md: replace curasoft-detective link with plain reference
- test_websearch.py: fix test domain from ticket.curasoft.de to example.com
- Rewrote git history with git-filter-repo to remove all traces
2026-08-12 13:46:54 +01:00
eSlider ef4189c72d kbsearch: Go implementation with daemon model serving
- New nested module bin/kbsearch with Go implementation of bin/kb/search
- Embedding model (potion-multilingual-128M) served by localhost daemon
  so repeated CLI calls reuse the loaded model
- Bash launcher bin/kb/search builds binary on first run, caches to var/bin/
- Hybrid FTS + vector search (RRF k=60) matching Python kblib behavior
- YAML output via port of yamlout.py (ordered keys, same format)
- JSON output with proper field order
- All flags: --root, --repo, -n, --json, --list-model
- Root go.mod reverted to 1.25.0 (kbsearch is isolated nested module)
- CI passes: go test ./... and go vet ./... unaffected by kbsearch
2026-08-11 23:57:39 +01:00
eSlider 60c20ed98d feat(mail): full Gmail+OnlyOffice sync, import, and brain indexing
- bin/mail/sync.go: async Go sync engine (8 workers, paginated Gmail via
  API + OnlyOffice IMAP); Gmail attachments key off body.attachmentId, not
  MIME partId; ICS sidecars Latin-1->UTF-8 normalized (TestICSToMarkdownNormalizesLatin1)
- bin/mail/import: message.json -> markdown; PDFs via pdftotext -layout
  fast path with docling subprocess fallback for the ~5% textless files
- bin/mail/index_mail: fresh-rebuild indexer (repo corpus + mail) avoiding
  ladybug WAL corruption on bulk-insert into indexed DBs; split from import
- bin/kb/index: keep FTS/VECTOR indexes across incremental runs (drop+recreate
  leaves stale backing tables killing the vector index)
- docs: README/PLAN/AGENTS cover the mail pipeline

Result: 17,835 messages -> 28,918 info leafs, FTS+HNSW healthy.
2026-08-11 21:57:38 +01:00
eSlider 9f22380e82 refactor(tools): bin/{subject}/{method} layout; Go serve+watch modules
Move serve/ (module) -> bin/server, tools/ -> bin/tools, replace bin/kb-watch
bash with bin/watch Go package; self-executing Go shebangs bin/serve.go and
bin/kb/watch.go; Docker + CI + git/import + docs repointed. Multi-stage image
builds static serve+watch binaries (no Go runtime in container).
2026-08-11 09:52:20 +01:00
eSlider e2eff3b9c7 feat(kb): CRM association proof via oo, fix ssh-tunnel self-ref + oo creds
- bin/facts/crm: prove person<->company/company<->project against ooCRM
  x corpus SoT (knowledge-mesh-seed.yaml), write 78 facts (root=facts)
- tools/crmfacts.py + test_crm_facts.py: parser under unit tests (26 pass)
- docs/crm-associations-proof.md: provable graph, mistakes, fixes
- oo merge 759->763 resolves duplicate GoldenRatio.Exchange legal entity
- bin/db/ssh-tunnel: "$0" self-check + accept-new/BatchMode ssh flags
- AGENTS.md: document bin/facts/crm
2026-08-10 23:22:34 +01:00
28 changed files with 469 additions and 1883 deletions
+1 -5
View File
@@ -41,9 +41,7 @@ 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/mail/ sync.go import.go (index_mail → brain/index.go)
bin/markdown/ import.go (mistune leafs) bin/markdown/ import.go (mistune leafs)
bin/postgres/ query.go (read-only YAML) bin/postgres/ query.go (read-only YAML)
bin/git/ import.go (go-git history; Python shim execs it) internal/ shared Go (brain/rank is cgo-free; chats parsers too)
bin/web/ search.go (SearXNG; Python shim execs it)
internal/ shared Go (brain/rank is cgo-free; chats parsers; gitlog; websearch)
bin/watch/ corpus watcher (used by bin/brain/watch.go) bin/watch/ corpus watcher (used by bin/brain/watch.go)
bin/tools/ vendored python libs behind bin/* (kblib, yamlout, websearch) bin/tools/ vendored python libs behind bin/* (kblib, yamlout, websearch)
bin/docker-entrypoint container entrypoint (brain index|search|serve|watch) bin/docker-entrypoint container entrypoint (brain index|search|serve|watch)
@@ -83,8 +81,6 @@ bin/kb/search "query" [--repo X] # deprecated wrapper → bin/b
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/get.go <id> [--body]
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/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
bin/postgres/query.go --profile onlyoffice -c 'SELECT 1' bin/postgres/query.go --profile onlyoffice -c 'SELECT 1'
bin/md/tables # what the graph holds → YAML bin/md/tables # what the graph holds → YAML
bin/brain/deduce "question" # thinking wrapper bin/brain/deduce "question" # thinking wrapper
+2 -5
View File
@@ -26,7 +26,7 @@ detective method: **a fact needs ≥2 independent sources or it is
|---|----------|--------| |---|----------|--------|
| D1 | RAG corpus | ops stack (chat, onlyoffice, gitea/NPM, searchxng, observability, ai-bot, mcp-servers, `~/.ssh/config`) + portfolio. Exclude `office.dev` + jobs/applications. | | D1 | RAG corpus | ops stack (chat, onlyoffice, gitea/NPM, searchxng, observability, ai-bot, mcp-servers, `~/.ssh/config`) + portfolio. Exclude `office.dev` + jobs/applications. |
| D2 | skill merging | integrate skills **in this project** `skills/`; skip gitea / brain-dependent skills. | | D2 | skill merging | integrate skills **in this project** `skills/`; skip gitea / brain-dependent skills. |
| 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 | Vendored client; SearXNG URL is config. Optional Compose instance (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`); Python remains for index/write until the Go write path is safe. |
@@ -42,7 +42,6 @@ detective method: **a fact needs ≥2 independent sources or it is
| 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 sources → web), not every edit. 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`. |
## Architecture ## Architecture
@@ -62,13 +61,11 @@ detective method: **a fact needs ≥2 independent sources or it is
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)
git/import.go go-git history (no git binary; conversion only)
web/search.go SearXNG client (throttled ≠ absence)
chats/sync.go import.go facts.go apply.go chats/sync.go import.go facts.go apply.go
(libs in internal/chats; no chats index) (libs in internal/chats; no chats index)
md/import (deprecated; bin/markdown/import.go) md/import (deprecated; bin/markdown/import.go)
brain/extract brain/audit brain/deduce (thinking wrapper) brain/extract brain/audit brain/deduce (thinking wrapper)
web/search (deprecated shim → web/search.go) web/search (vendored)
db/psql-yq (vendored) db/psql-yq (vendored)
ssh-tunnel onlyoffice pg tunnel 5433 ssh-tunnel onlyoffice pg tunnel 5433
var/kb.lbug single embedded store (gitignored) var/kb.lbug single embedded store (gitignored)
-17
View File
@@ -95,23 +95,6 @@ bin/brain/eval.go # recall@5 gate
`--hop` is not implemented (needs File/FROM_FILE edges); the flag errors instead of walking. `bin/kb/search` is a deprecated wrapper around `bin/brain/search.go`. `--hop` is not implemented (needs File/FROM_FILE edges); the flag errors instead of walking. `bin/kb/search` is a deprecated wrapper around `bin/brain/search.go`.
Git history is read with [go-git](https://github.com/go-git/go-git) (no git binary):
```bash
bin/git/import.go --json --limit 100 # commit leafs for this repo
bin/git/import.go --root "$PROJECTS_ROOT" --json # one pass per .git under root
```
Conversion only. Graph write (`File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person`) stays with `bin/brain/index.go`.
Web search (second independent source) goes through SearXNG. Empty results mean **throttled**, not “nothing exists”:
```bash
bin/web/search.go "LadybugDB vector index" --json
# Optional local instance (skip if BRAIN_SEARCH_URL already points at one):
# SEARXNG_SECRET=$(openssl rand -hex 32) docker compose --profile searxng up -d
```
Mail is a first-class corpus (retrievable through the same search): Mail is a first-class corpus (retrievable through the same search):
```bash ```bash
+139 -11
View File
@@ -1,26 +1,154 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""git/import — deprecated. Use bin/git/import.go (go-git, no git binary). """git/import - import git history (commits, authors, files) into the brain.
bin/git/import.go [REPO] [--json] [--limit N] [--since DATE] bin/git/import [REPO] import all commits -> leafs + graph
bin/git/import --json emit import leafs as JSON, no write
bin/git/import --limit 100 cap commits processed
bin/git/import --since 2026-01-01 only recent commits
bin/git/import --root DIR run per repo dir under DIR
bin/git/import --no-env never read .env anywhere (default: true)
Reads `git log --no-merges --name-only` from the repo, maps commits to
`info` leafs (root=info, type=commit) and writes the version graph
`File -[:HAS_VERSION]-> Commit -[:AUTHORED]-> Person` into var/kb.lbug.
Idempotent: leaf MERGE by (source,text via leaf_id), graph MERGE by sha.
""" """
from __future__ import annotations from __future__ import annotations
import os import json
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "bin" / "tools"))
from kblib import ( # noqa: E402
connect, ensure_indexes, init_schema, upsert_leaf,
)
from gitimport import commits_to_leafs, ensure_git_schema, index_commits, parse_log # noqa: E402
LOG_FMT = "--format=%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s"
def git_log(repo: Path, limit: int = 0, since: str = "") -> str:
cmd = ["git", "-C", str(repo), "log", "--no-merges", "--name-only", LOG_FMT]
if since:
cmd += ["--since", since]
if limit:
cmd += ["-n", str(limit)]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
if out.returncode != 0:
print(f"git/import: {repo}: {out.stderr.strip()}", file=sys.stderr)
return ""
return out.stdout
def repo_name(repo: Path) -> str:
try:
out = subprocess.run(
["git", "-C", str(repo), "remote", "get-url", "origin"],
capture_output=True, text=True, timeout=20)
url = out.stdout.strip()
return url.rstrip("/").split("/")[-1].removesuffix(".git") if url else repo.name
except (FileNotFoundError, subprocess.TimeoutExpired):
return repo.name
def embedder():
from model2vec import StaticModel
model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
return lambda text: model.encode([text])[0].astype(float).tolist()
def import_repo(conn, repo: Path, embed, limit: int, since: str,
no_write: bool = False) -> tuple[int, int]:
raw = git_log(repo, limit, since)
commits = parse_log(raw)
leafs = commits_to_leafs(commits, repo_name(repo))
if no_write:
return len(commits), 0
written = 0
for lf in leafs:
query = f"{lf['heading']}\n\n{lf['text']}"
emb = embed(lf["text"]) if lf["text"] else None
upsert_leaf(conn, text=query, root="info", confidence="confirmed",
source=lf["source"], source_rev="git", how="git/import",
loc=lf["source"], type_=lf.get("type", "commit"),
embedding=emb)
written += 1
index_commits(conn, commits, repo_name(repo))
return len(commits), written
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
print( import argparse
"bin/git/import is deprecated; use bin/git/import.go (go-git)", p = argparse.ArgumentParser(description="import git history into the brain")
file=sys.stderr, p.add_argument("repo", nargs="?", default=None)
) p.add_argument("--root", default=None, help="directory of repos to import (each git dir separately)")
target = ROOT / "bin" / "git" / "import.go" p.add_argument("--limit", type=int, default=0)
os.execvp("go", ["go", "run", str(target), *argv]) p.add_argument("--since", default="")
return 1 p.add_argument("--json", action="store_true")
p.add_argument("--dry-run", action="store_true", help="parse + report, no db write")
a = p.parse_args(argv)
repos: list[Path] = []
if a.repo:
repos = [Path(a.repo)]
elif a.root:
root = Path(a.root)
if root.is_file():
repos = [root]
else:
repos = [dp for dp in sorted(root.iterdir()) if (dp / ".git").exists() or dp.is_file()]
else:
repos = [ROOT]
total_commits = 0
results: list[dict] = []
if a.dry_run:
for repo in repos:
if not repo.exists():
continue
commits = parse_log(git_log(repo, a.limit, a.since))
name = repo_name(repo)
total_commits += len(commits)
results.append({"repo": name, "commits": len(commits),
"leafs": len(commits_to_leafs(commits, name)), "path": str(repo)})
if a.json:
print(json.dumps(results, indent=2))
else:
for r in results:
print(f"{r['repo']:<24} {r['commits']:>5} commits -> {r['leafs']} leafs {r['path']}")
return 0
# Never DROP FTS/VECTOR (ghost catalog). Upsert while indexes exist is OK;
# ensure_indexes only CREATEs when missing.
db, conn = connect(ROOT / "var" / "kb.lbug", read_only=False)
init_schema(conn)
embed = embedder()
rows: list[dict] = []
for repo in repos:
if not repo.exists():
continue
reached, written = import_repo(conn, repo, embed, a.limit, a.since)
total_commits += reached
rows.append({"repo": repo_name(repo), "commits": reached, "written": written})
ensure_indexes(conn)
conn.close()
db.close()
if a.json:
print(json.dumps(rows, indent=2))
else:
for r in rows:
print(f"imported {r['commits']:>5} commits -> {r['written']} leafs {r['repo']}")
print(f"total: {total_commits} commits")
return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) sys.exit(main(sys.argv[1:]))
-143
View File
@@ -1,143 +0,0 @@
//usr/bin/env go run "$0" "$@"; exit
//
// bin/git/import.go - read git history with go-git (no git binary).
//
// ./bin/git/import.go [REPO]
// ./bin/git/import.go --json
// ./bin/git/import.go --limit 100 --since 2026-01-01
// ./bin/git/import.go --root DIR
//
// Conversion only: prints commit leafs. Brain write is bin/brain/index.go.
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/eSlider/2dph/internal/cmdbin"
"github.com/eSlider/2dph/internal/gitlog"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var repo, root, since string
limit := 0
jsonOut := false
i := 0
for i < len(args) {
a := args[i]
switch {
case a == "--json":
jsonOut = true
case a == "--limit" && i+1 < len(args):
i++
n, err := strconv.Atoi(args[i])
if err != nil || n < 0 {
fmt.Fprintf(os.Stderr, "git/import: --limit must be a non-negative integer\n")
return 2
}
limit = n
case a == "--since" && i+1 < len(args):
i++
since = args[i]
case a == "--root" && i+1 < len(args):
i++
root = args[i]
case a == "-h" || a == "--help":
fmt.Fprintln(os.Stderr, `usage: bin/git/import.go [REPO] [--json] [--limit N] [--since DATE] [--root DIR]`)
return 0
case len(a) > 0 && a[0] != '-':
repo = a
default:
fmt.Fprintf(os.Stderr, "git/import: unknown flag %s\n", a)
return 2
}
i++
}
var sinceT time.Time
if since != "" {
var err error
sinceT, err = parseSince(since)
if err != nil {
fmt.Fprintf(os.Stderr, "git/import: %v\n", err)
return 2
}
}
repos := []string{}
if repo != "" {
repos = []string{repo}
} else if root != "" {
entries, err := os.ReadDir(root)
if err != nil {
fmt.Fprintf(os.Stderr, "git/import: %v\n", err)
return 1
}
for _, e := range entries {
p := filepath.Join(root, e.Name())
if _, err := os.Stat(filepath.Join(p, ".git")); err == nil {
repos = append(repos, p)
}
}
} else {
repos = []string{cmdbin.Root()}
}
opt := gitlog.Options{Limit: limit, Since: sinceT}
type row struct {
Repo string `json:"repo"`
Path string `json:"path"`
Commits int `json:"commits"`
Leafs []gitlog.Leaf `json:"leafs,omitempty"`
}
var rows []row
for _, p := range repos {
name, err := gitlog.RepoName(p)
if err != nil && name == "" {
fmt.Fprintf(os.Stderr, "git/import: %s: %v\n", p, err)
continue
}
cs, err := gitlog.Log(p, opt)
if err != nil {
fmt.Fprintf(os.Stderr, "git/import: %s: %v\n", p, err)
return 1
}
leafs := make([]gitlog.Leaf, 0, len(cs))
for _, c := range cs {
leafs = append(leafs, gitlog.ToLeaf(c, name))
}
rows = append(rows, row{Repo: name, Path: p, Commits: len(cs), Leafs: leafs})
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(rows); err != nil {
return 1
}
return 0
}
for _, r := range rows {
fmt.Printf("%-24s %5d commits %s\n", r.Repo, r.Commits, r.Path)
}
return 0
}
func parseSince(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse --since %q", s)
}
+61 -4
View File
@@ -1,12 +1,21 @@
"""gitimport - Ladybug graph writes for Commit/File/Person (no git binary). """gitimport - parse `git log` output and turn commits into brain leafs.
Commit records come from bin/git/import.go (go-git). This module only MERGEs Pure, testable functions. Field grammar (see bin/git/import):
the version graph File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person.
git log --no-merges --name-only \
--format='%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s'
0x1e = record separator, 0x1f = field separator.
Files: newline-separated lines following each record's subject.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
REC_SEP = "\x1e"
FIELD_SEP = "\x1f"
@dataclass @dataclass
class Commit: class Commit:
@@ -17,6 +26,54 @@ class Commit:
subject: str subject: str
files: list[str] = field(default_factory=list) files: list[str] = field(default_factory=list)
def leaf_text(self, repo: str) -> str:
head = f"commit {self.sha[:12]} in {repo}{self.subject}"
body = [head, f"Author: {self.author} <{self.email}>", f"Date: {self.date}"]
if self.files:
body.append("Changing: " + ", ".join(self.files))
return "\n".join(body)
def parse_log(text: str) -> list[Commit]:
"""Parse `git log` output into Commit records.
Records are separated by 0x1e. A record is fields joined by 0x1f,
followed by optional newline-separated file paths inside the next
segment (git emits blank line + files after each record).
"""
commits: list[Commit] = []
# field records and file lists alternate; simpler: split on REC_SEP,
# each chunk = header line, possibly followed by newline + files.
for chunk in text.split(REC_SEP):
chunk = chunk.strip("\n")
if not chunk:
continue
lines = chunk.split("\n", 1)
header = lines[0].split(FIELD_SEP)
if len(header) < 5:
continue
sha, author, email, date, subject = header[:5]
files = [ln.strip() for ln in lines[1].splitlines() if ln.strip()] if len(lines) > 1 else []
commits.append(Commit(sha=sha, author=author, email=email,
date=date, subject=subject, files=files))
return commits
def commits_to_leafs(commits: list[Commit], repo: str) -> list[dict]:
"""Map commits to the leaf shape bin/kb/index expects (source/repo/...)."""
out: list[dict] = []
for c in commits:
out.append({
"source": f"{repo}@{c.sha}",
"repo": repo,
"heading": f"commit {c.sha[:12]}{c.subject}",
"text": c.leaf_text(repo),
"type": "commit",
"status": "current",
"related": ",".join(c.files),
})
return out
GIT_SCHEMA = ( GIT_SCHEMA = (
"CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, " "CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, "
@@ -57,4 +114,4 @@ def index_commits(conn, commits: list[Commit], repo: str) -> int:
conn.execute("MATCH (f:File {id:$fid}), (c:Commit {id:$sha}) " conn.execute("MATCH (f:File {id:$fid}), (c:Commit {id:$sha}) "
"MERGE (f)-[:HAS_VERSION]->(c)", "MERGE (f)-[:HAS_VERSION]->(c)",
parameters={"fid": f"{repo}:{path}", "sha": c.sha}) parameters={"fid": f"{repo}:{path}", "sha": c.sha})
return len(commits) return len(commits)
-28
View File
@@ -91,31 +91,3 @@ class BinLayoutTest(unittest.TestCase):
def test_postgres_query_is_shebang(self) -> None: def test_postgres_query_is_shebang(self) -> None:
self._assert_shebang("bin/postgres/query.go") self._assert_shebang("bin/postgres/query.go")
def test_git_import_is_gogit_shebang(self) -> None:
self._assert_shebang("bin/git/import.go")
py = (ROOT / "bin" / "git" / "import").read_text()
self.assertNotIn(
'["git"',
py,
"Python git/import must not subprocess the git binary",
)
self.assertIn("bin/git/import.go", py)
def test_web_search_is_shebang(self) -> None:
self._assert_shebang("bin/web/search.go")
py = (ROOT / "bin" / "web" / "search").read_text()
self.assertIn("bin/web/search.go", py)
def test_gitimport_py_has_no_git_binary(self) -> None:
py = (ROOT / "bin" / "tools" / "gitimport.py").read_text()
self.assertNotIn("subprocess", py)
self.assertNotIn("git log", py)
def test_gogit_is_direct_go_mod_require(self) -> None:
text = (ROOT / "go.mod").read_text()
first = text.split("require (")[1].split(")")[0]
self.assertRegex(first, r"github.com/go-git/go-git/v5\s+v")
for line in first.splitlines():
if "go-git/go-git" in line:
self.assertNotIn("indirect", line)
+11 -14
View File
@@ -9,6 +9,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import kblib # noqa: E402 import kblib # noqa: E402
import gitimport # noqa: E402 import gitimport # noqa: E402
SAMPLE = (
"\x1e" + "a1b2c3d" + "\x1f" + "Ada Lovelace" + "\x1f" + "ada@example.com"
+ "\x1f" + "2026-08-10T12:00:00+01:00" + "\x1f" + "feat: first commit"
+ "\n\nREADME.md\nsrc/main.c\n"
)
COMMIT_PERSON_SCHEMA = ( COMMIT_PERSON_SCHEMA = (
"CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, " "CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, "
"author STRING, email STRING, date STRING, PRIMARY KEY(id))" "author STRING, email STRING, date STRING, PRIMARY KEY(id))"
@@ -20,17 +26,6 @@ HAS_VERSION_SCHEMA = "CREATE REL TABLE IF NOT EXISTS HAS_VERSION (FROM File TO C
AUTHORED_SCHEMA = "CREATE REL TABLE IF NOT EXISTS AUTHORED (FROM Commit TO Person)" AUTHORED_SCHEMA = "CREATE REL TABLE IF NOT EXISTS AUTHORED (FROM Commit TO Person)"
def sample_commit() -> gitimport.Commit:
return gitimport.Commit(
sha="a1b2c3d",
author="Ada Lovelace",
email="ada@example.com",
date="2026-08-10T12:00:00+01:00",
subject="feat: first commit",
files=["README.md", "src/main.c"],
)
class GitGraphTest(unittest.TestCase): class GitGraphTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.dir = tempfile.mkdtemp() self.dir = tempfile.mkdtemp()
@@ -47,12 +42,14 @@ class GitGraphTest(unittest.TestCase):
self.db.close() self.db.close()
def test_index_commits_creates_nodes_and_edges(self): def test_index_commits_creates_nodes_and_edges(self):
gitimport.index_commits(self.conn, [sample_commit()], "sample-repo") cs = gitimport.parse_log(SAMPLE)
gitimport.index_commits(self.conn, cs, "sample-repo")
rp = self.conn.execute("MATCH (p:Person) RETURN p.name, p.email").get_all() rp = self.conn.execute("MATCH (p:Person) RETURN p.name, p.email").get_all()
self.assertEqual([tuple(r) for r in rp], [("Ada Lovelace", "ada@example.com")]) self.assertEqual([tuple(r) for r in rp], [("Ada Lovelace", "ada@example.com")])
rc = self.conn.execute("MATCH (c:Commit) RETURN c.id, c.repo").get_all() rc = self.conn.execute("MATCH (c:Commit) RETURN c.id, c.repo").get_all()
self.assertEqual(len(rc), 1) self.assertEqual(len(rc), 1)
self.assertEqual(rc[0][1], "sample-repo") self.assertEqual(rc[0][1], "sample-repo")
# File -[:HAS_VERSION]-> Commit -[:AUTHORED]-> Person
rf = self.conn.execute( rf = self.conn.execute(
"MATCH (f:File)-[:HAS_VERSION]->(c:Commit)-[:AUTHORED]->(p:Person) " "MATCH (f:File)-[:HAS_VERSION]->(c:Commit)-[:AUTHORED]->(p:Person) "
"RETURN f.path, c.id, p.email").get_all() "RETURN f.path, c.id, p.email").get_all()
@@ -61,7 +58,7 @@ class GitGraphTest(unittest.TestCase):
self.assertTrue(all(r[2] == "ada@example.com" for r in rf)) self.assertTrue(all(r[2] == "ada@example.com" for r in rf))
def test_index_commits_idempotent(self): def test_index_commits_idempotent(self):
cs = [sample_commit()] cs = gitimport.parse_log(SAMPLE)
gitimport.index_commits(self.conn, cs, "sample-repo") gitimport.index_commits(self.conn, cs, "sample-repo")
gitimport.index_commits(self.conn, cs, "sample-repo") gitimport.index_commits(self.conn, cs, "sample-repo")
n = self.conn.execute("MATCH (c:Commit) RETURN count(*)").get_all()[0][0] n = self.conn.execute("MATCH (c:Commit) RETURN count(*)").get_all()[0][0]
@@ -71,4 +68,4 @@ class GitGraphTest(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+57
View File
@@ -0,0 +1,57 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import gitimport # noqa: E402
SAMPLE = (
"\x1e" + "a1b2c3d" + "\x1f" + "Ada Lovelace" + "\x1f" + "ada@example.com"
+ "\x1f" + "2026-08-10T12:00:00+01:00" + "\x1f" + "feat: first commit"
+ "\n\nREADME.md\nsrc/main.c\n"
+ "\x1e" + "e4f5a6b" + "\x1f" + "Bob Babbage" + "\x1f" + "bob@example.com"
+ "\x1f" + "2026-08-11T09:30:00+01:00" + "\x1f" + "fix: typo"
+ "\n\ndocs/notes.md"
)
class GitparseTest(unittest.TestCase):
def test_parses_records(self):
cs = gitimport.parse_log(SAMPLE)
self.assertEqual(len(cs), 2)
def test_parses_commit_fields(self):
cs = gitimport.parse_log(SAMPLE)
c = cs[0]
self.assertEqual(c.sha, "a1b2c3d")
self.assertEqual(c.author, "Ada Lovelace")
self.assertEqual(c.email, "ada@example.com")
self.assertEqual(c.date, "2026-08-10T12:00:00+01:00")
self.assertEqual(c.subject, "feat: first commit")
def test_parses_changed_files(self):
cs = gitimport.parse_log(SAMPLE)
self.assertEqual(cs[0].files, ["README.md", "src/main.c"])
self.assertEqual(cs[1].files, ["docs/notes.md"])
def test_ignores_empty(self):
self.assertEqual(gitimport.parse_log(""), [])
def test_skip_malformed_record(self):
self.assertEqual(gitimport.parse_log("\x1eweird\x1e"), [])
def test_commit_leaf_shape(self):
leafs = gitimport.commits_to_leafs(gitimport.parse_log(SAMPLE), "sample-repo")
self.assertEqual(len(leafs), 2)
lf = leafs[0]
self.assertEqual(lf["type"], "commit")
self.assertEqual(lf["repo"], "sample-repo")
self.assertEqual(lf["source"], "sample-repo@a1b2c3d")
self.assertIn("Ada Lovelace", lf["text"])
self.assertIn("README.md", lf["related"])
self.assertIn("feat: first commit", lf["heading"])
if __name__ == "__main__":
unittest.main()
-20
View File
@@ -39,26 +39,6 @@ class PublishedDocsTest(unittest.TestCase):
"mail index is a brain write; README must name bin/brain/index.go", "mail index is a brain write; README must name bin/brain/index.go",
) )
def test_readme_git_import_is_gogit(self) -> None:
text = (ROOT / "README.md").read_text()
self.assertIn("bin/git/import.go", text)
self.assertIn("go-git", text)
self.assertIn("D19", (ROOT / "PLAN.md").read_text())
def test_web_search_is_go_not_ops_host(self) -> None:
readme = (ROOT / "README.md").read_text()
self.assertIn("bin/web/search.go", readme)
skill = (ROOT / "skills" / "web-search" / "SKILL.md").read_text()
self.assertIn("bin/web/search.go", skill)
self.assertNotIn("search.ops.io", skill)
self.assertNotIn("search.ops.io", readme)
compose = (ROOT / "compose.yaml").read_text()
self.assertIn("searxng", compose)
self.assertNotIn("search.ops.io", compose)
settings = (ROOT / "deploy" / "searxng" / "settings.yml").read_text()
self.assertNotIn("password", settings.lower())
self.assertIn("json", settings)
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",
+136 -12
View File
@@ -1,26 +1,150 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""web/search — deprecated. Use bin/web/search.go (SearXNG, no Python client). """web/search - web search through the self-hosted SearXNG at search.ops.io.
bin/web/search.go QUERY [--json] [-n N] [--site HOST] bin/web/search "LadybugDB vector search"
bin/web/search "model2vec multilingual" --site github.com
bin/web/search "uclancy" --category it -n 3 --json | jq -r '.results[].url'
bin/web/search "sqlite-vec" --refresh # ignore the cached answer
This complements bin/kb/search: the knowledge base holds our own facts, this
reaches the public web. Use it as the second, independent source that the
detective method asks for.
Exit codes: 0 results, 2 refused as possible PII, 3 throttled (not "nothing
found" - the instance answers 200 with an empty list when it throttles).
""" """
from __future__ import annotations from __future__ import annotations
import argparse
import fcntl
import json
import os import os
import sys import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] TOOLS = Path(__file__).resolve().parents[1] / "tools"
sys.path.insert(0, str(TOOLS))
sys.path.insert(0, str(TOOLS / "web-search"))
import websearch as ws # noqa: E402
from yamlout import to_yaml # noqa: E402
CONFIG = Path(os.environ.get("BRAIN_SEARCH_ENV", Path.home() / ".config/brain/search.env"))
CACHE = Path(os.environ.get("BRAIN_SEARCH_CACHE", Path.home() / ".cache/brain/web-search.sqlite"))
LOCK = CACHE.with_suffix(".lock")
def main(argv: list[str]) -> int: def load_config() -> dict:
print( if not CONFIG.exists():
"bin/web/search is deprecated; use bin/web/search.go", sys.exit(f"no credentials at {CONFIG} (mode 600, BRAIN_SEARCH_URL/USER/PASS)")
file=sys.stderr, conf = {}
) for line in CONFIG.read_text().splitlines():
target = ROOT / "bin" / "web" / "search.go" line = line.strip()
os.execvp("go", ["go", "run", str(target), *argv]) if not line or line.startswith("#") or "=" not in line:
return 1 continue
key, _, value = line.partition("=")
conf[key.strip()] = value.strip().strip("\"'")
missing = {"BRAIN_SEARCH_URL", "BRAIN_SEARCH_USER", "BRAIN_SEARCH_PASS"} - conf.keys()
if missing:
sys.exit(f"{CONFIG} is missing {', '.join(sorted(missing))}")
return conf
def fetch(conf: dict, query: str, params: dict, timeout: int) -> dict:
args = {"q": query, "format": "json", **params}
url = f"{conf['BRAIN_SEARCH_URL'].rstrip('/')}/search?{urllib.parse.urlencode(args)}"
request = urllib.request.Request(url)
token = f"{conf['BRAIN_SEARCH_USER']}:{conf['BRAIN_SEARCH_PASS']}".encode()
import base64
request.add_header("Authorization", "Basic " + base64.b64encode(token).decode())
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode())
def main() -> int:
parser = argparse.ArgumentParser(description="web search via SearXNG")
parser.add_argument("query")
parser.add_argument("-n", "--limit", type=int, default=ws.DEFAULT_LIMIT)
parser.add_argument("--site", help="restrict to one domain")
parser.add_argument("--lang", help="language code, e.g. de")
parser.add_argument("--fresh", choices=["day", "week", "month", "year"],
help="time range")
parser.add_argument("--category", help="SearXNG category, e.g. it, science, news")
parser.add_argument("--engines", help="comma separated engine list")
parser.add_argument("--json", action="store_true")
parser.add_argument("--refresh", action="store_true", help="bypass the cache")
parser.add_argument("--ttl", type=float, default=ws.CACHE_TTL)
parser.add_argument("--timeout", type=int, default=25)
parser.add_argument("--force", action="store_true",
help="send even if the query looks like PII")
args = parser.parse_args()
query = f"site:{args.site} {args.query}" if args.site else args.query
reason = ws.phi_reason(query)
if reason and not args.force:
print(f"refused: {reason}. This query would leave the host.", file=sys.stderr)
print("Rephrase without identifiers, or pass --force if it is genuinely public.",
file=sys.stderr)
return 2
params = {}
if args.lang:
params["language"] = args.lang
if args.fresh:
params["time_range"] = args.fresh
if args.category:
params["categories"] = args.category
if args.engines:
params["engines"] = args.engines
key = ws.cache_key(query, params)
conn = ws.open_cache(CACHE)
if not args.refresh:
cached = ws.cache_get(conn, key, ttl=args.ttl)
if cached is not None:
out = ws.project(cached, limit=args.limit)
out["cached"] = True
sys.stdout.write(json.dumps(out, indent=2, ensure_ascii=False) + "\n"
if args.json else to_yaml(out))
return 0
conf = load_config()
LOCK.parent.mkdir(parents=True, exist_ok=True)
# One request at a time across every agent on this host: the instance
# suspends engines for minutes when several of us ask at once.
with open(LOCK, "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
payload = None
for attempt in range(1 + len(ws.RETRY_BACKOFF)):
delay = ws.wait_for(ws.last_call(conn), time.time())
if delay:
time.sleep(delay)
ws.mark_call(conn)
try:
payload = fetch(conf, query, params, args.timeout)
except Exception as error: # noqa: BLE001 - report, do not crash
print(f"request failed: {error}", file=sys.stderr)
return 3
if ws.classify(payload) == "ok":
break
if attempt < len(ws.RETRY_BACKOFF):
time.sleep(ws.RETRY_BACKOFF[attempt])
if ws.classify(payload) == "ok":
ws.cache_put(conn, key, payload)
out = ws.project(payload, limit=args.limit)
sys.stdout.write(json.dumps(out, indent=2, ensure_ascii=False) + "\n"
if args.json else to_yaml(out))
return 0 if out["status"] == "ok" else 3
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) sys.exit(main())
-232
View File
@@ -1,232 +0,0 @@
//usr/bin/env go run "$0" "$@"; exit
//
// bin/web/search.go - SearXNG as the second independent source (D3).
//
// ./bin/web/search.go "LadybugDB vector search"
// ./bin/web/search.go "model2vec" --category it --json
// ./bin/web/search.go "postgres" --site github.com --fresh year
//
// Empty results mean throttled, not "nothing exists". Exit 2 = PII refuse, 3 = throttled.
// Config: $BRAIN_SEARCH_ENV (default $HOME/.config/brain/search.env).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/eSlider/2dph/internal/websearch"
"golang.org/x/sys/unix"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var (
query, site, lang, fresh, category, engines string
limit = websearch.DefaultLimit
jsonOut, refresh, force bool
ttl = float64(websearch.CacheTTL)
timeout = 25
)
i := 0
for i < len(args) {
a := args[i]
switch {
case a == "--json":
jsonOut = true
case a == "--refresh":
refresh = true
case a == "--force":
force = true
case (a == "-n" || a == "--limit") && i+1 < len(args):
i++
n, err := strconv.Atoi(args[i])
if err != nil || n < 0 {
fmt.Fprintln(os.Stderr, "web/search: --limit must be a non-negative integer")
return 2
}
limit = n
case a == "--site" && i+1 < len(args):
i++
site = args[i]
case a == "--lang" && i+1 < len(args):
i++
lang = args[i]
case a == "--fresh" && i+1 < len(args):
i++
fresh = args[i]
case a == "--category" && i+1 < len(args):
i++
category = args[i]
case a == "--engines" && i+1 < len(args):
i++
engines = args[i]
case a == "--ttl" && i+1 < len(args):
i++
v, err := strconv.ParseFloat(args[i], 64)
if err != nil {
fmt.Fprintln(os.Stderr, "web/search: --ttl must be a number")
return 2
}
ttl = v
case a == "--timeout" && i+1 < len(args):
i++
n, err := strconv.Atoi(args[i])
if err != nil || n <= 0 {
fmt.Fprintln(os.Stderr, "web/search: --timeout must be a positive integer")
return 2
}
timeout = n
case a == "-h" || a == "--help":
fmt.Fprintln(os.Stderr, `usage: bin/web/search.go QUERY [--json] [-n N] [--site HOST] [--lang LANG] [--fresh day|week|month|year] [--category CAT] [--engines LIST] [--refresh] [--force]`)
return 0
case len(a) > 0 && a[0] != '-' && query == "":
query = a
default:
fmt.Fprintf(os.Stderr, "web/search: unknown flag %s\n", a)
return 2
}
i++
}
if query == "" {
fmt.Fprintln(os.Stderr, "web/search: query required")
return 2
}
if site != "" {
query = "site:" + site + " " + query
}
if reason := websearch.PHIReason(query); reason != "" && !force {
fmt.Fprintf(os.Stderr, "refused: %s. This query would leave the host.\n", reason)
fmt.Fprintln(os.Stderr, "Rephrase without identifiers, or pass --force if it is genuinely public.")
return 2
}
params := map[string]string{}
if lang != "" {
params["language"] = lang
}
if fresh != "" {
params["time_range"] = fresh
}
if category != "" {
params["categories"] = category
}
if engines != "" {
params["engines"] = engines
}
cachePath := os.Getenv("BRAIN_SEARCH_CACHE")
if cachePath == "" {
cachePath = os.Getenv("HOME") + "/.cache/brain/web-search.sqlite"
}
cache, err := websearch.OpenCache(cachePath)
if err != nil {
fmt.Fprintf(os.Stderr, "web/search: cache: %v\n", err)
return 1
}
defer cache.Close()
key := websearch.CacheKey(query, params)
now := float64(time.Now().Unix())
if !refresh {
if cached, err := cache.Get(key, ttl, now); err != nil {
fmt.Fprintf(os.Stderr, "web/search: cache: %v\n", err)
return 1
} else if cached != nil {
out := websearch.Project(*cached, limit, websearch.DefaultSnippetChars)
out.Cached = true
return writeOut(out, jsonOut)
}
}
envPath := os.Getenv("BRAIN_SEARCH_ENV")
if envPath == "" {
envPath = os.Getenv("HOME") + "/.config/brain/search.env"
}
conf, err := websearch.LoadConfig(envPath)
if err != nil {
fmt.Fprintf(os.Stderr, "web/search: %v\n", err)
return 1
}
lockPath := cachePath + ".lock"
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
fmt.Fprintf(os.Stderr, "web/search: lock: %v\n", err)
return 1
}
defer lock.Close()
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
fmt.Fprintf(os.Stderr, "web/search: lock: %v\n", err)
return 1
}
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
var payload websearch.Payload
attempts := 1 + len(websearch.RetryBackoff)
client := &http.Client{}
for attempt := 0; attempt < attempts; attempt++ {
last, err := cache.LastCall()
if err != nil {
fmt.Fprintf(os.Stderr, "web/search: cache: %v\n", err)
return 1
}
if delay := websearch.WaitFor(last, float64(time.Now().Unix()), websearch.MinInterval); delay > 0 {
time.Sleep(time.Duration(delay * float64(time.Second)))
}
if err := cache.MarkCall(float64(time.Now().Unix())); err != nil {
fmt.Fprintf(os.Stderr, "web/search: cache: %v\n", err)
return 1
}
payload, err = websearch.Fetch(client, conf, query, params, time.Duration(timeout)*time.Second)
if err != nil {
fmt.Fprintf(os.Stderr, "request failed: %v\n", err)
return 3
}
if websearch.Classify(payload) == websearch.StatusOK {
break
}
if attempt < len(websearch.RetryBackoff) {
time.Sleep(time.Duration(websearch.RetryBackoff[attempt] * float64(time.Second)))
}
}
if websearch.Classify(payload) == websearch.StatusOK {
if err := cache.Put(key, payload, float64(time.Now().Unix())); err != nil {
fmt.Fprintf(os.Stderr, "web/search: cache: %v\n", err)
}
}
out := websearch.Project(payload, limit, websearch.DefaultSnippetChars)
code := writeOut(out, jsonOut)
if out.Status != websearch.StatusOK && code == 0 {
return 3
}
return code
}
func writeOut(out websearch.Output, jsonOut bool) int {
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(out); err != nil {
return 1
}
if out.Status != websearch.StatusOK {
return 3
}
return 0
}
fmt.Print(out.YAML())
if out.Status != websearch.StatusOK {
return 3
}
return 0
}
-15
View File
@@ -61,21 +61,6 @@ services:
restart: unless-stopped restart: unless-stopped
stop_grace_period: 20s stop_grace_period: 20s
# Optional local SearXNG (D3). Skip if BRAIN_SEARCH_URL already points at a
# live instance — do not run a second copy on that host.
# SEARXNG_SECRET=$(openssl rand -hex 32) docker compose --profile searxng up -d
searxng:
profiles: ["searxng"]
image: docker.io/searxng/searxng:2026.8.10-0a118066d
ports:
- "127.0.0.1:8888:8080"
environment:
SEARXNG_SECRET: ${SEARXNG_SECRET:-}
volumes:
- ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro
- ./deploy/searxng/limiter.toml:/etc/searxng/limiter.toml:ro
restart: unless-stopped
volumes: volumes:
kb-model: kb-model:
kb-var: kb-var:
-7
View File
@@ -1,7 +0,0 @@
[botdetection.ip_lists]
# RFC1918 only. Do not copy a live instance egress IP into git.
pass_ip = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
]
-28
View File
@@ -1,28 +0,0 @@
use_default_settings: true
general:
instance_name: "2dph"
search:
formats:
- html
- json
suspended_times:
SearxEngineCaptcha: 300
SearxEngineTooManyRequests: 120
SearxEngineAccessDenied: 300
server:
limiter: true
image_proxy: false
# secret_key comes from SEARXNG_SECRET (never commit a real secret)
engines:
- name: bing
disabled: false
- name: google
disabled: false
- name: duckduckgo
disabled: false
- name: wikipedia
disabled: false
-33
View File
@@ -1,33 +0,0 @@
# CRM association proof (oo CLI ↔ corpus)
Proven with `oo` (eslider/go-onlyoffice) against the OnlyOffice portal
(`office.produktor.io`). Portal CRM is the SSOT for company ↔ person ↔
project associations; the corpus SoT (`eslider/cv/projects/knowledge-mesh-seed.yaml`)
is the second, independent source. Facts that can be backed by both are
written to the brain under `root=facts` by `bin/facts/crm`.
## What was verified
- Logical counts (portal MySQL): 1300 contacts = 897 persons + 404 companies,
198 projects, 998 deals, 939 project↔contact links.
- Every client company linked to a project has ≥1 person underneath.
- Every person `company_id` resolves to an existing company.
- Corpus org list (9) maps 1:1 onto CRM companies:
ProProdukt SL / produktor.io, Dyvenia, Immowelt AG, WhereGroup,
Keynote SIGOS, D2S/SYSTEMS, GRID, Pack und Cup, Markets Platform.
- 78 person↔company association facts written to the brain
(`how=crm-crosscheck`, `type=association`). Recall@5 in `bin/kb/eval` = 1.0.
## Mistakes found
| # | Mistake | Fix |
|---|---------|-----|
| 1 | Duplicate legal entity `GoldenRatio.Exchange` (contact 759) vs `Golden Ratio Exchange` (763); 3 deals (211, 287, 559) were linked to 759 | `oo contacts merge 759 763` — 763 kept, 759 removed, deal links re-pointed to 763 |
| 2 | `env/`-wide: OnlyOffice creds file used wrong UX (user `eslider`, password with `$2` suffix) making `oo` auth fail | `.env` fixed to `eslider@gmail.com` + clean password; `.env` stays gitignored |
## Gates after fix
- `uv run python -m unittest discover -s bin/tools -t .` → 26 tests OK
- `bin/facts/audit self` + `bin/facts/audit db` → ok
- `bin/kb/eval` → recall@5 = 1.0
- `go test ./...` (bin/server + bin/watch) → ok
+1 -3
View File
@@ -21,7 +21,6 @@ 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)
``` ```
`--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
@@ -47,8 +46,7 @@ Every assertion edge carries:
Content leafs: `sha256`, `observed_at`, `source_rev`, `confidence`. Stale = a Content leafs: `sha256`, `observed_at`, `source_rev`, `confidence`. Stale = a
file changed on disk (git HEAD/mtime) after its last observed `source_rev`. file changed on disk (git HEAD/mtime) after its last observed `source_rev`.
`File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person` records the history of every `File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person` records the history of every
content leaf. Commit records come from `bin/git/import.go` (go-git, no git content leaf.
binary); conversion prints leafs, brain write is `bin/brain/index.go`.
`bin/facts/audit stale` flags leafs whose observed revision is behind the `bin/facts/audit stale` flags leafs whose observed revision is behind the
corpus HEAD. corpus HEAD.
+2 -29
View File
@@ -7,46 +7,19 @@ require (
github.com/arran4/golang-ical v0.3.5 github.com/arran4/golang-ical v0.3.5
github.com/chewxy/math32 v1.11.2 github.com/chewxy/math32 v1.11.2
github.com/daulet/tokenizers v1.27.0 github.com/daulet/tokenizers v1.27.0
github.com/go-git/go-git/v5 v5.19.2
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0 golang.org/x/text v0.40.0
modernc.org/sqlite v1.56.0
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/apache/arrow-go/v18 v18.6.0 // indirect github.com/apache/arrow-go/v18 v18.6.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
+6 -140
View File
@@ -1,184 +1,50 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/LadybugDB/go-ladybug v0.17.0 h1:RXDbkBjrbRmLdEbhGl4CLOIEzSt09gbP0n9UbKDEfwI= github.com/LadybugDB/go-ladybug v0.17.0 h1:RXDbkBjrbRmLdEbhGl4CLOIEzSt09gbP0n9UbKDEfwI=
github.com/LadybugDB/go-ladybug v0.17.0/go.mod h1:GeIXmE8XyF5TFS94NAuTag7vgCC+no/HTBMRA6Rd5Cs= github.com/LadybugDB/go-ladybug v0.17.0/go.mod h1:GeIXmE8XyF5TFS94NAuTag7vgCC+no/HTBMRA6Rd5Cs=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apache/arrow-go/v18 v18.6.0 h1:GX/Jyd3R7mCLiECAwY9FWbbaYblie2WXBSz4Sw8fNpM= github.com/apache/arrow-go/v18 v18.6.0 h1:GX/Jyd3R7mCLiECAwY9FWbbaYblie2WXBSz4Sw8fNpM=
github.com/apache/arrow-go/v18 v18.6.0/go.mod h1:gm3MiPpY82fLYK5VKPB3WoJbsiLVDfT7flD5/vHReKw= github.com/apache/arrow-go/v18 v18.6.0/go.mod h1:gm3MiPpY82fLYK5VKPB3WoJbsiLVDfT7flD5/vHReKw=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A= github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8= github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
github.com/chewxy/math32 v1.11.2 h1:IufN08Zwr1NKuWfY+4Tz55BcwKmyKKNdOP7KtumehnM= github.com/chewxy/math32 v1.11.2 h1:IufN08Zwr1NKuWfY+4Tz55BcwKmyKKNdOP7KtumehnM=
github.com/chewxy/math32 v1.11.2/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= github.com/chewxy/math32 v1.11.2/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/daulet/tokenizers v1.27.0 h1:MmFYAEDFz69s/nNQfHg59DWqHz3v94m99kEZ/JbL+s4= github.com/daulet/tokenizers v1.27.0 h1:MmFYAEDFz69s/nNQfHg59DWqHz3v94m99kEZ/JbL+s4=
github.com/daulet/tokenizers v1.27.0/go.mod h1:YjFY1o1HGMyWkQgbXJDghhvke/yFDp2vGdIO2hYs4MQ= github.com/daulet/tokenizers v1.27.0/go.mod h1:YjFY1o1HGMyWkQgbXJDghhvke/yFDp2vGdIO2hYs4MQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-180
View File
@@ -1,180 +0,0 @@
// Package gitlog reads commit history with go-git (no git binary).
package gitlog
import (
"errors"
"fmt"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
type Options struct {
Limit int
Since time.Time
}
type Commit struct {
SHA string `json:"sha"`
Author string `json:"author"`
Email string `json:"email"`
Date string `json:"date"`
Subject string `json:"subject"`
Files []string `json:"files"`
}
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"`
}
// Log walks commits from HEAD, newest first, skipping merges.
func Log(repo string, opt Options) ([]Commit, error) {
r, err := git.PlainOpen(repo)
if err != nil {
return nil, err
}
logOpt := &git.LogOptions{Order: git.LogOrderCommitterTime}
if !opt.Since.IsZero() {
t := opt.Since
logOpt.Since = &t
}
iter, err := r.Log(logOpt)
if err != nil {
return nil, err
}
defer iter.Close()
var out []Commit
err = iter.ForEach(func(c *object.Commit) error {
if c.NumParents() > 1 {
return nil
}
if opt.Limit > 0 && len(out) >= opt.Limit {
return Stop
}
files, ferr := changedFiles(c)
if ferr != nil {
return ferr
}
out = append(out, Commit{
SHA: c.Hash.String(),
Author: c.Author.Name,
Email: c.Author.Email,
Date: c.Author.When.Format(time.RFC3339),
Subject: firstLine(c.Message),
Files: files,
})
return nil
})
if errors.Is(err, Stop) {
err = nil
}
return out, err
}
// Stop ends a log walk early (limit reached).
var Stop = fmt.Errorf("gitlog: stop")
func changedFiles(c *object.Commit) ([]string, error) {
var names []string
if c.NumParents() == 0 {
t, err := c.Tree()
if err != nil {
return nil, err
}
err = t.Files().ForEach(func(f *object.File) error {
names = append(names, f.Name)
return nil
})
sort.Strings(names)
return names, err
}
parent, err := c.Parent(0)
if err != nil {
return nil, err
}
from, err := parent.Tree()
if err != nil {
return nil, err
}
to, err := c.Tree()
if err != nil {
return nil, err
}
changes, err := object.DiffTree(from, to)
if err != nil {
return nil, err
}
for _, ch := range changes {
name := ch.To.Name
if name == "" {
name = ch.From.Name
}
if name != "" {
names = append(names, name)
}
}
sort.Strings(names)
return names, nil
}
func firstLine(msg string) string {
msg = strings.ReplaceAll(msg, "\r\n", "\n")
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return strings.TrimSpace(msg[:i])
}
return strings.TrimSpace(msg)
}
func ToLeaf(c Commit, repo string) Leaf {
short := c.SHA
if len(short) > 12 {
short = short[:12]
}
head := fmt.Sprintf("commit %s — %s", short, c.Subject)
body := []string{
fmt.Sprintf("commit %s in %s — %s", short, repo, c.Subject),
fmt.Sprintf("Author: %s <%s>", c.Author, c.Email),
fmt.Sprintf("Date: %s", c.Date),
}
if len(c.Files) > 0 {
body = append(body, "Changing: "+strings.Join(c.Files, ", "))
}
return Leaf{
Source: repo + "@" + c.SHA,
Repo: repo,
Heading: head,
Text: strings.Join(body, "\n"),
Type: "commit",
Status: "current",
Related: strings.Join(c.Files, ","),
}
}
func RepoName(repo string) (string, error) {
r, err := git.PlainOpen(repo)
if err != nil {
return filepath.Base(repo), err
}
rem, err := r.Remote("origin")
if err != nil {
return filepath.Base(repo), nil
}
urls := rem.Config().URLs
if len(urls) == 0 {
return filepath.Base(repo), nil
}
u := strings.TrimSuffix(strings.TrimSuffix(urls[0], "/"), ".git")
return path.Base(strings.ReplaceAll(u, "\\", "/")), nil
}
-243
View File
@@ -1,243 +0,0 @@
package gitlog
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
func TestLogReadsCommitsWithoutGitBinary(t *testing.T) {
dir := initRepo(t, []commitSpec{
{
when: time.Date(2026, 8, 10, 12, 0, 0, 0, time.FixedZone("CEST", 3600)),
name: "Ada Lovelace",
email: "ada@example.com",
subject: "feat: first commit",
files: map[string]string{"README.md": "hi\n", "src/main.c": "int main(){}\n"},
},
{
when: time.Date(2026, 8, 11, 9, 30, 0, 0, time.FixedZone("CEST", 3600)),
name: "Bob Babbage",
email: "bob@example.com",
subject: "fix: typo",
files: map[string]string{"docs/notes.md": "note\n"},
},
})
cs, err := Log(dir, Options{})
if err != nil {
t.Fatal(err)
}
if len(cs) != 2 {
t.Fatalf("commits = %d, want 2", len(cs))
}
if cs[0].Subject != "fix: typo" {
t.Fatalf("head subject = %q, want fix: typo", cs[0].Subject)
}
if cs[1].Author != "Ada Lovelace" || cs[1].Email != "ada@example.com" {
t.Fatalf("author = %s <%s>", cs[1].Author, cs[1].Email)
}
sort.Strings(cs[1].Files)
if got := cs[1].Files; len(got) != 2 || got[0] != "README.md" || got[1] != "src/main.c" {
t.Fatalf("first commit files = %v", got)
}
if cs[0].Files[0] != "docs/notes.md" {
t.Fatalf("second commit files = %v", cs[0].Files)
}
}
func TestLogSkipsMerges(t *testing.T) {
dir := initRepo(t, []commitSpec{{
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
subject: "base", files: map[string]string{"a.txt": "a\n"},
}})
r, err := git.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
head, err := r.Head()
if err != nil {
t.Fatal(err)
}
c, err := r.CommitObject(head.Hash())
if err != nil {
t.Fatal(err)
}
// Second parent: duplicate the same tree so we do not need a real branch.
merge := &object.Commit{
Author: object.Signature{Name: "Ada Lovelace", Email: "ada@example.com", When: time.Now()},
Committer: object.Signature{Name: "Ada Lovelace", Email: "ada@example.com", When: time.Now()},
Message: "merge",
TreeHash: c.TreeHash,
ParentHashes: []plumbing.Hash{c.Hash, c.Hash},
}
obj := r.Storer.NewEncodedObject()
if err := merge.Encode(obj); err != nil {
t.Fatal(err)
}
h, err := r.Storer.SetEncodedObject(obj)
if err != nil {
t.Fatal(err)
}
if err := r.Storer.SetReference(plumbing.NewHashReference(head.Name(), h)); err != nil {
t.Fatal(err)
}
cs, err := Log(dir, Options{})
if err != nil {
t.Fatal(err)
}
for _, x := range cs {
if x.Subject == "merge" {
t.Fatal("merge commit was not skipped")
}
}
if len(cs) != 1 || cs[0].Subject != "base" {
t.Fatalf("after skip merges: %+v", subjects(cs))
}
}
func TestLogSinceAndLimit(t *testing.T) {
old := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
neu := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
dir := initRepo(t, []commitSpec{
{when: old, name: "Ada Lovelace", email: "ada@example.com", subject: "old", files: map[string]string{"old.md": "x"}},
{when: neu, name: "Ada Lovelace", email: "ada@example.com", subject: "new", files: map[string]string{"new.md": "y"}},
})
cs, err := Log(dir, Options{Since: neu.Add(-time.Hour)})
if err != nil {
t.Fatal(err)
}
if len(cs) != 1 || cs[0].Subject != "new" {
t.Fatalf("since filter: %v", subjects(cs))
}
cs, err = Log(dir, Options{Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(cs) != 1 {
t.Fatalf("limit=1 got %d", len(cs))
}
}
func TestLeafShape(t *testing.T) {
c := Commit{
SHA: "a1b2c3d4e5f6aaaa",
Author: "Ada Lovelace",
Email: "ada@example.com",
Date: "2026-08-10T12:00:00+01:00",
Subject: "feat: first commit",
Files: []string{"README.md", "src/main.c"},
}
lf := ToLeaf(c, "sample-repo")
if lf.Type != "commit" || lf.Repo != "sample-repo" {
t.Fatalf("leaf meta = %+v", lf)
}
if lf.Source != "sample-repo@a1b2c3d4e5f6aaaa" {
t.Fatalf("source = %s", lf.Source)
}
if lf.Related != "README.md,src/main.c" {
t.Fatalf("related = %s", lf.Related)
}
if lf.Heading != "commit a1b2c3d4e5f6 — feat: first commit" {
t.Fatalf("heading = %q", lf.Heading)
}
if !strings.Contains(lf.Text, "Ada Lovelace") || !strings.Contains(lf.Text, "README.md") {
t.Fatalf("text = %s", lf.Text)
}
}
func TestRepoNameFromOrigin(t *testing.T) {
dir := initRepo(t, []commitSpec{{
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
subject: "init", files: map[string]string{"README.md": "x"},
}})
r, err := git.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
if _, err := r.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{"https://git.example.com/eSlider/sample-repo.git"},
}); err != nil {
t.Fatal(err)
}
name, err := RepoName(dir)
if err != nil {
t.Fatal(err)
}
if name != "sample-repo" {
t.Fatalf("RepoName = %q, want sample-repo", name)
}
}
func TestRepoNameFallsBackToDir(t *testing.T) {
dir := initRepo(t, []commitSpec{{
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
subject: "init", files: map[string]string{"README.md": "x"},
}})
name, err := RepoName(dir)
if err != nil {
t.Fatal(err)
}
if name != filepath.Base(dir) {
t.Fatalf("RepoName = %q, want %s", name, filepath.Base(dir))
}
}
type commitSpec struct {
when time.Time
name string
email string
subject string
files map[string]string
}
func initRepo(t *testing.T, specs []commitSpec) string {
t.Helper()
dir := t.TempDir()
r, err := git.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
w, err := r.Worktree()
if err != nil {
t.Fatal(err)
}
for _, s := range specs {
for path, body := range s.files {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil && !os.IsExist(err) {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if _, err := w.Add(path); err != nil {
t.Fatal(err)
}
}
if _, err := w.Commit(s.subject, &git.CommitOptions{
Author: &object.Signature{Name: s.name, Email: s.email, When: s.when},
}); err != nil {
t.Fatal(err)
}
}
return dir
}
func subjects(cs []Commit) []string {
out := make([]string, len(cs))
for i, c := range cs {
out[i] = c.Subject
}
return out
}
-97
View File
@@ -1,97 +0,0 @@
package websearch
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
const cacheSchema = `
CREATE TABLE IF NOT EXISTS responses (
key TEXT PRIMARY KEY,
fetched REAL NOT NULL,
payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value REAL NOT NULL
);
`
type Cache struct {
db *sql.DB
}
func OpenCache(path string) (*Cache, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if _, err := db.Exec(cacheSchema); err != nil {
db.Close()
return nil, err
}
return &Cache{db: db}, nil
}
func (c *Cache) Close() error {
if c == nil || c.db == nil {
return nil
}
return c.db.Close()
}
func (c *Cache) Get(key string, ttl, now float64) (*Payload, error) {
var fetched float64
var raw string
err := c.db.QueryRow("SELECT fetched, payload FROM responses WHERE key = ?", key).Scan(&fetched, &raw)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if now-fetched > ttl {
return nil, nil
}
var p Payload
if err := json.Unmarshal([]byte(raw), &p); err != nil {
return nil, err
}
return &p, nil
}
func (c *Cache) Put(key string, p Payload, now float64) error {
raw, err := json.Marshal(p)
if err != nil {
return err
}
_, err = c.db.Exec(
"INSERT OR REPLACE INTO responses (key, fetched, payload) VALUES (?, ?, ?)",
key, now, string(raw),
)
return err
}
func (c *Cache) LastCall() (*float64, error) {
var v float64
err := c.db.QueryRow("SELECT value FROM meta WHERE key = 'last_call'").Scan(&v)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &v, nil
}
func (c *Cache) MarkCall(now float64) error {
_, err := c.db.Exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_call', ?)", now)
return err
}
-90
View File
@@ -1,90 +0,0 @@
package websearch
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Config struct {
URL string
User string
Pass string
}
func LoadConfig(path string) (Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("no credentials at %s (mode 600, BRAIN_SEARCH_URL)", path)
}
conf := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
continue
}
k, v, _ := strings.Cut(line, "=")
v = strings.TrimSpace(v)
v = strings.Trim(v, `"'`)
conf[strings.TrimSpace(k)] = v
}
out := Config{
URL: conf["BRAIN_SEARCH_URL"],
User: conf["BRAIN_SEARCH_USER"],
Pass: conf["BRAIN_SEARCH_PASS"],
}
if out.URL == "" {
return Config{}, fmt.Errorf("%s is missing BRAIN_SEARCH_URL", path)
}
return out, nil
}
func Fetch(client *http.Client, conf Config, query string, params map[string]string, timeout time.Duration) (Payload, error) {
if client == nil {
client = &http.Client{Timeout: timeout}
} else if timeout > 0 {
c := *client
c.Timeout = timeout
client = &c
}
q := url.Values{}
q.Set("q", query)
q.Set("format", "json")
for k, v := range params {
if v != "" {
q.Set(k, v)
}
}
u := strings.TrimRight(conf.URL, "/") + "/search?" + q.Encode()
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return Payload{}, err
}
if conf.User != "" || conf.Pass != "" {
token := base64.StdEncoding.EncodeToString([]byte(conf.User + ":" + conf.Pass))
req.Header.Set("Authorization", "Basic "+token)
}
resp, err := client.Do(req)
if err != nil {
return Payload{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return Payload{}, err
}
if resp.StatusCode >= 400 {
return Payload{}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var p Payload
if err := json.Unmarshal(body, &p); err != nil {
return Payload{}, err
}
return p, nil
}
-77
View File
@@ -1,77 +0,0 @@
package websearch
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadConfigRequiresURL(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "search.env")
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_USER=x\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(p); err == nil {
t.Fatal("expected missing URL error")
}
}
func TestLoadConfigOptionalAuth(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "search.env")
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_URL=http://127.0.0.1:8080\n"), 0o600); err != nil {
t.Fatal(err)
}
c, err := LoadConfig(p)
if err != nil {
t.Fatal(err)
}
if c.URL != "http://127.0.0.1:8080" || c.User != "" || c.Pass != "" {
t.Fatalf("%+v", c)
}
}
func TestFetchJSONNoBasicAuth(t *testing.T) {
payload := Payload{Query: "x", Results: []RawHit{{Title: "t", URL: "http://example.com", Content: "c", Engine: "bing"}}}
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
if r.URL.Query().Get("format") != "json" || r.URL.Query().Get("q") != "x" {
t.Errorf("query = %s", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
got, err := Fetch(srv.Client(), Config{URL: srv.URL}, "x", nil, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if sawAuth != "" {
t.Fatalf("Authorization = %q, want empty for local instance", sawAuth)
}
if Classify(got) != StatusOK {
t.Fatalf("classify = %s", Classify(got))
}
}
func TestFetchSendsBasicAuthWhenConfigured(t *testing.T) {
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
w.Write([]byte(`{"query":"x","results":[]}`))
}))
defer srv.Close()
_, err := Fetch(srv.Client(), Config{URL: srv.URL, User: "u", Pass: "p"}, "x", nil, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if sawAuth == "" {
t.Fatal("expected Basic auth")
}
}
-205
View File
@@ -1,205 +0,0 @@
// Package websearch is the SearXNG client used as the second independent source.
//
// An empty result list from this instance is throttling, not evidence of absence.
package websearch
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
const (
StatusOK = "ok"
StatusThrottled = "throttled"
DefaultLimit = 5
DefaultSnippetChars = 150
MinInterval = 10.0
CacheTTL = 7 * 24 * 3600
)
var RetryBackoff = []float64{20, 60}
type Payload struct {
Query string `json:"query"`
Results []RawHit `json:"results"`
UnresponsiveEngines [][]string `json:"unresponsive_engines"`
}
type RawHit struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
}
type Hit struct {
Rank int `json:"rank"`
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Engine string `json:"engine"`
}
type Output struct {
Query string `json:"query"`
Status string `json:"status"`
Results []Hit `json:"results"`
Unresponsive []string `json:"unresponsive,omitempty"`
Note string `json:"note,omitempty"`
Cached bool `json:"cached,omitempty"`
}
func Classify(p Payload) string {
if len(p.Results) > 0 {
return StatusOK
}
return StatusThrottled
}
func Project(p Payload, limit, snippetChars int) Output {
if limit <= 0 {
limit = DefaultLimit
}
if snippetChars <= 0 {
snippetChars = DefaultSnippetChars
}
status := Classify(p)
n := limit
if n > len(p.Results) {
n = len(p.Results)
}
hits := make([]Hit, 0, n)
for i := 0; i < n; i++ {
item := p.Results[i]
hits = append(hits, Hit{
Rank: i + 1,
Title: item.Title,
URL: item.URL,
Snippet: trimSnippet(item.Content, snippetChars),
Engine: item.Engine,
})
}
out := Output{
Query: p.Query,
Status: status,
Results: hits,
}
for _, pair := range p.UnresponsiveEngines {
if len(pair) >= 2 {
out.Unresponsive = append(out.Unresponsive, pair[0]+": "+pair[1])
} else if len(pair) == 1 {
out.Unresponsive = append(out.Unresponsive, pair[0])
}
}
if status == StatusThrottled {
out.Note = "no engine answered - this is a throttled instance, not evidence that nothing exists"
}
return out
}
var spaceRE = regexp.MustCompile(`\s+`)
func trimSnippet(s string, max int) string {
s = strings.TrimSpace(spaceRE.ReplaceAllString(s, " "))
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
cut := strings.TrimRightFunc(string(runes[:max]), unicode.IsSpace)
return cut + "..."
}
func CacheKey(query string, params map[string]string) string {
norm := strings.Join(strings.Fields(strings.ToLower(query)), " ")
if params == nil {
params = map[string]string{}
}
stable, _ := json.Marshal(params)
sum := sha256.Sum256([]byte(norm + "\x00" + string(stable)))
return hex.EncodeToString(sum[:])
}
func WaitFor(last *float64, now, interval float64) float64 {
if last == nil {
return 0
}
d := interval - (now - *last)
if d < 0 {
return 0
}
return d
}
func PHIReason(query string) string {
for _, p := range phiPatterns {
if p.re.MatchString(query) {
return p.reason
}
}
return ""
}
type phiPat struct {
re *regexp.Regexp
reason string
}
var phiPatterns = []phiPat{
{regexp.MustCompile(`\d{6,}`), "a run of six or more digits looks like an ID"},
{regexp.MustCompile(`(?i)\bpersonalnummer\b`), "Personalnummer is staff data"},
{regexp.MustCompile(`(?i)\bkv[-\s]?nr\b`), "KV-Nr is an insurance number"},
{regexp.MustCompile(`(?i)\bversichertennummer\b`), "insurance number"},
{regexp.MustCompile(`(?i)\b[A-Za-zÄÖÜäöüß]+(?:stra(?:ss|ß)e|str\.)\s*\d+`), "a street with a house number looks like an address"},
{regexp.MustCompile(`(?i)\bgeb(?:urtsdatum)?\.?\s*\d{1,2}[./]\d{1,2}[./]\d{2,4}`), "a date of birth"},
}
func (o Output) YAML() string {
var b strings.Builder
fmt.Fprintf(&b, "query: %s\n", yamlScalar(o.Query))
fmt.Fprintf(&b, "status: %s\n", yamlScalar(o.Status))
if len(o.Results) == 0 {
b.WriteString("results: []\n")
} else {
b.WriteString("results:\n")
for _, r := range o.Results {
b.WriteString("-\n")
fmt.Fprintf(&b, " rank: %d\n", r.Rank)
fmt.Fprintf(&b, " title: %s\n", yamlScalar(r.Title))
fmt.Fprintf(&b, " url: %s\n", yamlScalar(r.URL))
fmt.Fprintf(&b, " snippet: %s\n", yamlScalar(r.Snippet))
fmt.Fprintf(&b, " engine: %s\n", yamlScalar(r.Engine))
}
}
if len(o.Unresponsive) > 0 {
b.WriteString("unresponsive:\n")
for _, u := range o.Unresponsive {
fmt.Fprintf(&b, "- %s\n", yamlScalar(u))
}
}
if o.Note != "" {
fmt.Fprintf(&b, "note: %s\n", yamlScalar(o.Note))
}
if o.Cached {
b.WriteString("cached: true\n")
}
return b.String()
}
func yamlScalar(s string) string {
if strings.Contains(s, "\n") {
b, _ := json.Marshal(s)
return string(b)
}
if s == "" || strings.ContainsAny(s, ":#'\"[]{}&*!|>%@`") || s != strings.TrimSpace(s) {
b, _ := json.Marshal(s)
return string(b)
}
return s
}
-203
View File
@@ -1,203 +0,0 @@
package websearch
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"unicode/utf8"
)
func loadFixture(t *testing.T, name string) Payload {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller")
}
path := filepath.Join(filepath.Dir(file), "..", "..", "bin", "tools", "web-search", "fixtures", name)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatal(err)
}
return p
}
func TestClassifyHealthyIsOK(t *testing.T) {
if got := Classify(loadFixture(t, "healthy.json")); got != StatusOK {
t.Fatalf("classify healthy = %q, want ok", got)
}
}
func TestClassifyEmptyIsThrottledNotEmpty(t *testing.T) {
got := Classify(loadFixture(t, "throttled.json"))
if got != StatusThrottled {
t.Fatalf("classify empty = %q, want throttled", got)
}
if got == "empty" || got == "no_results" {
t.Fatal("status must never sound like absence")
}
}
func TestProjectKeepsContextFields(t *testing.T) {
out := Project(loadFixture(t, "healthy.json"), 3, DefaultSnippetChars)
if out.Status != StatusOK {
t.Fatalf("status = %q", out.Status)
}
if len(out.Results) != 3 {
t.Fatalf("len = %d, want 3", len(out.Results))
}
r := out.Results[0]
if r.Rank != 1 || r.Title == "" || r.URL == "" {
t.Fatalf("hit = %+v", r)
}
}
func TestProjectTrimsSnippet(t *testing.T) {
out := Project(loadFixture(t, "healthy.json"), 5, 40)
for _, r := range out.Results {
n := utf8.RuneCountInString(r.Snippet)
if n > 43 {
t.Fatalf("snippet len %d > 43: %q", n, r.Snippet)
}
}
}
func TestProjectIsCheaperThanRaw(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(fixtureDir(t), "healthy.json"))
if err != nil {
t.Fatal(err)
}
out, err := json.Marshal(Project(loadFixture(t, "healthy.json"), 5, DefaultSnippetChars))
if err != nil {
t.Fatal(err)
}
if len(out)*3 >= len(raw) {
t.Fatalf("projected %d not cheaper than raw %d", len(out), len(raw))
}
}
func TestThrottledProjectionCarriesEngineReasons(t *testing.T) {
out := Project(loadFixture(t, "throttled.json"), 5, DefaultSnippetChars)
if out.Status != StatusThrottled {
t.Fatalf("status = %q", out.Status)
}
if len(out.Results) != 0 {
t.Fatalf("results = %v", out.Results)
}
if len(out.Unresponsive) == 0 {
t.Fatal("unresponsive empty")
}
if !strings.Contains(out.Note, "not evidence that nothing exists") {
t.Fatalf("note = %q", out.Note)
}
}
func TestCacheKeyStable(t *testing.T) {
if CacheKey("Pflegegrad", nil) != CacheKey("Pflegegrad", map[string]string{}) {
t.Fatal("nil vs empty params")
}
if CacheKey(" Pflegegrad ", nil) != CacheKey("pflegegrad", nil) {
t.Fatal("case/padding")
}
if CacheKey("x", map[string]string{"lang": "de"}) == CacheKey("x", nil) {
t.Fatal("params must change key")
}
a := CacheKey("x", map[string]string{"a": "1", "b": "2"})
b := CacheKey("x", map[string]string{"b": "2", "a": "1"})
if a != b {
t.Fatal("param order must not change key")
}
}
func TestPHIGuard(t *testing.T) {
if PHIReason("Pflegegrad SGB XI Einstufung") != "" {
t.Fatal("technical query refused")
}
if PHIReason("site:example.com technical query") != "" {
t.Fatal("site query refused")
}
if PHIReason("SGB XI Paragraph 45b") != "" {
t.Fatal("short numbers refused")
}
if PHIReason("Kunde 4711220385 Adresse") == "" {
t.Fatal("long digit run allowed")
}
if PHIReason("KV-Nr A123456789") == "" {
t.Fatal("KV-Nr allowed")
}
if PHIReason("Hauptstraße 14 Berlin") == "" {
t.Fatal("street allowed")
}
if PHIReason("Lindenstr. 7") == "" {
t.Fatal("str. allowed")
}
if PHIReason("Personalnummer 12") == "" {
t.Fatal("Personalnummer allowed")
}
}
func TestWaitFor(t *testing.T) {
last := 100.0
if got := WaitFor(&last, 104.0, 10); got != 6 {
t.Fatalf("wait = %v, want 6", got)
}
if got := WaitFor(&last, 130.0, 10); got != 0 {
t.Fatalf("wait = %v, want 0", got)
}
if got := WaitFor(nil, 130.0, 10); got != 0 {
t.Fatalf("first call wait = %v", got)
}
}
func TestSQLiteCacheRoundTrip(t *testing.T) {
dir := t.TempDir()
c, err := OpenCache(filepath.Join(dir, "web-search.sqlite"))
if err != nil {
t.Fatal(err)
}
defer c.Close()
p := loadFixture(t, "healthy.json")
key := CacheKey("pflegegrad", nil)
if got, err := c.Get(key, CacheTTL, 1_000); err != nil || got != nil {
t.Fatalf("empty get = %v %v", got, err)
}
if err := c.Put(key, p, 1_000); err != nil {
t.Fatal(err)
}
got, err := c.Get(key, CacheTTL, 1_001)
if err != nil || got == nil {
t.Fatalf("get = %v %v", got, err)
}
if Classify(*got) != StatusOK {
t.Fatalf("cached classify = %s", Classify(*got))
}
expired, err := c.Get(key, 10, 2_000)
if err != nil || expired != nil {
t.Fatalf("expired = %v %v", expired, err)
}
if v, err := c.LastCall(); err != nil || v != nil {
t.Fatalf("last = %v %v", v, err)
}
if err := c.MarkCall(50); err != nil {
t.Fatal(err)
}
v, err := c.LastCall()
if err != nil || v == nil || *v != 50 {
t.Fatalf("last after mark = %v %v", v, err)
}
}
func fixtureDir(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller")
}
return filepath.Join(filepath.Dir(file), "..", "..", "bin", "tools", "web-search", "fixtures")
}
+9 -14
View File
@@ -1,30 +1,25 @@
--- ---
name: web-search name: web-search
description: >- description: >-
Search the public web through SearXNG using bin/web/search.go. Use for vendor Search the public web through the self-hosted SearXNG at search.ops.io
documentation, public standards, and any fact that is not in our own repos — using bin/web/search. Use for German care law, SGB paragraphs, vendor
and as the second independent source the detective method requires. documentation and any fact that is not in our own repos - and as the second
independent source the detective method requires.
--- ---
# web-search # web-search
```bash ```bash
bin/web/search.go "LadybugDB vector index" bin/web/search "LadybugDB vector index"
bin/web/search.go "model2vec multilingual" --category it bin/web/search "model2vec multilingual" --category it
bin/web/search.go "hypervisor" --site example.com --json | jq -r '.results[].url' bin/web/search "hypervisor" --site ops.io --json | jq -r '.results[].url'
bin/web/search.go "postgres partial index" --lang en --fresh year bin/web/search "postgres partial index" --lang en --fresh year
``` ```
URL and optional Basic Auth live in `$BRAIN_SEARCH_ENV` (default
`$HOME/.config/brain/search.env`): `BRAIN_SEARCH_URL` is required;
`BRAIN_SEARCH_USER` / `BRAIN_SEARCH_PASS` only if the instance uses Basic Auth.
A host that already runs SearXNG should set `BRAIN_SEARCH_URL` and not start
the Compose profile.
## Web or knowledge base ## Web or knowledge base
`bin/brain/search.go` holds our own facts: the ops stack, portfolio, ssh hosts, `bin/brain/search.go` holds our own facts: the ops stack, portfolio, ssh hosts,
the lexicon. Go there first. Reach for `bin/web/search.go` when the answer is the lexicon. Go there first. Reach for `bin/web/search` when the answer is
outside our repos: upstream library behaviour, vendor documentation, public outside our repos: upstream library behaviour, vendor documentation, public
standards. standards.
+44 -28
View File
@@ -5,47 +5,63 @@ status: current
# Tuning the SearXNG instance # Tuning the SearXNG instance
The client treats HTTP 200 + `results: []` as **throttled**, not as absence. The client works around a fragile instance. These changes fix the cause, and
Fix the cause on the instance you point `BRAIN_SEARCH_URL` at, or use the they need shell access to the host behind `search.ops.io`
optional Compose profile in this repo. (`90.169.228.16` / `ops.mywire.org`), which is a different machine from
the one the agents run on.
## Optional Compose profile ## Why it is needed
Do not start this on a host that already runs SearXNG — set `BRAIN_SEARCH_URL` Measured on 2026-08-10 from this host:
instead (D3).
```bash - The default engine set for the `general` category is only `duckduckgo`,
SEARXNG_SECRET=$(openssl rand -hex 32) docker compose --profile searxng up -d `brave` and `startpage`. `brave` and `startpage` sit in
``` `Suspended: too many requests` or `Suspended: CAPTCHA` almost permanently, so
in practice a single engine carries every query.
- About 25 probe requests over a few minutes pushed `duckduckgo` into `CAPTCHA`
as well. The instance then answered HTTP 200 with `results: []` and an empty
`unresponsive_engines` - indistinguishable from "nothing found" without the
client-side handling we added.
- Recovery took roughly six minutes.
Pinned image: `docker.io/searxng/searxng:2026.8.10-0a118066d`. ## Changes
Settings: `deploy/searxng/settings.yml` + `limiter.toml` (RFC1918 `pass_ip`,
short `suspended_times`, `formats: [html, json]`). No secrets in git. Bind is
`127.0.0.1:8888`.
## Why the client classifies empty as throttled 1. **Allow our egress IP through the limiter.** In `limiter.toml`:
A default engine set under load answers HTTP 200 with `results: []` (sometimes ```toml
with empty `unresponsive_engines`). That is indistinguishable from "nothing [botdetection.ip_lists]
found" unless the client refuses to call it absence. pass_ip = ["77.7.46.234"]
```
## Instance-side levers 2. **Shorten the suspensions.** In `settings.yml` the defaults are 24 hours for
a CAPTCHA and one hour for too-many-requests, which is far longer than the
condition lasts:
1. **Allow the callers through the limiter** (`limiter.toml` `pass_ip`). The ```yaml
Compose file uses RFC1918 only. search:
2. **Shorten suspensions** (`settings.yml` `search.suspended_times`) so a suspended_times:
CAPTCHA does not last a day. SearxEngineCaptcha: 300
3. **More than one engine in `general`.** One live engine is a single point of SearxEngineTooManyRequests: 120
failure. This repo enables bing, google, duckduckgo, wikipedia. SearxEngineAccessDenied: 300
4. **Keep the JSON API on.** `formats: [html, json]` must stay. ```
3. **Give `general` more than one working engine.** `google` and `wikipedia`
report `enabled: true` in `/config` yet never appear in a `general` response,
so they are not in the default set. Put them in it; one live engine per
category is a single point of failure.
4. **Keep the JSON API on.** `formats: [html, json]` must stay, otherwise every
client here breaks.
## Verifying ## Verifying
Ten requests in a row used to suspend the instance for minutes. After the
change they should all answer:
```bash ```bash
for i in $(seq 10); do for i in $(seq 10); do
bin/web/search.go "test $i" -n 1 --refresh --json | jq -r .status bin/web/search "test $i" -n 1 --refresh --json | jq -r .status
done done
``` ```
Ten lines of `ok` means the instance is healthy. Any `throttled` means say Ten lines of `ok` means it is fixed.
nothing about whether the subject exists.