diff --git a/README.md b/README.md index 358663f..28b1256 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Neo4j](https://img.shields.io/badge/Neo4j-native-008CC1?logo=neo4j)](https://neo4j.com) [![Claude Code](https://img.shields.io/badge/Claude%20Code-MCP-blueviolet)](https://claude.ai/code) +[![Codex](https://img.shields.io/badge/Codex-MCP-black)](https://openai.com/codex) [![OpenClaw](https://img.shields.io/badge/OpenClaw-skill-orange)](https://openclaw.ai) +> **Graph-native persistent cognition for AI agents.** +> > **graphify gives you a snapshot. wisdomGraph gives you memory that compounds.** -Type `/wisdom` in Claude Code or OpenClaw. Feed it your codebases, notes, papers, conversations — every run **merges** into a living Neo4j graph. The graph doesn't reset. It accumulates. Facts become patterns. Patterns become insights. Insights become wisdom. +Use wisdomGraph from Claude Code, Codex, OpenClaw, or any MCP host. Feed it your codebases, notes, papers, conversations — every run **merges** into a living Neo4j graph. The graph doesn't reset. It accumulates. Facts become patterns. Patterns become insights. Insights become wisdom. ``` /wisdom . # absorb this project into the wisdom graph @@ -35,7 +38,7 @@ wisdomGraph does something fundamentally different. | **Memory** | resets each session | accumulates across sessions, projects, months | | **Reasoning** | community detection (topology) | graph path traversal + DIKW hierarchy | | **Feedback loop** | none | Wisdom → Knowledge (neuroplasticity) | -| **Database** | none required | Neo4j Aura (free) or DozerDB Docker | +| **Database** | none required | Neo4j Aura (free) or local Neo4j Docker | The difference is not incremental. It's architectural. graphify compresses a codebase into a readable report. wisdomGraph builds an artificial epistemology — one that remembers, connects, and grows. @@ -63,15 +66,51 @@ The feedback loop is critical: when a Wisdom node is queried and found useful, i ## Install -**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [OpenClaw](https://openclaw.ai) +**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenClaw](https://openclaw.ai), or another MCP host -**And one of:** [Neo4j Aura Free](https://neo4j.com/cloud/platform/aura-graph-database/) (cloud, no install) or [DozerDB](https://dozerdb.org) (local Docker, APOC included) +**And one of:** [Neo4j Aura Free](https://neo4j.com/cloud/platform/aura-graph-database/) (cloud, no install) or Docker Desktop/Engine for a managed local Neo4j container ```bash -pip install 'wisdomgraph[mcp]' && wisdom install +pip install 'wisdomgraph[mcp]' +wisdom quickstart ``` -### Option A — Neo4j Aura (zero infra, recommended for individuals) +`wisdom quickstart` is the end-to-end first-time setup. It prepares storage, verifies the Neo4j connection, and registers wisdomGraph with detected MCP hosts. + +```bash +# Local managed Neo4j backend + detected MCP hosts +wisdom quickstart + +# Local backend + Codex only +wisdom quickstart --host codex + +# Existing Neo4j or DozerDB instance +wisdom quickstart --storage existing --uri bolt://localhost:7689 --user neo4j --password + +# Neo4j Aura +wisdom quickstart --storage aura --uri bolt+s://xxxxxxxx.databases.neo4j.io --user neo4j --password +``` + +The MCP server itself never starts Docker or creates databases. Storage setup is explicit through `quickstart`, `local`, `docker`, or `connect`. + +### Option A — Managed local backend (recommended first run) + +```bash +wisdom local up +wisdom doctor +``` + +This starts a managed `neo4j:latest` container named `wisdomgraph-neo4j`, stores data under `~/.wisdom/neo4j`, uses the documented local login `neo4j/password`, saves the connection, and leaves MCP startup cleanly separate. The implementation uses the Docker CLI directly, so the same `wisdom local up` command works from Windows PowerShell, Windows cmd.exe, macOS Terminal, and Ubuntu Terminal after Docker is installed. + +Useful commands: + +```bash +wisdom local status +wisdom local logs +wisdom local down +``` + +### Option B — Neo4j Aura (zero local database) 1. Create a free account at [neo4j.com/cloud/aura](https://neo4j.com/cloud/aura) 2. Create a free AuraDB instance — copy the connection URI and password @@ -83,7 +122,7 @@ wisdom connect bolt+s://xxxxxxxx.databases.neo4j.io --user neo4j --password Experience -> Insight -> Wisdom + +Every run merges into Neo4j. The graph does not reset. Facts become patterns. Patterns become insights. Insights become wisdom. + +Version 0.3.0 adds Codex MCP support, so Codex can now use the same persistent graph memory as Claude Code. + +That means your agent can: + +- ingest a codebase, note, paper, URL, or conversation +- remember decisions and lessons learned +- query prior project experience +- trace why a principle exists +- reflect across projects to promote repeated patterns into higher-level wisdom +- reuse the same memory graph across tools + +Setup is now: + +```bash +pip install 'wisdomgraph[mcp]' +wisdom quickstart +wisdom mcp-install --host codex +``` + +Then start a new Codex session and the MCP tools are available. + +I built this because I do not think the future of agent memory is just bigger context windows. + +Bigger context lets an agent read more. + +Persistent graph memory lets an agent accumulate experience. + +GitHub: +https://github.com/cklam12345/wisdomGraph + +If you are building with Claude Code, Codex, MCP, Neo4j, GraphRAG, or agent memory, I would love feedback on the model and the 0.3.0 setup path. + +## Shorter Post + +wisdomGraph 0.3.0 is out. + +The main addition: Codex MCP support. + +wisdomGraph gives AI coding agents persistent Neo4j-backed memory across sessions and projects. + +Instead of treating memory as a summary file or vector store, it uses a DIKW graph: + +Knowledge -> Experience -> Insight -> Wisdom + +Every run merges into the graph. The graph compounds. + +Claude Code, Codex, OpenClaw, and MCP hosts can now use the same persistent memory layer. + +```bash +pip install 'wisdomgraph[mcp]' +wisdom quickstart +wisdom mcp-install --host codex +``` + +GitHub: +https://github.com/cklam12345/wisdomGraph + +I am looking for builders willing to try it on real projects and tell me where the memory model breaks. + +## Technical Founder Version + +I released wisdomGraph 0.3.0. + +It is an open-source memory layer for AI coding agents. + +The premise is simple: agents should accumulate experience across projects instead of starting from zero every session. + +wisdomGraph stores agent memory in Neo4j as a DIKW hierarchy: + +Knowledge -> Experience -> Insight -> Wisdom + +Version 0.3.0 adds Codex MCP support, which means Claude Code and Codex can now share the same persistent graph memory. + +This is useful when you want an agent to remember: + +- architecture decisions +- debugging lessons +- recurring project patterns +- source-grounded rationale +- contradictions between old and new assumptions + +It is not trying to be a chatbot memory toy. It is infrastructure for compounding agent work. + +GitHub: +https://github.com/cklam12345/wisdomGraph + +## DM Template: Warm Technical Contact + +Hey [Name], I just released wisdomGraph 0.3.0 and thought of you because you have been working around [agents / MCP / Neo4j / coding assistants]. + +It gives Claude Code, Codex, and MCP hosts persistent Neo4j-backed memory across sessions and projects. The new release adds Codex MCP support. + +Would value your technical read on whether the DIKW graph model makes sense for real agent workflows: +https://github.com/cklam12345/wisdomGraph + +No pressure, just wanted to put it on your radar. + +## DM Template: Potential Early User + +Hey [Name], I released wisdomGraph 0.3.0. + +It is a persistent memory layer for AI coding agents: Claude Code, Codex, OpenClaw, and MCP hosts can store and query a Neo4j-backed graph across sessions. + +The part I think may be relevant to you: it is built for real project history, not just chat summaries. + +If you try it on one repo, I would be grateful for blunt feedback: +https://github.com/cklam12345/wisdomGraph + +## DM Template: Neo4j / Graph Person + +Hey [Name], I just shipped wisdomGraph 0.3.0. + +It uses Neo4j as a persistent cognition layer for AI coding agents, with memory organized as Knowledge -> Experience -> Insight -> Wisdom. + +0.3.0 adds Codex MCP support, so the same graph can be used across agent hosts. + +I would value your graph-native critique: +https://github.com/cklam12345/wisdomGraph + +## First Comment + +The repo is here: +https://github.com/cklam12345/wisdomGraph + +Best first test: + +```bash +pip install 'wisdomgraph[mcp]' +wisdom quickstart +wisdom mcp-install --host codex +``` + +I am especially looking for feedback from people using Claude Code, Codex, MCP, Neo4j, or GraphRAG in real workflows. + +## Replies To Expected Comments + +What is different from vector memory? + +Vector memory retrieves semantically similar chunks. wisdomGraph stores typed relationships between facts, experiences, insights, and principles, then traverses that graph. The goal is not just recall. It is accumulated reasoning with provenance. + +Does this replace RAG? + +No. It is closer to a persistent reasoning substrate for an agent. You can still use RAG for document retrieval. wisdomGraph is for compounding project memory, lessons, decisions, and cross-project patterns. + +Why Neo4j? + +The relationships are the product. Cypher traversal, provenance paths, contradiction edges, and promotion through DIKW tiers are central to the model. + +Is it only for Claude? + +No. 0.3.0 adds Codex MCP support, and the MCP server can be used by other MCP hosts. + +## 7-Day Sequence + +Day 1: Launch post. + +Angle: 0.3.0 is out, Codex MCP support, persistent graph memory for agents. + +Optional community version: +Use `marketing/linkedin-wisdom-learn-community-post.md` with `marketing/wisdomLearn.png` to show wisdomGraph recording a real release-verification failure as agent memory. + +Day 2: Technical diagram post. + +Angle: Why DIKW instead of flat memory. + +Post hook: +"Most AI memory systems store facts. Experts store relationships between facts, outcomes, patterns, and principles." + +Day 3: Demo clip or screenshot. + +Angle: Codex using `wisdom_report`, `wisdom_query`, or `wisdom_learn`. + +Post hook: +"Here is Codex querying memory it did not have in its context window." + +Day 4: Founder story. + +Angle: Why bigger context windows are not enough. + +Post hook: +"Bigger context lets an agent read more. It does not make the agent accumulate experience." + +Day 5: Neo4j angle. + +Angle: The graph database is the intelligence layer. + +Post hook: +"For agent memory, the edge is often more important than the node." + +Day 6: Open-source contributor ask. + +Angle: Ask for worked examples. + +Post hook: +"The highest-trust contribution to wisdomGraph is not code. It is a worked example from a real project." + +Day 7: Results and feedback post. + +Angle: Share early feedback, common objections, and what is next. + +Post hook: +"After releasing wisdomGraph 0.3.0, the most useful feedback has been about where agent memory should stop." + +## Targeted Outreach Buckets + +- 50 people building with Claude Code or Codex. +- 50 MCP builders. +- 30 Neo4j or graph database practitioners. +- 30 GraphRAG / knowledge graph people. +- 25 AI engineering founders. +- 25 developer tools maintainers. + +Send no more than 20-30 DMs per day. Prioritize people who have already engaged with your agent, graph, or MCP posts. + +## Metrics To Track + +- Profile views. +- GitHub stars. +- GitHub issues opened. +- PyPI downloads after 0.3.0 is visible. +- Replies from credible builders. +- Number of people who complete `wisdom quickstart`. +- Number of worked examples submitted. diff --git a/marketing/linkedin-wisdom-learn-community-post.md b/marketing/linkedin-wisdom-learn-community-post.md new file mode 100644 index 0000000..08495e0 --- /dev/null +++ b/marketing/linkedin-wisdom-learn-community-post.md @@ -0,0 +1,135 @@ +# LinkedIn Community Post: wisdomGraph Learning From Its Own Mistake + +Image to attach: +`marketing/wisdomLearn.png` + +Alt text: +Screenshot of a terminal and browser. The browser shows PyPI with `wisdomgraph 0.3.0` as the latest version. The terminal shows a user calling out an AI assistant for repeating a release-verification mistake, followed by the assistant attempting to record the failure as a `wisdom_learn` lesson in wisdomGraph. + +## Main Post + +This screenshot is probably the cleanest explanation of why I built wisdomGraph. + +I had just released wisdomGraph 0.3.0. + +I asked my coding agent to help with the launch. + +It checked PyPI through a stale rendered page, concluded that `0.3.0` was not visible, and told me to delay the launch. + +But I was looking at PyPI with my own eyes. + +`wisdomgraph 0.3.0` + +Latest version. + +Right there. + +So I called it out: + +"you are obviously not using wisdomgraph as your memory to learn your mistake?" + +And that is the product. + +Not the mistake. + +The recovery loop. + +The agent turned the failure into a durable lesson: + +For package release verification, do not rely on cached rendered web pages or search snippets alone. Check installer-facing endpoints such as `pip index`, PyPI JSON, and the Simple API before advising launch readiness. + +That lesson is now in the graph. + +Then wisdomGraph reflected on it and promoted it into a failure anti-pattern. + +This is what I mean by persistent cognition for AI agents. + +Not "memory" as a bigger context window. + +Not "memory" as a summary file. + +Memory as accumulated experience: + +- what was tried +- what failed +- why it failed +- what should be avoided next time +- how that lesson connects to future decisions + +The interesting part of agent memory is not perfect recall. + +The interesting part is whether the system can become harder to fool by the same class of mistake. + +wisdomGraph 0.3.0 is live now. + +It works with Claude Code, Codex, OpenClaw, and MCP hosts. + +GitHub: +https://github.com/cklam12345/wisdomGraph + +PyPI: +https://pypi.org/project/wisdomgraph/ + +## Shorter Version + +This screenshot is the product. + +I asked my coding agent to help launch wisdomGraph 0.3.0. + +It checked a stale PyPI page and incorrectly told me the release was not live. + +I was looking at `wisdomgraph 0.3.0` on PyPI with my own eyes. + +So I called it out: + +"you are obviously not using wisdomgraph as your memory to learn your mistake?" + +The agent then recorded the failure into wisdomGraph: + +Do not verify package releases from cached rendered pages alone. Use `pip index`, PyPI JSON, and the Simple API before advising launch readiness. + +That is the loop I care about. + +Agents should not just make mistakes. + +They should accumulate the lessons from them. + +wisdomGraph 0.3.0 is live: +https://github.com/cklam12345/wisdomGraph + +## First Comment + +The release is here: +https://pypi.org/project/wisdomgraph/ + +Repo: +https://github.com/cklam12345/wisdomGraph + +Best first test: + +```bash +pip install 'wisdomgraph[mcp]' +wisdom quickstart +wisdom mcp-install --host codex +``` + +## Suggested Hook Variants + +1. My AI agent made a launch mistake. Then it wrote the mistake into its own memory graph. +2. This is the difference between chatbot memory and agent experience. +3. The most important part of agent memory is not recall. It is not repeating the same class of failure. +4. A bigger context window would not have fixed this. A durable failure lesson might. + +## Comment Replies + +Is this staged? + +No. The mistake was real: a cached rendered PyPI page disagreed with PyPI JSON, the Simple API, and `pip index`. The screenshot captures the moment I pushed the agent to store the lesson. + +Why is this better than a note file? + +A note file can store the text. wisdomGraph stores it as an Experience with outcome, confidence, project context, and promotion paths into anti-pattern Insights and Wisdom. + +What changed in 0.3.0? + +Codex MCP support, `wisdom quickstart`, broader MCP tools, and a smoother path for using one persistent Neo4j-backed graph across agent hosts. diff --git a/marketing/wisdomLearn.png b/marketing/wisdomLearn.png new file mode 100644 index 0000000..c6e0963 Binary files /dev/null and b/marketing/wisdomLearn.png differ diff --git a/pyproject.toml b/pyproject.toml index b9d2cc8..01f38c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=68,<77"] build-backend = "setuptools.build_meta" [project] name = "wisdomgraph" -version = "0.2.0" -description = "Accumulative Neo4j-native DIKW wisdom memory for AI coding assistants (Claude Code, OpenClaw)" +version = "0.3.2" +description = "Graph-native persistent cognition for AI agents using Neo4j, DIKW, and MCP" readme = "README.md" -license = "MIT" -license-files = ["LICENSE"] +license = {text = "MIT"} keywords = [ "claude", "claude-code", "openclaw", "neo4j", "knowledge-graph", "graphrag", "dikw", "wisdom", "memory", "accumulative", "llm", - "skill", "agent-memory", "dozerdb", "mcp", "model-context-protocol", + "skill", "agent-memory", "agentic-memory", "multi-agent", "dozerdb", + "mcp", "model-context-protocol", "codex", ] requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_mcp.py b/tests/test_mcp.py index db19c01..36cc3e4 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib +import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -448,13 +449,145 @@ def capture_run(query, **kwargs): # ── Tool list ───────────────────────────────────────────────────────────────── -def test_tool_list_has_six_tools(): +def test_status_returns_counts(): + from wisdom.mcp import _handle_status + + fake_stats = { + "Knowledge": 74, + "Experience": 16, + "Insight": 11, + "Wisdom": 6, + "Source": 30, + "edges": 215, + } + + with patch("wisdom.mcp._get_driver", return_value=MagicMock()), \ + patch("wisdom.connect.status", return_value=fake_stats): + result = _handle_status({}) + + assert not result.isError + text = result.content[0].text + assert "Knowledge" in text + assert "Insight" in text + assert "215" in text + + +def test_list_defaults_to_insight_nodes(): + from wisdom.mcp import _handle_list + + session = MagicMock() + session.run.return_value = iter([ + { + "id": "insight:abc", + "label": "Agent infrastructure positioning", + "tier": "insight", + "project": "wisdomGraph", + "confidence": 0.95, + "degree": 3, + } + ]) + driver = _make_driver(session) + + with patch("wisdom.mcp._get_driver", return_value=driver): + result = _handle_list({}) + + assert not result.isError + text = result.content[0].text + assert "Insight nodes" in text + assert "Agent infrastructure" in text + + +def test_list_rejects_invalid_tier(): + from wisdom.mcp import _handle_list + result = _handle_list({"tier": "source"}) + assert result.isError + assert "tier" in result.content[0].text + + +def test_trace_requires_id_or_label(): + from wisdom.mcp import _handle_trace + result = _handle_trace({}) + assert result.isError + assert "id" in result.content[0].text + + +def test_trace_returns_node_neighborhood(): + from wisdom.mcp import _handle_trace + + node_record = { + "id": "insight:abc", + "label": "Agent infrastructure positioning", + "tier": "insight", + "content": "Persistent cognition for agents.", + "principle": None, + "confidence": 0.95, + } + edge_record = { + "source": "Repo visit", + "relation": "REVEALS", + "target": "Agent infrastructure positioning", + } + + session = MagicMock() + session.run.side_effect = [ + SimpleNamespace(single=lambda: node_record), + iter([edge_record]), + ] + driver = _make_driver(session) + + with patch("wisdom.mcp._get_driver", return_value=driver), \ + patch("wisdom.traverse.walk_dikw_path", return_value=[]), \ + patch("wisdom.traverse.get_provenance", return_value=[]): + result = _handle_trace({"label": "Agent infrastructure"}) + + assert not result.isError + text = result.content[0].text + assert "Trace" in text + assert "Agent infrastructure positioning" in text + assert "REVEALS" in text + + +def test_explain_requires_label(): + from wisdom.mcp import _handle_explain + result = _handle_explain({}) + assert result.isError + assert "label" in result.content[0].text + + +def test_explain_formats_dikw_chain(): + from wisdom.mcp import _handle_explain + + explanation = { + "id": "insight:abc", + "label": "Agent infrastructure positioning", + "tier": "insight", + "content": "Persistent cognition for agents.", + "confidence": 0.95, + "dikw_chain": [{"label": "WisdomGraph", "tier": "knowledge"}], + "sources": [{"uri": "README.md"}], + } + + with patch("wisdom.mcp._get_driver", return_value=_make_driver()), \ + patch("wisdom.traverse.explain_node", return_value=explanation): + result = _handle_explain({"label": "Agent infrastructure"}) + + assert not result.isError + text = result.content[0].text + assert "DIKW chain" in text + assert "README.md" in text + + +def test_tool_list_has_ten_tools(): from wisdom.mcp import _TOOL_DEFS names = {t["name"] for t in _TOOL_DEFS} assert names == { "wisdom_ingest", "wisdom_remember", "wisdom_learn", + "wisdom_status", + "wisdom_list", + "wisdom_trace", + "wisdom_explain", "wisdom_query", "wisdom_reflect", "wisdom_report", @@ -482,7 +615,7 @@ def test_mcp_install_writes_settings(tmp_path): import sys from wisdom.__main__ import _install_mcp - _install_mcp(project_dir=tmp_path) + _install_mcp(project_dir=tmp_path, host="claude") settings_path = tmp_path / ".claude" / "settings.json" assert settings_path.exists() @@ -499,10 +632,215 @@ def test_mcp_install_idempotent(tmp_path): import json from wisdom.__main__ import _install_mcp - _install_mcp(project_dir=tmp_path) - _install_mcp(project_dir=tmp_path) + _install_mcp(project_dir=tmp_path, host="claude") + _install_mcp(project_dir=tmp_path, host="claude") settings_path = tmp_path / ".claude" / "settings.json" settings = json.loads(settings_path.read_text()) # Should still be a single entry, not a list assert isinstance(settings["mcpServers"]["wisdomGraph"], dict) + + +def test_mcp_install_codex_noops_when_existing(): + from wisdom.__main__ import _install_mcp + + completed = SimpleNamespace(returncode=0, stdout="", stderr="") + with patch("shutil.which", return_value="/usr/bin/codex"), \ + patch("subprocess.run", return_value=completed) as run: + _install_mcp(host="codex") + + run.assert_called_once_with( + ["codex", "mcp", "get", "wisdomGraph"], + stdout=-3, + stderr=-3, + check=False, + ) + + +def test_mcp_install_rejects_unknown_host(): + from wisdom.__main__ import _install_mcp + + with pytest.raises(SystemExit): + _install_mcp(host="unknown") + + +# ── local backend / quickstart CLI ──────────────────────────────────────────── + +def test_local_default_image_is_official_neo4j(monkeypatch): + import wisdom.local as local + + monkeypatch.delenv("WISDOM_NEO4J_IMAGE", raising=False) + monkeypatch.delenv("WISDOM_NEO4J_ENGINE", raising=False) + + assert local._select_image() == "neo4j:latest" + + +def test_local_dozerdb_engine_is_explicit(): + import wisdom.local as local + + assert local._select_image(engine="dozerdb") == "graphstack/dozerdb:5.26.3.0" + + +def test_local_docker_run_args_are_shell_independent(tmp_path, monkeypatch): + import wisdom.local as local + + monkeypatch.setattr(local, "BASE_DIR", tmp_path / "neo4j") + args = local._docker_run_args("neo4j:latest", "password") + + assert args[:3] == ["docker", "run", "-d"] + assert "neo4j:latest" == args[-1] + assert "NEO4J_AUTH=neo4j/password" in args + assert all(isinstance(part, str) for part in args) + +def test_local_up_starts_managed_backend_without_existing_container(tmp_path, monkeypatch): + import wisdom.local as local + + calls = [] + + def fake_run(cmd, capture=False): + calls.append(cmd) + if cmd[:2] == ["docker", "info"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd[:3] == ["docker", "ps", "-q"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd[:3] == ["docker", "ps", "-aq"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + return SimpleNamespace(returncode=0, stdout="ok", stderr="") + + monkeypatch.setattr(local, "BASE_DIR", tmp_path / "neo4j") + monkeypatch.setattr(local, "PASSWORD_PATH", tmp_path / "local-password") + + with patch("wisdom.local.shutil.which", return_value="/usr/bin/docker"), \ + patch("wisdom.local._run", side_effect=fake_run), \ + patch("wisdom.local._wait_until_ready", return_value=True), \ + patch("wisdom.local.save_connection") as save: + local.up(password="test-password") + + assert any(cmd[:3] == ["docker", "run", "-d"] for cmd in calls) + save.assert_called_once_with(local.URI, local.USER, "test-password") + + +def test_local_up_reuses_existing_running_backend(tmp_path, monkeypatch): + import wisdom.local as local + + def fake_run(cmd, capture=False): + if cmd[:2] == ["docker", "info"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd[:3] == ["docker", "ps", "-q"]: + return SimpleNamespace(returncode=0, stdout="abc123\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + pw_path = tmp_path / "local-password" + pw_path.write_text("stored-secret\n", encoding="utf-8") + monkeypatch.setattr(local, "PASSWORD_PATH", pw_path) + + with patch("wisdom.local.shutil.which", return_value="/usr/bin/docker"), \ + patch("wisdom.local._run", side_effect=fake_run), \ + patch("wisdom.local.save_connection") as save: + local.up(password="test-password") + + # An existing container keeps its baked-in password; --password is ignored. + save.assert_called_once_with(local.URI, local.USER, "stored-secret") + + +def test_wait_until_ready_fails_when_port_never_opens(): + import wisdom.local as local + + with patch("wisdom.local._wait_for_port", return_value=False), \ + patch("wisdom.local._probe_cypher") as probe: + assert local._wait_until_ready("password", timeout_s=1) is False + probe.assert_not_called() + + +def test_wait_until_ready_waits_for_cypher_after_port_opens(): + import wisdom.local as local + + # Port is open immediately, but Cypher is only ready on the 2nd probe. + with patch("wisdom.local._wait_for_port", return_value=True), \ + patch("wisdom.local._probe_cypher", side_effect=[False, True]), \ + patch("wisdom.local.time.sleep"): + assert local._wait_until_ready("password", timeout_s=5) is True + + +def test_probe_cypher_is_graceful_without_neo4j_driver(monkeypatch): + import wisdom.local as local + + # Simulate the neo4j driver not being importable in this environment. + monkeypatch.setitem(sys.modules, "neo4j", None) + assert local._probe_cypher("password") is True + + +def test_quickstart_local_registers_requested_host(monkeypatch): + import sys + from wisdom.__main__ import _run_quickstart + + monkeypatch.setattr(sys, "argv", ["wisdom", "quickstart", "--host", "codex"]) + + with patch("wisdom.local.up") as local_up, \ + patch("wisdom.__main__._doctor") as doctor, \ + patch("wisdom.__main__._install_mcp") as install_mcp: + _run_quickstart() + + local_up.assert_called_once_with(password=None, image=None, engine=None) + doctor.assert_called_once_with(connect_only=True) + install_mcp.assert_called_once_with(host="codex") + + +def test_quickstart_existing_requires_uri(monkeypatch): + import sys + from wisdom.__main__ import _run_quickstart + + monkeypatch.setattr(sys, "argv", ["wisdom", "quickstart", "--storage", "existing", "--host", "none"]) + + with pytest.raises(SystemExit): + _run_quickstart() + + +# ── MCP driver auto-start ────────────────────────────────────────────────────── + +def test_get_driver_autostarts_local_backend_on_first_failure(): + import wisdom.mcp as mcp + + good_driver = object() + # First get_driver() fails (nothing running); after autostart, it succeeds. + get_driver = MagicMock(side_effect=[SystemExit(1), good_driver]) + + with patch("wisdom.connect.get_driver", get_driver), \ + patch("wisdom.mcp._try_autostart_local", return_value=True) as autostart: + assert mcp._get_driver() is good_driver + + autostart.assert_called_once() + assert get_driver.call_count == 2 + + +def test_get_driver_raises_helpful_error_when_autostart_unavailable(): + import wisdom.mcp as mcp + + get_driver = MagicMock(side_effect=SystemExit(1)) + + with patch("wisdom.connect.get_driver", get_driver), \ + patch("wisdom.mcp._try_autostart_local", return_value=False): + with pytest.raises(RuntimeError) as exc: + mcp._get_driver() + + assert "quickstart" in str(exc.value) + # Only the initial attempt — no retry when autostart could not run. + assert get_driver.call_count == 1 + + +def test_try_autostart_local_skips_when_docker_absent(): + import wisdom.mcp as mcp + + with patch("wisdom.local.docker_daemon_available", return_value=False), \ + patch("wisdom.local.up") as up: + assert mcp._try_autostart_local() is False + up.assert_not_called() + + +def test_try_autostart_local_starts_backend_when_docker_present(): + import wisdom.mcp as mcp + + with patch("wisdom.local.docker_daemon_available", return_value=True), \ + patch("wisdom.local.up") as up: + assert mcp._try_autostart_local() is True + up.assert_called_once_with(connect=True) diff --git a/wisdom/__init__.py b/wisdom/__init__.py index 643671b..c89e3d8 100644 --- a/wisdom/__init__.py +++ b/wisdom/__init__.py @@ -1,4 +1,4 @@ -"""wisdomGraph — accumulative Neo4j-native wisdom memory for AI coding assistants.""" +"""wisdomGraph — graph-native persistent cognition for AI agents.""" from __future__ import annotations try: diff --git a/wisdom/__main__.py b/wisdom/__main__.py index f902cc1..667747b 100644 --- a/wisdom/__main__.py +++ b/wisdom/__main__.py @@ -154,7 +154,19 @@ def _install_claude_hook(project_dir: Path) -> None: print(" .claude/settings.json -> PreToolUse hook registered") -def _install_mcp(project_dir: Path | None = None) -> None: +def _install_mcp(project_dir: Path | None = None, host: str = "claude") -> None: + """Register wisdomGraph as an MCP server for a supported host.""" + host = host.lower() + if host == "claude": + _install_claude_mcp(project_dir) + elif host == "codex": + _install_codex_mcp() + else: + print("error: unknown MCP host. Choose: claude, codex", file=sys.stderr) + sys.exit(1) + + +def _install_claude_mcp(project_dir: Path | None = None) -> None: """Register wisdomGraph as an MCP server in .claude/settings.json.""" target_dir = project_dir or Path(".") settings_path = target_dir / ".claude" / "settings.json" @@ -178,7 +190,157 @@ def _install_mcp(project_dir: Path | None = None) -> None: print(f" .claude/settings.json -> wisdomGraph MCP server registered") print() print("Claude Code will now call wisdomGraph tools directly:") - print(" wisdom_ingest, wisdom_remember, wisdom_query, wisdom_reflect, wisdom_report") + print(" wisdom_ingest, wisdom_remember, wisdom_learn, wisdom_status, wisdom_list,") + print(" wisdom_trace, wisdom_explain, wisdom_query, wisdom_reflect, wisdom_report") + + +def _install_codex_mcp() -> None: + """Register wisdomGraph as a global Codex MCP server.""" + import subprocess + + if not shutil.which("codex"): + print("error: codex CLI not found on PATH.", file=sys.stderr) + print("Install Codex or run manually: codex mcp add wisdomGraph -- wisdom mcp", file=sys.stderr) + sys.exit(1) + + existing = subprocess.run( + ["codex", "mcp", "get", "wisdomGraph"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if existing.returncode == 0: + print(" Codex MCP -> wisdomGraph already registered (no change)") + return + + result = subprocess.run( + ["codex", "mcp", "add", "wisdomGraph", "--", "wisdom", "mcp"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + print("error: failed to register wisdomGraph with Codex.", file=sys.stderr) + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + print("Run manually: codex mcp add wisdomGraph -- wisdom mcp", file=sys.stderr) + sys.exit(result.returncode) + + print(" Codex MCP -> wisdomGraph registered globally") + print() + print("Codex will launch wisdomGraph with:") + print(" wisdom mcp") + + +def _detect_hosts() -> list[str]: + hosts: list[str] = [] + if Path.home().joinpath(".claude").exists() or shutil.which("claude"): + hosts.append("claude") + if shutil.which("codex"): + hosts.append("codex") + return hosts + + +def _expand_hosts(host: str) -> list[str]: + host = host.lower() + if host == "none": + return [] + if host == "all": + hosts = _detect_hosts() + return hosts or ["claude"] + if host in {"claude", "codex"}: + return [host] + print("error: unknown host. Choose: claude, codex, all, none", file=sys.stderr) + sys.exit(1) + + +def _run_quickstart() -> None: + """First-time setup: storage plus optional MCP registration.""" + storage = _get_arg("--storage", "local").lower() + host_arg = _get_arg("--host", "all") + hosts = _expand_hosts(host_arg) + + print("wisdomGraph quickstart") + print() + + if storage == "local": + from wisdom.local import up as local_up + local_up( + password=_get_arg("--password", None), + image=_get_arg("--image", None), + engine=_get_arg("--engine", None), + ) + elif storage in {"aura", "existing"}: + uri = _get_arg("--uri", "") + user = _get_arg("--user", "neo4j") + password = _get_arg("--password", "") + if not uri: + print(f"error: --uri is required for --storage {storage}", file=sys.stderr) + sys.exit(1) + if not password: + import getpass + password = getpass.getpass("Neo4j password: ") + from wisdom.connect import save_connection + save_connection(uri, user, password) + else: + print("error: unknown storage. Choose: local, aura, existing", file=sys.stderr) + sys.exit(1) + + _doctor(connect_only=True) + + for host in hosts: + _install_mcp(host=host) + + print() + print("wisdomGraph is ready.") + if hosts: + print(f"MCP hosts: {', '.join(hosts)}") + print("Start a new agent session so it can discover the MCP tools.") + else: + print("MCP registration skipped. Run `wisdom mcp-install --host ` later.") + + +def _doctor(connect_only: bool = False) -> None: + """Check local setup without mutating Docker or graph state.""" + print("wisdomGraph doctor") + print() + + from wisdom.connect import _load_config + cfg = _load_config() + print(f"Config: {Path.home() / '.wisdom' / 'config.json'}") + print(f"Neo4j URI: {cfg.get('neo4j_uri', 'not configured')}") + print(f"Neo4j user: {cfg.get('neo4j_user', 'not configured')}") + print(f"Password env: {cfg.get('neo4j_password_env', 'WISDOM_NEO4J_PASSWORD')}") + + try: + from wisdom.connect import ensure_schema, get_driver, status as graph_status + driver = get_driver() + ensure_schema(driver) + stats = graph_status(driver) + driver.close() + print("Connection: ok") + print( + "Graph: " + f"{stats.get('Knowledge', 0)} Knowledge, " + f"{stats.get('Experience', 0)} Experience, " + f"{stats.get('Insight', 0)} Insight, " + f"{stats.get('Wisdom', 0)} Wisdom, " + f"{stats.get('edges', 0)} edges" + ) + except SystemExit: + print("Connection: failed") + if connect_only: + raise + if connect_only: + return + + print() + from wisdom.local import docker_available, docker_daemon_available + print(f"Docker installed: {'yes' if docker_available() else 'no'}") + print(f"Docker daemon: {'yes' if docker_daemon_available() else 'no'}") + + hosts = _detect_hosts() + print(f"MCP hosts found: {', '.join(hosts) if hosts else 'none detected'}") def claude_install(project_dir: Path | None = None) -> None: @@ -238,9 +400,35 @@ def main() -> None: run_mcp_server() elif cmd == "mcp-install": - # Register MCP server in .claude/settings.json + # Register MCP server with a supported host. project = _get_arg("--project", None) - _install_mcp(Path(project) if project else None) + host = _get_arg("--host", "claude") + _install_mcp(Path(project) if project else None, host=host) + + elif cmd == "quickstart": + _run_quickstart() + + elif cmd == "doctor": + _doctor() + + elif cmd == "local": + from wisdom.local import down as local_down, logs as local_logs, status as local_status, up as local_up + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + password = _get_arg("--password", None) + image = _get_arg("--image", None) + engine = _get_arg("--engine", None) + if subcmd == "up": + local_up(password=password, image=image, engine=engine) + elif subcmd == "down": + local_down() + elif subcmd == "status": + local_status() + elif subcmd == "logs": + tail = int(_get_arg("--tail", "80")) + local_logs(tail=tail) + else: + print("Usage: wisdom local [up|down|status|logs]", file=sys.stderr) + sys.exit(1) elif cmd == "connect": if len(sys.argv) < 3: @@ -448,14 +636,18 @@ def _print_help() -> None: print("Usage: wisdom ") print() print("Setup:") + print(" quickstart [--storage local|aura|existing] [--host claude|codex|all|none]") + print(" doctor check Neo4j, local backend, and MCP host readiness") print(" install [--platform P] copy skill (claude|windows|claw)") print(" connect --user U --pass P save Neo4j connection") - print(" docker up|down|status manage DozerDB local container") + print(" local up [--engine neo4j|dozerdb] [--image IMAGE] [--password P]") + print(" docker up|down|status manage legacy/manual DozerDB container") print(" claude install|uninstall write CLAUDE.md + PreToolUse hook") print() - print("MCP (Claude Code native integration):") + print("MCP:") print(" mcp start MCP server over stdio") - print(" mcp-install [--project ] register MCP in .claude/settings.json") + print(" mcp-install [--project ] register MCP for Claude Code") + print(" mcp-install --host codex register MCP for Codex") print() print("Absorb:") print(" (use /wisdom in Claude Code — the skill handles absorption)") diff --git a/wisdom/local.py b/wisdom/local.py new file mode 100644 index 0000000..5b2dfbc --- /dev/null +++ b/wisdom/local.py @@ -0,0 +1,291 @@ +"""Managed local Neo4j backend for first-time wisdomGraph setup.""" +from __future__ import annotations + +import os +import shutil +import socket +import stat +import subprocess +import sys +import time +from pathlib import Path + +from .connect import save_connection + +CONTAINER_NAME = "wisdomgraph-neo4j" +DEFAULT_ENGINE = "neo4j" +IMAGE_BY_ENGINE = { + "neo4j": "neo4j:latest", + "dozerdb": "graphstack/dozerdb:5.26.3.0", +} +DEFAULT_PASSWORD = "password" +BASE_DIR = Path.home() / ".wisdom" / "neo4j" +PASSWORD_PATH = Path.home() / ".wisdom" / "local-password" +HTTP_PORT = 7474 +BOLT_PORT = 7687 +URI = f"bolt://localhost:{BOLT_PORT}" +USER = "neo4j" + + +def _run(cmd: list[str], capture: bool = False) -> subprocess.CompletedProcess: + if capture: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + return subprocess.run(cmd, check=False) + + +def docker_available() -> bool: + return shutil.which("docker") is not None + + +def docker_daemon_available() -> bool: + if not docker_available(): + return False + result = _run(["docker", "info"], capture=True) + return result.returncode == 0 + + +def _container_id(all_containers: bool = False) -> str: + args = ["docker", "ps", "-q"] + if all_containers: + args = ["docker", "ps", "-aq"] + result = _run(args + ["-f", f"name=^{CONTAINER_NAME}$"], capture=True) + return result.stdout.strip() + + +def _ensure_dirs() -> None: + for subdir in ("data", "logs", "import", "plugins"): + (BASE_DIR / subdir).mkdir(parents=True, exist_ok=True) + + +def _select_image(engine: str | None = None, image: str | None = None) -> str: + explicit_image = image or os.environ.get("WISDOM_NEO4J_IMAGE") + if explicit_image: + return explicit_image + + selected_engine = (engine or os.environ.get("WISDOM_NEO4J_ENGINE") or DEFAULT_ENGINE).lower() + if selected_engine not in IMAGE_BY_ENGINE: + choices = ", ".join(sorted(IMAGE_BY_ENGINE)) + print(f"error: unknown local backend engine '{selected_engine}'. Choose: {choices}", file=sys.stderr) + sys.exit(1) + return IMAGE_BY_ENGINE[selected_engine] + + +def _read_or_create_password(default: str = DEFAULT_PASSWORD) -> str: + if PASSWORD_PATH.exists(): + return PASSWORD_PATH.read_text(encoding="utf-8").strip() + PASSWORD_PATH.parent.mkdir(parents=True, exist_ok=True) + PASSWORD_PATH.write_text(default + "\n", encoding="utf-8") + PASSWORD_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) + return default + + +def _resolve_password(password: str | None = None) -> str: + selected = password or os.environ.get("WISDOM_NEO4J_PASSWORD") + if selected: + PASSWORD_PATH.parent.mkdir(parents=True, exist_ok=True) + PASSWORD_PATH.write_text(selected + "\n", encoding="utf-8") + PASSWORD_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) + return selected + return _read_or_create_password() + + +def _docker_run_args(image: str, password: str) -> list[str]: + return [ + "docker", "run", "-d", + "--name", CONTAINER_NAME, + "-p", f"{HTTP_PORT}:7474", + "-p", f"{BOLT_PORT}:7687", + "-v", f"{BASE_DIR}/data:/data", + "-v", f"{BASE_DIR}/logs:/logs", + "-v", f"{BASE_DIR}/import:/var/lib/neo4j/import", + "-v", f"{BASE_DIR}/plugins:/plugins", + "--env", f"NEO4J_AUTH=neo4j/{password}", + image, + ] + + +def _wait_for_port(timeout_s: int = 45) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", BOLT_PORT), timeout=1): + return True + except OSError: + time.sleep(1) + return False + + +def _probe_cypher(password: str) -> bool: + """One-shot: can we run an authenticated `RETURN 1` yet? + + Neo4j opens the Bolt port several seconds before it accepts + authenticated queries, so a TCP connect is not proof of readiness. + """ + try: + from neo4j import GraphDatabase + except ImportError: + # Driver not installed here; the TCP probe is the best we can do. + return True + driver = None + try: + driver = GraphDatabase.driver(URI, auth=(USER, password)) + driver.verify_connectivity() + with driver.session() as session: + session.run("RETURN 1").consume() + return True + except Exception: + return False + finally: + if driver is not None: + driver.close() + + +def _wait_until_ready(password: str, timeout_s: int = 90) -> bool: + """Wait until Neo4j accepts an authenticated query, not just a TCP socket.""" + if not _wait_for_port(timeout_s=min(timeout_s, 45)): + return False + deadline = time.time() + timeout_s + while time.time() < deadline: + if _probe_cypher(password): + return True + time.sleep(1) + return False + + +def up( + password: str | None = None, + connect: bool = True, + image: str | None = None, + engine: str | None = None, +) -> None: + """Start the managed local backend and optionally save wisdomGraph connection.""" + selected_image = _select_image(engine=engine, image=image) + + if not docker_available(): + print("error: Docker is not installed or not on PATH.", file=sys.stderr) + print("Install Docker Desktop/Engine, or use Neo4j Aura with:", file=sys.stderr) + print(" wisdom quickstart --storage aura --uri --password ", file=sys.stderr) + sys.exit(1) + if not docker_daemon_available(): + print("error: Docker is installed but the daemon is not reachable.", file=sys.stderr) + print("Start Docker Desktop/Engine, then run: wisdom local up", file=sys.stderr) + sys.exit(1) + + running = _container_id(all_containers=False) + exists = bool(running) or bool(_container_id(all_containers=True)) + + if exists: + # A container's NEO4J_AUTH is fixed at creation time; a new --password + # cannot change it, so reuse the saved one and warn on a mismatch. + saved = _read_or_create_password() + if password and password != saved: + print(" note: a local backend already exists — keeping its existing password.", file=sys.stderr) + print(f" To reset it: wisdom local down && docker rm {CONTAINER_NAME}, then rerun.", file=sys.stderr) + password = saved + else: + password = _resolve_password(password) + + if running: + print(f" Local wisdomGraph backend already running ({CONTAINER_NAME})") + if connect: + save_connection(URI, USER, password) + _print_ready(password, selected_image) + return + + if exists: + print(f" Starting existing local backend ({CONTAINER_NAME})...") + result = _run(["docker", "start", CONTAINER_NAME], capture=True) + if result.returncode != 0: + print(result.stderr.strip() or "error: failed to start local backend", file=sys.stderr) + sys.exit(result.returncode) + _report_if_not_ready(password) + if connect: + save_connection(URI, USER, password) + _print_ready(password, selected_image) + return + + _ensure_dirs() + + print(f" Pulling {selected_image} (first run may take a minute)...") + result = _run(["docker", "pull", selected_image]) # stream progress to the user + if result.returncode != 0: + print(f"error: failed to pull {selected_image}", file=sys.stderr) + print("Check Docker Hub access/proxy settings, then retry: wisdom local up", file=sys.stderr) + sys.exit(result.returncode) + + print(f" Starting managed local backend ({CONTAINER_NAME})...") + result = _run(_docker_run_args(selected_image, password), capture=True) + if result.returncode != 0: + print(result.stderr.strip() or "error: failed to start local backend", file=sys.stderr) + sys.exit(result.returncode) + + print(" Waiting for Neo4j to accept connections...") + _report_if_not_ready(password) + + if connect: + save_connection(URI, USER, password) + _print_ready(password, selected_image) + + +def _report_if_not_ready(password: str) -> None: + if not _wait_until_ready(password): + print("warning: local backend started, but Neo4j was not ready before timeout.", file=sys.stderr) + print("Run `wisdom local logs` or `wisdom doctor` if connection fails.", file=sys.stderr) + + +def down() -> None: + if not docker_available(): + print("error: Docker is not installed or not on PATH.", file=sys.stderr) + sys.exit(1) + if not _container_id(all_containers=False): + print(f" Local backend not running ({CONTAINER_NAME})") + return + result = _run(["docker", "stop", CONTAINER_NAME], capture=True) + if result.returncode != 0: + print(result.stderr.strip() or "error: failed to stop local backend", file=sys.stderr) + sys.exit(result.returncode) + print(f" Local backend stopped. Data preserved in {BASE_DIR}/data") + + +def status() -> None: + if not docker_available(): + print("Docker: not installed") + return + result = _run( + ["docker", "ps", "-a", "-f", f"name=^{CONTAINER_NAME}$", "--format", "{{.Names}}\t{{.Status}}\t{{.Ports}}"], + capture=True, + ) + if result.stdout.strip(): + print(result.stdout.strip()) + else: + print(f"No managed local backend found. Run: wisdom local up") + + +def logs(tail: int = 80) -> None: + if not docker_available(): + print("error: Docker is not installed or not on PATH.", file=sys.stderr) + sys.exit(1) + if not _container_id(all_containers=True): + print(f"No managed local backend found. Run: wisdom local up") + return + result = _run(["docker", "logs", "--tail", str(tail), CONTAINER_NAME], capture=True) + if result.stdout: + print(result.stdout.rstrip()) + if result.stderr: + print(result.stderr.rstrip(), file=sys.stderr) + + +def _print_ready(password: str, image: str) -> None: + print() + print(" wisdomGraph local backend is ready") + print() + print(f" Container: {CONTAINER_NAME}") + print(f" Image: {image}") + print(f" Browser: http://localhost:{HTTP_PORT}") + print(f" Bolt URI: {URI}") + print(f" User: {USER}") + if password == DEFAULT_PASSWORD: + print(f" Password: {password}") + else: + print(f" Password: stored in {PASSWORD_PATH}") + print() diff --git a/wisdom/mcp.py b/wisdom/mcp.py index 6e1f8cf..343e656 100644 --- a/wisdom/mcp.py +++ b/wisdom/mcp.py @@ -15,6 +15,10 @@ - wisdom_ingest : ingest a file / directory / URL into Neo4j - wisdom_remember : store an explicit knowledge node - wisdom_learn : record an outcome (SUCCEEDED/FAILED/PARTIAL) + lesson learned +- wisdom_status : return DIKW tier counts and edge/source totals +- wisdom_list : list nodes by DIKW tier, project, and connectivity +- wisdom_trace : trace a node's graph neighborhood and DIKW provenance +- wisdom_explain : explain a node with its DIKW chain and sources - wisdom_query : run a Cypher traversal and return results - wisdom_reflect : run the DIKW promotion pipeline (surfaces failure anti-patterns) - wisdom_report : return tier counts + top Wisdom nodes + PREVENTS warnings as markdown @@ -201,6 +205,87 @@ def _err(text: str) -> _Result: "required": ["cypher"], }, }, + { + "name": "wisdom_status", + "description": ( + "Return wisdomGraph status: Knowledge, Experience, Insight, Wisdom, Source, " + "and edge counts. Read-only. Use this at the start of an agent session." + ), + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "wisdom_list", + "description": ( + "List nodes from a DIKW tier, optionally filtered by project, ordered by graph degree. " + "Read-only. Useful for seeing available knowledge, insights, or wisdom without Cypher." + ), + "inputSchema": { + "type": "object", + "properties": { + "tier": { + "type": "string", + "enum": ["knowledge", "experience", "insight", "wisdom"], + "description": "DIKW tier to list. Defaults to insight.", + }, + "project": { + "type": "string", + "description": "Optional project filter.", + }, + "limit": { + "type": "integer", + "description": "Max rows to return. Defaults to 10.", + }, + }, + }, + }, + { + "name": "wisdom_trace", + "description": ( + "Trace a node by id or label and return its DIKW provenance plus neighboring edges. " + "Read-only. Use this to explain why an insight or wisdom node exists." + ), + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact node id. Optional if label is provided.", + }, + "label": { + "type": "string", + "description": "Label substring to find. Optional if id is provided.", + }, + "depth": { + "type": "integer", + "description": "Relationship depth for the neighborhood. Defaults to 2.", + }, + "limit": { + "type": "integer", + "description": "Max relationships to return. Defaults to 25.", + }, + }, + }, + }, + { + "name": "wisdom_explain", + "description": ( + "Explain a node by label with its DIKW chain and source provenance. " + "Read-only convenience wrapper around wisdomGraph traversal." + ), + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Node label substring to explain.", + }, + }, + "required": ["label"], + }, + }, { "name": "wisdom_reflect", "description": ( @@ -240,16 +325,48 @@ def _err(text: str) -> _Result: # ── Handler helpers ─────────────────────────────────────────────────────────── -def _get_driver(): +_CONNECT_HELP = ( + "Cannot connect to Neo4j, and a managed local backend could not be started " + "automatically (Docker not available). Run `wisdom quickstart` to provision a " + "local backend, or `wisdom connect --user --password

` to point at " + "an existing Neo4j/DozerDB/Aura instance." +) + + +def _try_autostart_local() -> bool: + """Best-effort: bring up the managed local backend when Docker is available. + + Runs silently — stdout is redirected to stderr so `local.up`'s progress output + never corrupts the MCP stdio (JSON-RPC) stream. Returns True if it started. + """ + import contextlib + + try: + from . import local + except Exception: + return False + if not local.docker_daemon_available(): + return False + try: + with contextlib.redirect_stdout(sys.stderr): + local.up(connect=True) + return True + except SystemExit: + return False + + +def _get_driver(_allow_autostart: bool = True): """Return a live Neo4j driver (or raise RuntimeError with a helpful message).""" + from .connect import get_driver try: - from .connect import get_driver return get_driver() except SystemExit as e: - raise RuntimeError( - "Cannot connect to Neo4j. Make sure DozerDB is running (`wisdom docker up`) " - "and credentials are configured (`wisdom connect`)." - ) from e + if _allow_autostart and _try_autostart_local(): + try: + return get_driver() + except SystemExit as e2: + raise RuntimeError(_CONNECT_HELP) from e2 + raise RuntimeError(_CONNECT_HELP) from e # ── Tool handlers (pure Python — no mcp types needed) ──────────────────────── @@ -512,6 +629,205 @@ def _handle_query(args: dict[str, Any]) -> _Result: return _ok("\n".join([header, sep] + rows)) +def _handle_status(args: dict[str, Any]) -> _Result: + try: + driver = _get_driver() + except RuntimeError as exc: + return _err(str(exc)) + + from .connect import status as graph_status + stats = graph_status(driver) + driver.close() + + lines = [ + "## wisdomGraph status", + "", + f"- Knowledge: {stats.get('Knowledge', 0)}", + f"- Experience: {stats.get('Experience', 0)}", + f"- Insight: {stats.get('Insight', 0)}", + f"- Wisdom: {stats.get('Wisdom', 0)}", + f"- Sources: {stats.get('Source', 0)}", + f"- Edges: {stats.get('edges', 0)}", + ] + return _ok("\n".join(lines)) + + +def _handle_list(args: dict[str, Any]) -> _Result: + tier = args.get("tier", "insight").lower() + project = args.get("project", "") + limit = int(args.get("limit", 10)) + label = { + "knowledge": "Knowledge", + "experience": "Experience", + "insight": "Insight", + "wisdom": "Wisdom", + }.get(tier) + if not label: + return _err("'tier' must be one of: knowledge, experience, insight, wisdom") + + try: + driver = _get_driver() + except RuntimeError as exc: + return _err(str(exc)) + + where = "WHERE ($project = '' OR n.project = $project)" + cypher = f""" + MATCH (n:{label}) + {where} + RETURN n.id AS id, n.label AS label, n.tier AS tier, + n.project AS project, n.confidence AS confidence, + count{{(n)--()}} AS degree + ORDER BY degree DESC, label ASC + LIMIT $limit + """ + with driver.session() as session: + rows = [dict(r) for r in session.run(cypher, project=project, limit=limit)] + driver.close() + + if not rows: + return _ok(f"No {tier}-tier nodes found.") + + lines = [f"## {label} nodes", ""] + for i, row in enumerate(rows, 1): + confidence = row.get("confidence") + conf = f", confidence={confidence:.2f}" if isinstance(confidence, (int, float)) else "" + project_text = f", project={row.get('project')}" if row.get("project") else "" + lines.append( + f"{i}. **{row.get('label', '')}** " + f"[{row.get('id', '')}] - degree={row.get('degree', 0)}{conf}{project_text}" + ) + return _ok("\n".join(lines)) + + +def _handle_trace(args: dict[str, Any]) -> _Result: + node_id = args.get("id", "").strip() + label_query = args.get("label", "").strip() + depth = max(1, min(int(args.get("depth", 2)), 4)) + limit = int(args.get("limit", 25)) + + if not node_id and not label_query: + return _err("'id' or 'label' is required") + + try: + driver = _get_driver() + except RuntimeError as exc: + return _err(str(exc)) + + with driver.session() as session: + if node_id: + record = session.run( + """ + MATCH (n {id: $id}) + WHERE n:Knowledge OR n:Experience OR n:Insight OR n:Wisdom + RETURN n.id AS id, n.label AS label, n.tier AS tier, + n.content AS content, n.principle AS principle, + n.confidence AS confidence + LIMIT 1 + """, + id=node_id, + ).single() + else: + record = session.run( + """ + MATCH (n) + WHERE (n:Knowledge OR n:Experience OR n:Insight OR n:Wisdom) + AND toLower(n.label) CONTAINS toLower($label) + RETURN n.id AS id, n.label AS label, n.tier AS tier, + n.content AS content, n.principle AS principle, + n.confidence AS confidence + LIMIT 1 + """, + label=label_query, + ).single() + + if not record: + driver.close() + needle = node_id or label_query + return _ok(f"No node found matching `{needle}`.") + + node = dict(record) + from .traverse import get_provenance, walk_dikw_path + chain = walk_dikw_path(session, node["id"]) + sources = get_provenance(session, node["id"]) + edges = [dict(r) for r in session.run( + f""" + MATCH (n {{id: $id}})-[r*1..{depth}]-(m) + WHERE m:Knowledge OR m:Experience OR m:Insight OR m:Wisdom + WITH n, m, relationships(r) AS rels + UNWIND rels AS rel + WITH DISTINCT startNode(rel) AS a, type(rel) AS relation, endNode(rel) AS b + RETURN a.label AS source, relation, b.label AS target + LIMIT $limit + """, + id=node["id"], + limit=limit, + )] + driver.close() + + body = node.get("principle") or node.get("content") or "" + lines = [ + f"## Trace: {node.get('label', '')}", + "", + f"- ID: `{node.get('id', '')}`", + f"- Tier: `{node.get('tier', '')}`", + f"- Confidence: {node.get('confidence', 0) or 0:.2f}", + ] + if body: + lines += ["", body[:1000]] + if chain: + lines += ["", "### DIKW chain"] + for item in chain: + lines.append(f"- {item.get('label', '')} [{item.get('tier', '?')}]") + if sources: + lines += ["", "### Sources"] + for source in sources[:10]: + lines.append(f"- {source.get('uri', '')}") + if edges: + lines += ["", "### Neighborhood"] + for edge in edges: + lines.append(f"- {edge.get('source', '')} -[{edge.get('relation', '')}]-> {edge.get('target', '')}") + return _ok("\n".join(lines)) + + +def _handle_explain(args: dict[str, Any]) -> _Result: + label = args.get("label", "").strip() + if not label: + return _err("'label' is required") + + try: + driver = _get_driver() + except RuntimeError as exc: + return _err(str(exc)) + + from .traverse import explain_node + with driver.session() as session: + result = explain_node(session, label) + driver.close() + + if result.get("error"): + return _ok(result["error"]) + + body = result.get("principle") or result.get("content") or "" + lines = [ + f"## {result.get('label', '')}", + "", + f"- ID: `{result.get('id', '')}`", + f"- Tier: `{result.get('tier', '')}`", + f"- Confidence: {result.get('confidence', 0) or 0:.2f}", + ] + if body: + lines += ["", body[:1000]] + if result.get("dikw_chain"): + lines += ["", "### DIKW chain"] + for item in result["dikw_chain"]: + lines.append(f"- {item.get('label', '')} [{item.get('tier', '?')}]") + if result.get("sources"): + lines += ["", "### Sources"] + for source in result["sources"][:10]: + lines.append(f"- {source.get('uri', '')}") + return _ok("\n".join(lines)) + + def _handle_reflect(args: dict[str, Any]) -> _Result: project = args.get("project", None) @@ -591,6 +907,10 @@ def _handle_report(args: dict[str, Any]) -> _Result: "wisdom_ingest": _handle_ingest, "wisdom_remember": _handle_remember, "wisdom_learn": _handle_learn, + "wisdom_status": _handle_status, + "wisdom_list": _handle_list, + "wisdom_trace": _handle_trace, + "wisdom_explain": _handle_explain, "wisdom_query": _handle_query, "wisdom_reflect": _handle_reflect, "wisdom_report": _handle_report,