mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
4bf87a8d19
Two strands of work. Session brain — a topic graph over raw agent session history, so you can find the session where something happened instead of remembering its UUID. Reads every Claude transcript plus the pruned sessions that survive only as prompts in history.jsonl, clusters them with local TF-IDF and community detection (no embeddings, no API calls, no new dependencies), and writes a sidecar at ~/.claude/session-brain/ — the vault is never touched. Ships five modules, the sessions-* CLI commands, an interactive graph.html, and the /session-brain and /wiki-sessions skills. This is retrieval, deliberately distinct from wiki-history-ingest, which distils sessions into permanent vault pages. Docs — README.md was 598 lines and put trust-check flags and a 17-row agent matrix in front of anyone evaluating the project. Cut it to a 129- line landing page (problem statement, 60-second install, four blocks of runnable commands, graph screenshot, why-not-a-notes-folder) and move the reference material into docs/: installation, agents, skills, cli, configuration, architecture, session-brain, contributing. README_TW.md mirrors the new structure; docs/ stays English-only for now. The restructure also closed real drift: - Ten skills existed but appeared in no reference — session-brain, session-search, wiki-dedup, wiki-digest, wiki-import, wiki-context-pack, wiki-stage-commit, wiki-switch, vault-skill-factory, and obsidian-layout-adjustment. - SETUP.md listed 12 of 39 skills and still claimed "no scripts, no dependencies" despite the pip package. It is now a signpost into docs/. - Eight config variables were undocumented anywhere, including WIKI_STAGED_WRITES, which wiki-setup prompts for and three skills read. Added to .env.example and docs/configuration.md. - sessions-name and a number of CLI flags were missing from the docs. Two details worth noting. README links are absolute GitHub URLs because pyproject sets readme = "README.md" as the PyPI long_description, and PyPI resolves relative hrefs against pypi.org rather than the repo — docs/ links would have 404'd on the package page. And three doc-drift guards asserted their facts against README.md; since those facts moved rather than disappeared, the assertions were repointed at the page that now owns each one instead of padding content back into the README. Also gitignore /vault/ (a local knowledge vault, not a fixture) and the packed browser extension. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
"""Tests for the session-brain CLI surface.
|
|
|
|
Every run gets a synthetic HOME and an explicit --claude-dir, so no test can
|
|
touch the developer's real session cache.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
TELEMETRY = [
|
|
"add telemetry batching to the warden runtime with a redacted sink",
|
|
"the telemetry sink drops spans under load, add warden batching retries",
|
|
"warden telemetry chain signing broke, redacted spans never reach the sink",
|
|
]
|
|
SPRITES = [
|
|
"slice the generated sprite sheet into aligned animation frames",
|
|
"the sprite sheet flood fill leaves halos around each animation frame",
|
|
"generate a sprite sheet and slice it into frames for the walk animation",
|
|
]
|
|
|
|
|
|
def _run(home: Path, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
env = os.environ.copy()
|
|
env["HOME"] = str(home)
|
|
env.pop("WIKI_SESSION_BRAIN_DIR", None)
|
|
env.pop("WIKI_SKIP_PROJECTS", None)
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
|
capture_output=True, text=True, env=env, input=stdin,
|
|
)
|
|
|
|
|
|
def _cache(tmp_path: Path) -> Path:
|
|
claude = tmp_path / "cache"
|
|
for topic, texts, cwd in (("tel", TELEMETRY, "/w/warden"), ("spr", SPRITES, "/w/game")):
|
|
project = claude / "projects" / cwd.replace("/", "-")
|
|
project.mkdir(parents=True, exist_ok=True)
|
|
for i, text in enumerate(texts):
|
|
sid = f"{topic}{i}"
|
|
lines = [
|
|
json.dumps({"type": "user", "message": {"role": "user", "content": text},
|
|
"timestamp": f"2026-07-{10 + i:02d}T10:00:00.000Z",
|
|
"cwd": cwd, "gitBranch": "main", "sessionId": sid}),
|
|
json.dumps({"type": "ai-title", "aiTitle": text[:40], "sessionId": sid}),
|
|
]
|
|
(project / f"{sid}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return claude
|
|
|
|
|
|
def _build(tmp_path: Path) -> tuple[Path, Path]:
|
|
home, claude, out = tmp_path / "home", _cache(tmp_path), tmp_path / "brain"
|
|
home.mkdir(exist_ok=True)
|
|
proc = _run(home, "sessions-build", "--claude-dir", str(claude), "--out", str(out),
|
|
"--k", "3", "--min-sim", "0.05", "--json")
|
|
assert proc.returncode == 0, proc.stderr
|
|
return home, out
|
|
|
|
|
|
def test_build_emits_json_and_artifacts(tmp_path: Path) -> None:
|
|
home, claude, out = tmp_path / "home", _cache(tmp_path), tmp_path / "brain"
|
|
home.mkdir()
|
|
proc = _run(home, "sessions-build", "--claude-dir", str(claude), "--out", str(out),
|
|
"--k", "3", "--min-sim", "0.05", "--json")
|
|
|
|
assert proc.returncode == 0, proc.stderr
|
|
data = json.loads(proc.stdout)
|
|
assert data["stats"]["sessions"] == 6
|
|
assert data["stats"]["clusters"] >= 2
|
|
for name in ("graph.json", "clusters.json", "graph.html", "docs.jsonl", "state.json"):
|
|
assert (out / name).is_file(), f"missing {name}"
|
|
|
|
|
|
def test_build_human_output_is_readable(tmp_path: Path) -> None:
|
|
home, claude, out = tmp_path / "home", _cache(tmp_path), tmp_path / "brain"
|
|
home.mkdir()
|
|
proc = _run(home, "sessions-build", "--claude-dir", str(claude), "--out", str(out),
|
|
"--k", "3", "--min-sim", "0.05")
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert "sessions" in proc.stdout
|
|
assert str(out) in proc.stdout
|
|
|
|
|
|
def test_build_on_an_empty_cache_succeeds(tmp_path: Path) -> None:
|
|
"""A machine with no session history is not an error condition."""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
proc = _run(home, "sessions-build", "--claude-dir", str(tmp_path / "nothing"),
|
|
"--out", str(tmp_path / "brain"), "--json")
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert json.loads(proc.stdout)["stats"]["sessions"] == 0
|
|
assert "Traceback" not in proc.stderr
|
|
|
|
|
|
def test_build_respects_skip(tmp_path: Path) -> None:
|
|
"""Skip matches on substrings, so a project can be named without the leading
|
|
dash that Claude's cwd encoding puts on every cache directory (and that
|
|
argparse would otherwise parse as a flag)."""
|
|
home, claude, out = tmp_path / "home", _cache(tmp_path), tmp_path / "brain"
|
|
home.mkdir()
|
|
proc = _run(home, "sessions-build", "--claude-dir", str(claude), "--out", str(out),
|
|
"--skip", "game", "--json")
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert json.loads(proc.stdout)["stats"]["sessions"] == 3
|
|
|
|
explicit = _run(home, "sessions-build", "--claude-dir", str(claude),
|
|
"--out", str(tmp_path / "b2"), "--skip=-w-game", "--json")
|
|
assert explicit.returncode == 0, explicit.stderr
|
|
assert json.loads(explicit.stdout)["stats"]["sessions"] == 3
|
|
|
|
|
|
def test_query_returns_ranked_json(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
proc = _run(home, "sessions-query", "warden telemetry sink", "--out", str(out), "--json")
|
|
|
|
assert proc.returncode == 0, proc.stderr
|
|
data = json.loads(proc.stdout)
|
|
assert data["candidates"]
|
|
assert not data["candidates"][0]["session_id"].startswith("spr")
|
|
assert data["should_load"]
|
|
|
|
|
|
def test_query_before_build_fails_with_guidance(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
proc = _run(home, "sessions-query", "anything", "--out", str(tmp_path / "nope"), "--json")
|
|
assert proc.returncode == 1
|
|
assert "sessions-build" in proc.stderr
|
|
|
|
|
|
def test_show_reports_neighbours(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
proc = _run(home, "sessions-show", "tel0", "--out", str(out))
|
|
assert proc.returncode == 0, proc.stderr
|
|
data = json.loads(proc.stdout)
|
|
assert data["session"]["id"] == "tel0"
|
|
assert data["neighbors"]
|
|
|
|
|
|
def test_show_unknown_session_fails_cleanly(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
proc = _run(home, "sessions-show", "does-not-exist", "--out", str(out))
|
|
assert proc.returncode == 1
|
|
assert "Traceback" not in proc.stderr
|
|
|
|
|
|
def test_clusters_lists_and_filters_unnamed(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
proc = _run(home, "sessions-clusters", "--out", str(out), "--json")
|
|
assert proc.returncode == 0, proc.stderr
|
|
clusters = json.loads(proc.stdout)["clusters"]
|
|
assert clusters
|
|
assert all(c["name"] is None for c in clusters)
|
|
|
|
unnamed = json.loads(
|
|
_run(home, "sessions-clusters", "--out", str(out), "--unnamed", "--json").stdout)
|
|
assert len(unnamed["clusters"]) == len(clusters)
|
|
|
|
|
|
def test_name_round_trips_through_stdin(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
clusters = json.loads(
|
|
_run(home, "sessions-clusters", "--out", str(out), "--json").stdout)["clusters"]
|
|
target = clusters[0]["id"]
|
|
|
|
proc = _run(home, "sessions-name", "--out", str(out), "--from", "-",
|
|
stdin=json.dumps([{"id": target, "name": "warden telemetry",
|
|
"summary": "the telemetry pipeline work"}]))
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert json.loads(proc.stdout)["named"] == 1
|
|
|
|
after = json.loads(
|
|
_run(home, "sessions-clusters", "--out", str(out), "--json").stdout)["clusters"]
|
|
assert any(c["name"] == "warden telemetry" for c in after)
|
|
|
|
still_unnamed = json.loads(
|
|
_run(home, "sessions-clusters", "--out", str(out), "--unnamed", "--json").stdout)["clusters"]
|
|
assert all(c["name"] is None for c in still_unnamed)
|
|
|
|
|
|
def test_name_rejects_malformed_input(tmp_path: Path) -> None:
|
|
home, out = _build(tmp_path)
|
|
proc = _run(home, "sessions-name", "--out", str(out), "--from", "-", stdin="not json")
|
|
assert proc.returncode == 1
|
|
assert "Traceback" not in proc.stderr
|
|
|
|
|
|
def test_brain_dir_env_var_is_honoured(tmp_path: Path) -> None:
|
|
home, claude, out = tmp_path / "home", _cache(tmp_path), tmp_path / "envbrain"
|
|
home.mkdir()
|
|
env = os.environ.copy()
|
|
env["HOME"] = str(home)
|
|
env["WIKI_SESSION_BRAIN_DIR"] = str(out)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-m", "obsidian_wiki.cli", "sessions-build",
|
|
"--claude-dir", str(claude), "--json"],
|
|
capture_output=True, text=True, env=env)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert (out / "graph.json").is_file()
|