From 0d4afcdc07add1471c2686e389e81dcde1f297c2 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:08:40 +0530 Subject: [PATCH 1/9] feat(ui): add plugin cards and richer dashboard snapshots --- meshmind/cli/plugin_stubs/__init__.py | 1 + .../cli/plugin_stubs/demo_review/plugin.yaml | 9 ++ .../demo_review/prompts/system_prompt.txt | 1 + .../plugin_stubs/demo_security/plugin.yaml | 9 ++ .../demo_security/prompts/system_prompt.txt | 1 + meshmind/plugins/loader.py | 20 ++- meshmind/sdk/mesh.py | 2 + meshmind/sdk/node_def.py | 1 + meshmind/ui/app.py | 58 ++++++++ meshmind/ui/template/dashboard.html | 130 ++++++++++++++++-- 10 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 meshmind/cli/plugin_stubs/__init__.py create mode 100644 meshmind/cli/plugin_stubs/demo_review/plugin.yaml create mode 100644 meshmind/cli/plugin_stubs/demo_review/prompts/system_prompt.txt create mode 100644 meshmind/cli/plugin_stubs/demo_security/plugin.yaml create mode 100644 meshmind/cli/plugin_stubs/demo_security/prompts/system_prompt.txt diff --git a/meshmind/cli/plugin_stubs/__init__.py b/meshmind/cli/plugin_stubs/__init__.py new file mode 100644 index 0000000..655174b --- /dev/null +++ b/meshmind/cli/plugin_stubs/__init__.py @@ -0,0 +1 @@ +"""Bundled demo plugins shipped with MeshMind.""" diff --git a/meshmind/cli/plugin_stubs/demo_review/plugin.yaml b/meshmind/cli/plugin_stubs/demo_review/plugin.yaml new file mode 100644 index 0000000..1c8d83c --- /dev/null +++ b/meshmind/cli/plugin_stubs/demo_review/plugin.yaml @@ -0,0 +1,9 @@ +id: demo_review +name: Demo Review +capabilities: + - review + - analysis +functions: + - name: critique + description: Produce a concise review critique. + parameters: {} diff --git a/meshmind/cli/plugin_stubs/demo_review/prompts/system_prompt.txt b/meshmind/cli/plugin_stubs/demo_review/prompts/system_prompt.txt new file mode 100644 index 0000000..57918b6 --- /dev/null +++ b/meshmind/cli/plugin_stubs/demo_review/prompts/system_prompt.txt @@ -0,0 +1 @@ +You are a careful reviewer. Critique plans, code, and prose with direct, actionable feedback. diff --git a/meshmind/cli/plugin_stubs/demo_security/plugin.yaml b/meshmind/cli/plugin_stubs/demo_security/plugin.yaml new file mode 100644 index 0000000..15c464b --- /dev/null +++ b/meshmind/cli/plugin_stubs/demo_security/plugin.yaml @@ -0,0 +1,9 @@ +id: demo_security +name: Demo Security +capabilities: + - security + - threat-modeling +functions: + - name: assess_risk + description: Produce a short security risk assessment. + parameters: {} diff --git a/meshmind/cli/plugin_stubs/demo_security/prompts/system_prompt.txt b/meshmind/cli/plugin_stubs/demo_security/prompts/system_prompt.txt new file mode 100644 index 0000000..01a69b7 --- /dev/null +++ b/meshmind/cli/plugin_stubs/demo_security/prompts/system_prompt.txt @@ -0,0 +1 @@ +You are a security reviewer. Look for risks, unsafe defaults, and missing safeguards. diff --git a/meshmind/plugins/loader.py b/meshmind/plugins/loader.py index 905fe15..d9a73c0 100644 --- a/meshmind/plugins/loader.py +++ b/meshmind/plugins/loader.py @@ -18,6 +18,7 @@ import importlib import logging +import os from pathlib import Path from typing import Any @@ -28,6 +29,14 @@ PLUGIN_DIR = Path.home() / ".meshmind" / "plugins" +def get_plugin_dir() -> Path: + """Return the active plugin directory, honoring ``MESHMIND_PLUGIN_DIR``.""" + raw = os.environ.get("MESHMIND_PLUGIN_DIR", "").strip() + if raw: + return Path(raw).expanduser() + return PLUGIN_DIR + + def discover_plugins() -> list[dict[str, Any]]: """Discover installed plugins in ~/.meshmind/plugins/. @@ -35,10 +44,11 @@ def discover_plugins() -> list[dict[str, Any]]: """ plugins: list[dict[str, Any]] = [] - if not PLUGIN_DIR.exists(): + plugin_root = get_plugin_dir() + if not plugin_root.exists(): return plugins - for plugin_path in PLUGIN_DIR.iterdir(): + for plugin_path in plugin_root.iterdir(): if not plugin_path.is_dir(): continue @@ -68,7 +78,7 @@ def load_plugin(name: str) -> dict[str, Any]: Returns config dict with: system_prompt, functions, function_handlers, capabilities. """ # Try local plugin directory first - plugin_path = PLUGIN_DIR / name + plugin_path = get_plugin_dir() / name if plugin_path.exists(): return _load_plugin_from_dir(plugin_path) @@ -84,7 +94,7 @@ def load_plugin(name: str) -> dict[str, Any]: pass raise FileNotFoundError( - f"Plugin '{name}' not found in {PLUGIN_DIR} or as a Python package" + f"Plugin '{name}' not found in {get_plugin_dir()} or as a Python package" ) @@ -153,4 +163,4 @@ def _load_plugin_from_dir(plugin_path: Path) -> dict[str, Any]: return config -__all__ = ["PLUGIN_DIR", "discover_plugins", "load_plugin"] +__all__ = ["PLUGIN_DIR", "discover_plugins", "get_plugin_dir", "load_plugin"] diff --git a/meshmind/sdk/mesh.py b/meshmind/sdk/mesh.py index ef8675b..4fe050c 100644 --- a/meshmind/sdk/mesh.py +++ b/meshmind/sdk/mesh.py @@ -41,6 +41,7 @@ class NodeDef: capabilities: list[str] = field(default_factory=list) knowledge_files: list[str] = field(default_factory=list) knowledge_domains: list[str] = field(default_factory=list) + plugin: str = "" ui: bool = False ui_port: int = 8080 @@ -160,6 +161,7 @@ def from_yaml(cls, path: str | Path) -> "Mesh": capabilities=list(node_def.capabilities), knowledge_files=list(node_def.knowledge), knowledge_domains=list(node_def.knowledge_domains), + plugin=node_def.plugin, ui=node_def.ui, ui_port=node_def.ui_port, ) diff --git a/meshmind/sdk/node_def.py b/meshmind/sdk/node_def.py index 783c5c8..0273917 100644 --- a/meshmind/sdk/node_def.py +++ b/meshmind/sdk/node_def.py @@ -39,6 +39,7 @@ class NodeDef: functions: list[dict] = field(default_factory=list) function_handlers: dict = field(default_factory=dict) knowledge_domains: list[str] = field(default_factory=list) + plugin: str = "" ui: bool = False ui_port: int = 8080 diff --git a/meshmind/ui/app.py b/meshmind/ui/app.py index 03b100c..033bf8a 100644 --- a/meshmind/ui/app.py +++ b/meshmind/ui/app.py @@ -27,6 +27,8 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles +from meshmind.plugins.loader import discover_plugins + logger = logging.getLogger(__name__) UI_DIR = Path(__file__).parent @@ -61,6 +63,54 @@ def _tool_names_from_engine(eng: Any) -> list[str]: return names + +def _preview_text(text: str, limit: int = 240) -> str: + clean = " ".join(text.split()) + if len(clean) <= limit: + return clean + return clean[:limit].rstrip() + "…" + + +def _plugin_card_snapshot(meta: dict[str, Any]) -> dict[str, Any]: + """Compact dashboard record for an installed plugin. + + The UI intentionally shows prompt and tool summaries, but not the plugin id or + knowledge file inventory. + """ + path = meta.get("_path") + plugin_path = Path(path) if isinstance(path, str) and path else None + functions = meta.get("functions") if isinstance(meta.get("functions"), list) else [] + tool_names = [ + str(fn.get("name")) + for fn in functions + if isinstance(fn, dict) and isinstance(fn.get("name"), str) and fn.get("name") + ] + card: dict[str, Any] = { + "name": str(meta.get("name") or meta.get("title") or (plugin_path.name if plugin_path else "plugin")), + "capabilities": list(meta.get("capabilities") or []), + "tool_names": tool_names, + "tool_count": len(tool_names), + "knowledge_domains": list(meta.get("knowledge_domains") or []), + "summary": str(meta.get("summary") or meta.get("description") or ""), + "installed": True, + } + if plugin_path is not None: + try: + repo_root = Path(__file__).resolve().parents[2] + relative = plugin_path.resolve().relative_to(repo_root) + card["source_path"] = str(relative) + card["source_href"] = plugin_path.resolve().as_uri() + except Exception: + pass + prompt_file = plugin_path / "prompts" / "system_prompt.txt" if plugin_path else None + if prompt_file is not None and prompt_file.exists(): + prompt = prompt_file.read_text(encoding="utf-8") + if prompt.strip(): + card["system_prompt_preview"] = _preview_text(prompt, 220) + card["system_prompt_length"] = len(prompt) + return card + + def _mesh_self_snapshot(node: Any) -> dict: """Serializable node record for dashboard / WebSocket (matches peer mesh_state shape). @@ -164,6 +214,14 @@ async def events(limit: int = 50) -> list[dict]: async def traces(limit: int = 20) -> list[dict]: return node.orchestrator.get_recent_traces(limit) + @app.get("/api/plugins") + async def plugins() -> dict: + installed = [_plugin_card_snapshot(meta) for meta in discover_plugins()] + return { + "plugins": installed, + "total_plugins": len(installed), + } + @app.post("/api/query") async def submit_query(body: dict) -> dict: query = body.get("query", "") diff --git a/meshmind/ui/template/dashboard.html b/meshmind/ui/template/dashboard.html index 4fedd12..05f46d5 100644 --- a/meshmind/ui/template/dashboard.html +++ b/meshmind/ui/template/dashboard.html @@ -114,7 +114,7 @@ .sidebar { flex: 1; display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); min-width: 320px; max-width: 480px; min-height: 0; @@ -138,6 +138,12 @@ display: grid; gap: 8px; } + #pluginList { + max-height: min(250px, 28vh); + overflow-y: auto; + display: grid; + gap: 8px; + } .node-card { padding: 10px; background: #13131d; @@ -145,22 +151,45 @@ border-left: 3px solid #606070; border-radius: 6px; } + .plugin-card { + padding: 10px; + background: #10101a; + border: 1px solid #27273d; + border-left: 3px solid #8b5cf6; + border-radius: 6px; + } .node-card .name { font-size: 13px; color: #e6e6ff; margin-bottom: 4px; } + .plugin-card .name { + font-size: 13px; + color: #f0ecff; + margin-bottom: 4px; + } .node-card .type { font-size: 11px; color: #808090; margin-bottom: 4px; } + .plugin-card .type { + font-size: 11px; + color: #8f8fab; + margin-bottom: 4px; + } .node-card .caps { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } + .plugin-card .caps { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + } .cap-tag { background: #1a1a2a; border: 1px solid #2a2a4a; @@ -169,6 +198,26 @@ font-size: 10px; color: #a0a0b0; } + .prompt-preview { + margin-top: 8px; + padding: 8px 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 10px; + line-height: 1.5; + color: rgba(228, 228, 240, 0.9); + white-space: pre-wrap; + word-break: break-word; + } + .card-link { + color: #c4b5fd; + text-decoration: none; + margin-left: 8px; + } + .card-link:hover { + text-decoration: underline; + } .node-card.medical { border-left: 3px solid #ff4444; } .node-card.logistics { border-left: 3px solid #4488ff; } .node-card.field { border-left: 3px solid #00ff88; } @@ -361,6 +410,10 @@

MESHMIND // MESH DASHBOARD

Connected Nodes

+

Live Event Log

@@ -373,6 +426,7 @@

Live Event Log

const ctx = canvas.getContext("2d"); const nodeList = document.getElementById("nodeList"); const eventLog = document.getElementById("eventLog"); + const pluginList = document.getElementById("pluginList"); const nodeCountEl = document.getElementById("nodeCount"); const queryCountEl = document.getElementById("queryCount"); const nodeHoverCard = document.getElementById("nodeHoverCard"); @@ -392,6 +446,50 @@

Live Event Log

.replace(/"/g, """); } + function renderPills(values, pillClass) { + return values.map((value) => `${escHtml(value)}`).join(""); + } + + function renderNodeCard(n) { + const caps = (n.capabilities || []).filter(Boolean); + const domains = (n.knowledge_domains || []).filter(Boolean); + const tools = (n.tool_names || []).filter(Boolean); + const prompt = n.system_prompt_preview || ""; + const summaryParts = []; + if (tools.length) summaryParts.push(`${tools.length} tool(s)`); + if (typeof n.tool_schema_count === "number") summaryParts.push(`${n.tool_schema_count} schema(s)`); + return ` +
+
${escHtml(n.node_name)}
+
${escHtml((n.node_type || "").toUpperCase())} ${escHtml(n.status || "ready")}
+
${renderPills(caps, "cap-tag")}
+ ${domains.length ? `
${renderPills(domains, "cap-tag")}
` : ""} + ${summaryParts.length ? `
${escHtml(summaryParts.join(" • "))}
` : ""} + ${prompt ? `
${escHtml(prompt)}
` : ""} +
+ `; + } + + function renderPluginCard(plugin) { + const caps = (plugin.capabilities || []).filter(Boolean); + const domains = (plugin.knowledge_domains || []).filter(Boolean); + const tools = (plugin.tool_names || []).filter(Boolean); + const prompt = plugin.system_prompt_preview || plugin.summary || ""; + const source = plugin.source_href && plugin.source_path + ? `${escHtml(plugin.source_path)}` + : ""; + return ` +
+
${escHtml(plugin.name || "plugin")}${source}
+
READY-MADE PROMPT ${plugin.installed ? "installed" : ""}
+ ${caps.length ? `
${renderPills(caps, "cap-tag")}
` : ""} + ${domains.length ? `
${renderPills(domains, "cap-tag")}
` : ""} + ${tools.length ? `
Tools: ${escHtml(tools.join(", "))}
` : ""} + ${prompt ? `
${escHtml(prompt)}
` : ""} +
+ `; + } + function canvasLogicalCoords(clientX, clientY) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / 2 / rect.width; @@ -811,20 +909,27 @@

Live Event Log

updateNodeList(allNodes); }); + fetch("/api/plugins") + .then((r) => r.json()) + .then((data) => { + updatePluginList(data.plugins || []); + }) + .catch(() => { + pluginList.innerHTML = '
Plugin catalog unavailable.
'; + }); + connectWS(); function updateNodeList(allNodes) { - nodeList.innerHTML = allNodes - .map( - (n) => ` -
-
${n.node_name}
-
${n.node_type.toUpperCase()} ${n.status || "ready"}
-
${(n.capabilities || []).map((c) => `${c}`).join("")}
-
- ` - ) - .join(""); + nodeList.innerHTML = allNodes.map(renderNodeCard).join(""); + } + + function updatePluginList(items) { + if (!items.length) { + pluginList.innerHTML = '
No installed plugins found.
'; + return; + } + pluginList.innerHTML = items.map(renderPluginCard).join(""); } function addEvent(type, text) { @@ -843,3 +948,4 @@

Live Event Log

+ From 784cd709c2e8783dd1ea52af530b612899bf0894 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:08:42 +0530 Subject: [PATCH 2/9] feat(examples): add knowledge-backed sample configs --- README.md | 13 ++++++++ docker/configs/coordinator.node.yaml | 4 +++ docker/configs/specialist-research.node.yaml | 5 ++++ docker/configs/specialist-writing.node.yaml | 5 ++++ meshmind/cli/templates/meshmind.yaml.tmpl | 9 +++--- my-project/data/knowledge.json | 8 +++++ my-project/meshmind.yaml | 9 +++--- smoke-local/data/knowledge.json | 8 +++++ smoke-local/meshmind.yaml | 31 ++++++++++++++++++++ 9 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 my-project/data/knowledge.json create mode 100644 smoke-local/data/knowledge.json create mode 100644 smoke-local/meshmind.yaml diff --git a/README.md b/README.md index 6cf9884..7e15db6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This is not distributed inference for a single giant model. MeshMind is built ar - **Parallel fan-out**: Multi-capability queries can be sent to multiple nodes concurrently. - **Coordinator aggregation**: Responses are combined into a single final answer. - **Built-in dashboard**: Monitor mesh topology, routing activity, query traces, and node availability. +- **Dashboard cards**: Hover a node to inspect capabilities, knowledge domains, tools, and system prompt previews without exposing raw knowledge file lists or plugin ids. - **CLI-first workflow**: Bootstrap, validate, run, and query the mesh from the terminal. - **Python SDK**: Create and control meshes programmatically. - **Local-model runtime**: Uses Ollama-backed local models instead of remote APIs. @@ -69,6 +70,8 @@ Paths on the same coordinator UI: - **`/dashboard`** — topology canvas, connected nodes, and live event log - **`/chat`** — full-page mesh query composer (same `POST /api/query` backend) +The dashboard sidebar also shows plugin cards for installed prompt packs, with prompt previews and tool summaries. When a plugin lives inside this repository, its source folder is linked directly from the card. + If **`/chat`** returns `{"detail":"Not Found"}`, the running process is an older MeshMind build: stop the mesh (type `stop` in the `meshmind up` REPL, or Ctrl+C if you used `--no-interactive`), then from the repo root run `pip install -e .` and start again with `meshmind up` so the coordinator loads the current `meshmind.ui.app` routes. ## Benchmarks @@ -224,6 +227,11 @@ nodes: capabilities: - "data_analysis" - "statistics" + knowledge_domains: + - "data" + - "statistics" + knowledge: + - ./data/knowledge.json writer: type: "specialist" @@ -241,6 +249,11 @@ nodes: ui_port: 8080 ``` +Additional committed examples: + +- [my-project/meshmind.yaml](my-project/meshmind.yaml) +- [smoke-local/meshmind.yaml](smoke-local/meshmind.yaml) + ## CLI Reference ```bash diff --git a/docker/configs/coordinator.node.yaml b/docker/configs/coordinator.node.yaml index 85881cc..92c1ed8 100644 --- a/docker/configs/coordinator.node.yaml +++ b/docker/configs/coordinator.node.yaml @@ -6,6 +6,10 @@ node: - coordination - query_routing - response_aggregation + # knowledge_domains: + # - coordination + # knowledge: + # - ./data/knowledge.json ai: model: gemma3:1b diff --git a/docker/configs/specialist-research.node.yaml b/docker/configs/specialist-research.node.yaml index 2ab38b3..e2e9c83 100644 --- a/docker/configs/specialist-research.node.yaml +++ b/docker/configs/specialist-research.node.yaml @@ -5,6 +5,11 @@ node: capabilities: - research - analysis + knowledge_domains: + - research + - analysis + # knowledge: + # - ./data/knowledge.json ai: model: gemma3:1b diff --git a/docker/configs/specialist-writing.node.yaml b/docker/configs/specialist-writing.node.yaml index 9632afd..99b8f23 100644 --- a/docker/configs/specialist-writing.node.yaml +++ b/docker/configs/specialist-writing.node.yaml @@ -5,6 +5,11 @@ node: capabilities: - writing - summarization + knowledge_domains: + - writing + - summarization + # knowledge: + # - ./data/knowledge.json ai: model: gemma3:1b diff --git a/meshmind/cli/templates/meshmind.yaml.tmpl b/meshmind/cli/templates/meshmind.yaml.tmpl index ce5929f..86833cf 100644 --- a/meshmind/cli/templates/meshmind.yaml.tmpl +++ b/meshmind/cli/templates/meshmind.yaml.tmpl @@ -32,10 +32,11 @@ nodes: - "research" - "analysis" - "summarization" - # Knowledge files loaded into context: - # knowledge: - # - ./data/knowledge.json - # - ./data/reference.md + knowledge_domains: + - "research" + - "analysis" + knowledge: + - ./data/knowledge.json # Add more specialist nodes as needed: # writer: diff --git a/my-project/data/knowledge.json b/my-project/data/knowledge.json new file mode 100644 index 0000000..32ef07d --- /dev/null +++ b/my-project/data/knowledge.json @@ -0,0 +1,8 @@ +{ + "title": "my-project sample knowledge", + "knowledge_domains": ["research", "analysis"], + "notes": [ + "This sample demonstrates MeshMind JSON knowledge loading.", + "Replace these notes with local facts, policies, or FAQs." + ] +} \ No newline at end of file diff --git a/my-project/meshmind.yaml b/my-project/meshmind.yaml index 3b2d0b0..34316b8 100644 --- a/my-project/meshmind.yaml +++ b/my-project/meshmind.yaml @@ -32,10 +32,11 @@ nodes: - 'research' - 'analysis' - 'summarization' - # Knowledge files loaded into context: - # knowledge: - # - ./data/knowledge.json - # - ./data/reference.md + knowledge_domains: + - 'research' + - 'analysis' + knowledge: + - ./data/knowledge.json # Add more specialist nodes as needed: # writer: diff --git a/smoke-local/data/knowledge.json b/smoke-local/data/knowledge.json new file mode 100644 index 0000000..9af705f --- /dev/null +++ b/smoke-local/data/knowledge.json @@ -0,0 +1,8 @@ +{ + "title": "smoke-local knowledge sample", + "knowledge_domains": ["smoke-tests", "local-docs"], + "facts": [ + "This file exists so knowledge loading can be exercised end-to-end.", + "It is intentionally small and committed with the example config." + ] +} \ No newline at end of file diff --git a/smoke-local/meshmind.yaml b/smoke-local/meshmind.yaml new file mode 100644 index 0000000..45d1dff --- /dev/null +++ b/smoke-local/meshmind.yaml @@ -0,0 +1,31 @@ +mesh: + name: "smoke-local" + discovery: "manual" + peers: + - host: "127.0.0.1" + port: 8403 + +defaults: + model: "gemma3:1b" + +nodes: + smoke-local-assistant: + type: "specialist" + port: 8401 + system_prompt: | + You are a smoke-test assistant for local validation. + Use the bundled knowledge sample to answer simple checks. + capabilities: + - "smoke-test" + - "analysis" + knowledge_domains: + - "smoke-tests" + - "local-docs" + knowledge: + - ./data/knowledge.json + + smoke-local-coordinator: + type: "coordinator" + port: 8403 + ui: true + ui_port: 8081 \ No newline at end of file From b890c6c0717945f5cc477b287dd280533f3790c1 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:09:41 +0530 Subject: [PATCH 3/9] test: add dashboard, plugin, and knowledge coverage --- tests/test_config.py | 41 +++++++++++++++++++++ tests/test_plugin_init_ux.py | 33 +++++++++++++++++ tests/test_sdk.py | 63 ++++++++++++++++++++++++++++++++ tests/test_ui_dashboard_chat.py | 64 +++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index bd95fc6..33afeed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -104,6 +104,33 @@ """ +YAML_WITH_KNOWLEDGE = """\ +mesh: + name: "knowledge-mesh" + discovery: "mdns" + +defaults: + model: "gemma3:4b" + +nodes: + researcher: + type: "specialist" + port: 8401 + system_prompt: "You are a research assistant." + capabilities: + - "research" + knowledge_domains: + - "research" + - "docs" + knowledge: + - ./data/knowledge.json + + coordinator: + type: "coordinator" + port: 8403 +""" + + class TestMeshConfig: """Test Pydantic schema validation.""" @@ -147,6 +174,20 @@ def test_load_valid_config(self): assert config.nodes["researcher"].type == "specialist" assert config.nodes["coordinator"].ui is True + def test_load_config_with_knowledge(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + data_dir = tmp / "data" + data_dir.mkdir() + (data_dir / "knowledge.json").write_text('{"title": "sample knowledge"}', encoding="utf-8") + cfg_path = tmp / "meshmind.yaml" + cfg_path.write_text(YAML_WITH_KNOWLEDGE, encoding="utf-8") + + config = load_mesh_config(cfg_path) + assert config.nodes["researcher"].knowledge == ["./data/knowledge.json"] + assert config.nodes["researcher"].knowledge_domains == ["research", "docs"] + assert validate_config(cfg_path) == [] + def test_defaults_applied(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(VALID_YAML) diff --git a/tests/test_plugin_init_ux.py b/tests/test_plugin_init_ux.py index 803c77b..467d54d 100644 --- a/tests/test_plugin_init_ux.py +++ b/tests/test_plugin_init_ux.py @@ -131,3 +131,36 @@ def test_apply_plugin_file_not_found_message_includes_sync_hint(tmp_path, monkey msg = str(exc.value) assert "meshmind plugin install missing_plug" in msg assert "meshmind plugin sync" in msg + + +def test_plugin_loader_discovers_prompt_and_tools(tmp_path, monkeypatch) -> None: + plugin_root = tmp_path / "plugins" + plugin_dir = plugin_root / "review-pack" + (plugin_dir / "prompts").mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + """\ +name: Review Pack +capabilities: + - review +functions: + - name: critique + description: Produce a short critique +""", + encoding="utf-8", + ) + (plugin_dir / "prompts" / "system_prompt.txt").write_text( + "You are a focused reviewer.", + encoding="utf-8", + ) + monkeypatch.setenv("MESHMIND_PLUGIN_DIR", str(plugin_root)) + import meshmind.plugins.loader as pl + + importlib.reload(pl) + + assert pl.get_plugin_dir() == plugin_root + discovered = pl.discover_plugins() + assert discovered and discovered[0]["name"] == "Review Pack" + loaded = pl.load_plugin("review-pack") + assert loaded["capabilities"] == ["review"] + assert loaded["functions"][0]["name"] == "critique" + assert "focused reviewer" in loaded["system_prompt"] diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 35d0ef4..b9f878c 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -190,6 +190,69 @@ def test_programmatic_creation(self): assert mesh.name == "my-mesh" assert len(mesh._node_defs) == 2 + @pytest.mark.asyncio + async def test_start_loads_yaml_knowledge_into_prompt(self, monkeypatch): + captured_nodes = [] + + class FakeMeshNode: + def __init__(self, node_info, config=None, model="gemma4", discovery_mode="mdns", manual_peers=None, dev_mode=False): + _ = (model, discovery_mode, manual_peers, dev_mode) + self.info = node_info + self.config = config or {} + captured_nodes.append(self) + + async def start(self): + return None + + async def stop(self): + return None + + monkeypatch.setattr("meshmind.sdk.mesh.MeshNode", FakeMeshNode) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + data_dir = tmp / "data" + data_dir.mkdir() + (data_dir / "knowledge.json").write_text( + '{"title": "Mesh knowledge sample", "facts": ["alpha", "beta"]}', + encoding="utf-8", + ) + cfg_path = tmp / "meshmind.yaml" + cfg_path.write_text( + """\ +mesh: + name: "knowledge-mesh" + discovery: "manual" + +defaults: + model: "gemma3:1b" + +nodes: + worker: + type: "specialist" + port: 8401 + system_prompt: "You are a worker." + knowledge: + - ./data/knowledge.json + + coordinator: + type: "coordinator" + port: 8403 +""", + encoding="utf-8", + ) + + mesh = Mesh.from_yaml(cfg_path) + await mesh.start() + try: + assert captured_nodes + worker = next(node for node in captured_nodes if node.info.node_name == "worker") + assert "Available Knowledge:" in worker.config["system_prompt"] + assert "Mesh knowledge sample" in worker.config["system_prompt"] + assert "alpha" in worker.config["system_prompt"] + finally: + await mesh.stop() + @pytest.mark.asyncio async def test_mesh_start_stop_with_mock(): diff --git a/tests/test_ui_dashboard_chat.py b/tests/test_ui_dashboard_chat.py index df24f4a..5bb232d 100644 --- a/tests/test_ui_dashboard_chat.py +++ b/tests/test_ui_dashboard_chat.py @@ -18,7 +18,9 @@ import time from types import SimpleNamespace + import pytest +import yaml from fastapi.testclient import TestClient from meshmind.core.orchestrator import QueryTrace @@ -48,6 +50,11 @@ def __init__(self) -> None: capabilities=["orchestration"], status=NodeStatus.READY, ) + self.ai_engine = SimpleNamespace( + model="gemma3:1b", + functions=[{"name": "route_query"}], + system_prompt="You coordinate the mesh and route queries.", + ) self.event_log: list = [] self.registry = _MockRegistry() self.orchestrator = _MockOrchestrator() @@ -81,6 +88,38 @@ def dashboard_client() -> TestClient: yield client +@pytest.fixture +def plugin_dashboard_client(tmp_path, monkeypatch) -> TestClient: + plugin_root = tmp_path / "plugins" + plugin_dir = plugin_root / "review-pack" + (plugin_dir / "prompts").mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + yaml.safe_dump( + { + "name": "Review Pack", + "capabilities": ["review", "analysis"], + "functions": [ + { + "name": "critique", + "description": "Produce a short critique", + "parameters": {}, + }, + ], + } + ), + encoding="utf-8", + ) + (plugin_dir / "prompts" / "system_prompt.txt").write_text( + "You are a focused reviewer who gives direct feedback.", + encoding="utf-8", + ) + monkeypatch.setenv("MESHMIND_PLUGIN_DIR", str(plugin_root)) + node = _MockCoordinatorNode() + app = create_app(node) + with TestClient(app) as client: + yield client + + def test_chat_page_includes_mesh_query_ui(dashboard_client: TestClient) -> None: r = dashboard_client.get("/chat") assert r.status_code == 200 @@ -111,6 +150,31 @@ def test_api_mesh_self_includes_extended_fields(dashboard_client: TestClient) -> assert "dev_mode" in data["self"] assert "registry_peer_count" in data["self"] assert "orchestrator_uses_local_llm" in data["self"] + assert "tool_names" in data["self"] + assert data["self"]["tool_names"] == ["route_query"] + assert data["self"]["system_prompt_preview"].startswith("You coordinate") + + +def test_api_plugins_returns_dashboard_cards(plugin_dashboard_client: TestClient) -> None: + r = plugin_dashboard_client.get("/api/plugins") + assert r.status_code == 200 + data = r.json() + assert data["total_plugins"] == 1 + plugin = data["plugins"][0] + assert plugin["name"] == "Review Pack" + assert plugin["capabilities"] == ["review", "analysis"] + assert plugin["tool_names"] == ["critique"] + assert "system_prompt_preview" in plugin + assert "plugin_id" not in plugin + assert "source_path" not in plugin + + +def test_dashboard_plugin_section_renders(plugin_dashboard_client: TestClient) -> None: + r = plugin_dashboard_client.get("/dashboard") + assert r.status_code == 200 + html = r.text + assert "Plugins" in html + assert "pluginList" in html def test_api_query_returns_result_and_trace(dashboard_client: TestClient) -> None: From e607282e62706ddecded24f695d8abdb28715f4b Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:29:03 +0530 Subject: [PATCH 4/9] ci: run workflow on main branches --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ad3431..89c3e44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,11 @@ name: CI on: push: + branches: + - main pull_request: + branches: + - main jobs: test: From 9081bfe0e2eb30bcd72d7138922b8f12faa0c7a1 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:45:30 +0530 Subject: [PATCH 5/9] fix(cli): accept repl history path in run_mesh_repl --- meshmind/cli/repl.py | 12 ++++++++++-- tests/test_cli_session_log.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/meshmind/cli/repl.py b/meshmind/cli/repl.py index f66d1d5..7135354 100644 --- a/meshmind/cli/repl.py +++ b/meshmind/cli/repl.py @@ -18,6 +18,7 @@ import asyncio import logging +from pathlib import Path from typing import TYPE_CHECKING import click @@ -50,8 +51,15 @@ async def _read_line(prompt: str) -> str | None: return None -async def run_mesh_repl(mesh: Mesh, *, base_url: str, stop: asyncio.Event) -> None: - """Block until the user types `stop` / `exit` or EOF; sets ``stop`` when exiting.""" +async def run_mesh_repl( + mesh: Mesh, + *, + base_url: str, + stop: asyncio.Event, + repl_history_path: Path | None = None, +) -> None: + """Block until user exits; keeps a compat arg for callers that pass history path.""" + _ = repl_history_path click.echo() click.echo( f"{LIGHT_PURPLE}Interactive session — mesh logs are not shown here.{RESET} " diff --git a/tests/test_cli_session_log.py b/tests/test_cli_session_log.py index 389c999..8e8f6ad 100644 --- a/tests/test_cli_session_log.py +++ b/tests/test_cli_session_log.py @@ -16,13 +16,38 @@ from __future__ import annotations +import asyncio import logging from pathlib import Path +import pytest + from meshmind.cli.session_log import session_log_file from meshmind.logging_utils import configure_mesh_session_logging +@pytest.mark.asyncio +async def test_run_mesh_repl_accepts_history_path(monkeypatch) -> None: + from meshmind.cli.repl import run_mesh_repl + + class _DummyMesh: + pass + + async def _fake_read_line(prompt: str) -> str | None: + _ = prompt + return None + + monkeypatch.setattr("meshmind.cli.repl._read_line", _fake_read_line) + stop = asyncio.Event() + await run_mesh_repl( + _DummyMesh(), + base_url="http://localhost:9000", + stop=stop, + repl_history_path=Path(".meshmind/repl_history"), + ) + assert stop.is_set() + + def test_session_log_file_path(tmp_path: Path) -> None: cfg = tmp_path / "proj" / "meshmind.yaml" cfg.parent.mkdir(parents=True) From a9f3b6927cc0c708257c99a80268fbeb860c252a Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Fri, 17 Apr 2026 23:45:32 +0530 Subject: [PATCH 6/9] feat(chat): add expandable user messages and streamed query responses --- meshmind/ui/app.py | 116 ++++++++++++++++----- meshmind/ui/template/chat.html | 177 ++++++++++++++++++++++++++++++-- tests/test_ui_dashboard_chat.py | 12 +++ 3 files changed, 269 insertions(+), 36 deletions(-) diff --git a/meshmind/ui/app.py b/meshmind/ui/app.py index 033bf8a..c75446c 100644 --- a/meshmind/ui/app.py +++ b/meshmind/ui/app.py @@ -23,8 +23,9 @@ from pathlib import Path from typing import Any -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse +from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles from meshmind.plugins.loader import discover_plugins @@ -170,6 +171,51 @@ def _mesh_self_snapshot(node: Any) -> dict: return out +def _trace_to_dict(trace: Any, node: Any) -> dict | None: + if not trace: + return None + return { + "correlation_id": trace.correlation_id, + "orchestrator_node": getattr(node.info, "node_name", ""), + "orchestrator_node_id": getattr(node.info, "node_id", ""), + "sub_queries": trace.sub_queries, + "responses": [ + { + "source_node": r["source_node"], + "source_node_id": r["source_node_id"], + "source_address": r.get("source_address", ""), + "source_port": r.get("source_port", 0), + "capability": r["capability"], + } + for r in trace.responses + ], + "unavailable_nodes": trace.unavailable_nodes, + "duration": round(trace.end_time - trace.start_time, 2) if trace.end_time else None, + } + + +def _iter_stream_chunks(text: str, target_size: int = 24) -> list[str]: + if not text: + return [] + words = text.split(" ") + chunks: list[str] = [] + buf = "" + for w in words: + next_buf = (buf + " " + w).strip() if buf else w + if len(next_buf) >= target_size and buf: + chunks.append(buf + " ") + buf = w + else: + buf = next_buf + if buf: + chunks.append(buf) + return chunks + + +def _sse(event: str, payload: dict) -> str: + return f"event: {event}\ndata: {json.dumps(payload, default=str)}\n\n" + + def create_app(node: Any) -> FastAPI: """Create the FastAPI app wired to a MeshNode.""" app = FastAPI(title=f"MeshMind - {node.info.node_name}") @@ -235,36 +281,56 @@ async def submit_query(body: dict) -> dict: result, trace = await node.query(query) - trace_data = None - if trace: - trace_data = { - "correlation_id": trace.correlation_id, - "orchestrator_node": getattr(node.info, "node_name", ""), - "orchestrator_node_id": getattr(node.info, "node_id", ""), - "sub_queries": trace.sub_queries, - "responses": [ - { - "source_node": r["source_node"], - "source_node_id": r["source_node_id"], - "source_address": r.get("source_address", ""), - "source_port": r.get("source_port", 0), - "capability": r["capability"], - } - for r in trace.responses - ], - "unavailable_nodes": trace.unavailable_nodes, - "duration": ( - round(trace.end_time - trace.start_time, 2) - if trace.end_time - else None - ), - } + trace_data = _trace_to_dict(trace, node) await _broadcast_ws( {"type": "query_completed", "trace": trace_data, "timestamp": time.time()} ) return {"result": result, "trace": trace_data} + @app.post("/api/query/stream") + async def submit_query_stream(request: Request) -> StreamingResponse: + body = await request.json() + query = str(body.get("query", "") or "").strip() + + async def event_stream(): + if not query: + yield _sse("error", {"message": "No query provided"}) + yield _sse("done", {"trace": None}) + return + + node._log_event("query_submitted", {"query": query[:100]}) + await _broadcast_ws( + {"type": "query_started", "query": query[:100], "timestamp": time.time()} + ) + yield _sse("accepted", {"query": query[:100]}) + + try: + result, trace = await node.query(query) + except Exception as e: + yield _sse("error", {"message": f"Error: {e}"}) + yield _sse("done", {"trace": None}) + return + + for chunk in _iter_stream_chunks(result): + yield _sse("chunk", {"text": chunk}) + await asyncio.sleep(0) + + trace_data = _trace_to_dict(trace, node) + await _broadcast_ws( + {"type": "query_completed", "trace": trace_data, "timestamp": time.time()} + ) + yield _sse("done", {"trace": trace_data}) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket) -> None: await ws.accept() diff --git a/meshmind/ui/template/chat.html b/meshmind/ui/template/chat.html index cc7b599..c31398c 100644 --- a/meshmind/ui/template/chat.html +++ b/meshmind/ui/template/chat.html @@ -436,6 +436,43 @@ border-left: 3px solid var(--violet); color: #f0e8ff; } + .chat-user-collapsible { + position: relative; + max-width: 100%; + } + .chat-user-collapsible.is-collapsed { + max-height: min(220px, 34vh); + overflow: hidden; + } + .chat-user-collapsible.is-collapsed::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 48px; + pointer-events: none; + background: linear-gradient(180deg, rgba(24, 18, 42, 0) 0%, rgba(24, 18, 42, 0.95) 85%); + } + .chat-user-toggle { + display: inline-flex; + align-items: center; + margin-top: 8px; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.8); + font-family: inherit; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + .chat-user-toggle:hover { + background: rgba(139, 92, 246, 0.16); + border-color: rgba(139, 92, 246, 0.4); + color: #fff; + } .chat-msg.system { background: rgba(99, 102, 241, 0.1); border-left: 3px solid var(--indigo); @@ -1103,14 +1140,66 @@

return formatStructuredMarkdown(String(text)); } + function applyUserMessageCollapsible(div) { + const content = div.querySelector(".chat-msg-text"); + if (!content) return; + + const wrap = document.createElement("div"); + wrap.className = "chat-user-collapsible is-collapsed"; + content.parentNode.replaceChild(wrap, content); + wrap.appendChild(content); + + requestAnimationFrame(() => { + const collapsedHeight = Math.min(220, Math.floor(window.innerHeight * 0.34)); + const needsToggle = wrap.scrollHeight > collapsedHeight + 8; + if (!needsToggle) { + wrap.classList.remove("is-collapsed"); + return; + } + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "chat-user-toggle"; + toggle.setAttribute("aria-expanded", "false"); + toggle.textContent = "Expand"; + toggle.addEventListener("click", () => { + const expanded = !wrap.classList.contains("is-collapsed"); + if (expanded) { + wrap.classList.add("is-collapsed"); + toggle.textContent = "Expand"; + toggle.setAttribute("aria-expanded", "false"); + } else { + wrap.classList.remove("is-collapsed"); + toggle.textContent = "Shrink"; + toggle.setAttribute("aria-expanded", "true"); + } + }); + div.appendChild(toggle); + }); + } + function addMeshChatMessage(text, type = "system", extraHtml = "") { const div = document.createElement("div"); div.className = `chat-msg ${type}`; div.innerHTML = `${formatMessageBody(text)}${extraHtml}`; + if (type === "user") { + applyUserMessageCollapsible(div); + } meshChat.appendChild(div); meshChat.scrollTop = meshChat.scrollHeight; } + function createStreamingSystemMessage() { + const div = document.createElement("div"); + div.className = "chat-msg system"; + const body = document.createElement("div"); + body.className = "chat-msg-text"; + div.appendChild(body); + meshChat.appendChild(div); + meshChat.scrollTop = meshChat.scrollHeight; + return { div, body }; + } + function buildMeshRoutingHtml(trace) { if (!trace) return ""; let html = '
'; @@ -1152,29 +1241,95 @@

meshChat.scrollTop = meshChat.scrollHeight; try { - const res = await fetch("/api/query", { + const res = await fetch("/api/query/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), }); - const data = await res.json(); + if (!res.ok || !res.body) { + throw new Error(`HTTP ${res.status}`); + } + + const { div: streamDiv, body: streamBody } = createStreamingSystemMessage(); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fullText = ""; + let traceData = null; + + const processBlock = (block) => { + if (!block || !block.trim()) return; + const lines = block.split("\n"); + let event = "message"; + const dataLines = []; + for (const line of lines) { + if (line.startsWith("event:")) { + event = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trim()); + } + } + let payload = {}; + try { + payload = JSON.parse(dataLines.join("\n") || "{}"); + } catch { + payload = {}; + } + + if (event === "accepted") { + setMeshChatPending(true); + return; + } + if (event === "chunk") { + const piece = String(payload.text || ""); + if (piece) { + if (meshChatSection.classList.contains("pending")) { + setMeshChatPending(false); + setThinkingVisible(false); + } + fullText += piece; + streamBody.textContent = fullText; + meshChat.scrollTop = meshChat.scrollHeight; + } + return; + } + if (event === "error") { + const msg = String(payload.message || "Unknown error"); + streamDiv.remove(); + addMeshChatMessage(msg, "warning"); + setThinkingVisible(false); + setMeshChatPending(false); + return; + } + if (event === "done") { + traceData = payload.trace || null; + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() || ""; + for (const block of blocks) processBlock(block); + } + if (buffer.trim()) processBlock(buffer); + setThinkingVisible(false); setMeshChatPending(false); - if (data.trace?.unavailable_nodes?.length) { - for (const un of data.trace.unavailable_nodes) { + if (traceData?.unavailable_nodes?.length) { + for (const un of traceData.unavailable_nodes) { const name = un.node_name || un.capability || "node"; addMeshChatMessage(`⚠ ${name} unavailable — ${un.reason || "unknown"}`, "warning"); } } - const routingHtml = buildMeshRoutingHtml(data.trace); - - if (data.error) { - addMeshChatMessage(`Error: ${data.error}`, "warning", routingHtml); - } else { - addMeshChatMessage(data.result || "No response received.", "system", routingHtml); - } + const finalText = fullText || "No response received."; + const routingHtml = buildMeshRoutingHtml(traceData); + streamDiv.innerHTML = `${formatMessageBody(finalText)}${routingHtml}`; + meshChat.scrollTop = meshChat.scrollHeight; } catch (err) { setThinkingVisible(false); setMeshChatPending(false); diff --git a/tests/test_ui_dashboard_chat.py b/tests/test_ui_dashboard_chat.py index 5bb232d..ae16294 100644 --- a/tests/test_ui_dashboard_chat.py +++ b/tests/test_ui_dashboard_chat.py @@ -127,6 +127,8 @@ def test_chat_page_includes_mesh_query_ui(dashboard_client: TestClient) -> None: assert "meshChat" in html assert "sendMeshQuery" in html assert "meshQueryInput" in html + assert "chat-user-collapsible" in html + assert "applyUserMessageCollapsible" in html def test_dashboard_has_no_embedded_chat_panel(dashboard_client: TestClient) -> None: @@ -193,3 +195,13 @@ def test_api_query_empty_body_error(dashboard_client: TestClient) -> None: r = dashboard_client.post("/api/query", json={}) assert r.status_code == 200 assert "error" in r.json() + + +def test_api_query_stream_emits_chunk_and_done(dashboard_client: TestClient) -> None: + r = dashboard_client.post("/api/query/stream", json={"query": "stream hello"}) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + body = r.text + assert "event: accepted" in body + assert "event: chunk" in body + assert "event: done" in body From 6125dea4a269de06ae69ade2ef5585853f754970 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Sat, 18 Apr 2026 14:08:15 +0530 Subject: [PATCH 7/9] feat(stream): add end-to-end query streaming pipeline --- meshmind/ai/engine.py | 59 ++++++++++ meshmind/core/node.py | 24 ++++ meshmind/core/orchestrator.py | 216 +++++++++++++++++++++++++++++++--- meshmind/ui/app.py | 27 ++++- 4 files changed, 306 insertions(+), 20 deletions(-) diff --git a/meshmind/ai/engine.py b/meshmind/ai/engine.py index 0908eb0..67bae0d 100644 --- a/meshmind/ai/engine.py +++ b/meshmind/ai/engine.py @@ -20,6 +20,7 @@ import json import logging from functools import partial +import threading from typing import Any import ollama @@ -75,6 +76,35 @@ async def generate(self, prompt: str, context: str = "") -> str: logger.error("Unexpected AI engine error: %s", e) return f"AI Engine error: {e}" + async def stream_generate(self, prompt: str, context: str = ""): + """Stream response chunks as they are produced by Ollama.""" + messages: list[dict[str, Any]] = [] + + if self.system_prompt: + messages.append({"role": "system", "content": self.system_prompt}) + + if context: + messages.append({"role": "user", "content": f"Context:\n{context}"}) + messages.append({"role": "assistant", "content": "Understood. I have the context."}) + + messages.append({"role": "user", "content": prompt}) + + try: + # Tool-calling streaming is not supported here yet; keep behavior correct. + if self.functions: + full = await self._generate_with_tools(messages) + yield full + return + + async for chunk in self._stream_simple(messages): + yield chunk + except ollama.ResponseError as e: + logger.error("Ollama error: %s", e) + yield f"AI Engine error: {e}" + except Exception as e: + logger.error("Unexpected AI engine error: %s", e) + yield f"AI Engine error: {e}" + async def _generate_simple(self, messages: list[dict]) -> str: """Simple generation without function calling. @@ -85,6 +115,35 @@ async def _generate_simple(self, messages: list[dict]) -> str: ) return response.message.content or "" + async def _stream_simple(self, messages: list[dict]): + """Yield chunks from Ollama's streaming chat API without blocking asyncio.""" + queue: asyncio.Queue[Any] = asyncio.Queue() + done = object() + loop = asyncio.get_running_loop() + + def worker() -> None: + try: + stream = ollama.chat(model=self.model, messages=messages, stream=True) + for chunk in stream: + msg = getattr(chunk, "message", None) + content = getattr(msg, "content", "") if msg is not None else "" + if content: + loop.call_soon_threadsafe(queue.put_nowait, content) + except Exception as e: + loop.call_soon_threadsafe(queue.put_nowait, e) + finally: + loop.call_soon_threadsafe(queue.put_nowait, done) + + threading.Thread(target=worker, daemon=True).start() + + while True: + item = await queue.get() + if item is done: + break + if isinstance(item, Exception): + raise item + yield str(item) + async def _generate_with_tools(self, messages: list[dict]) -> str: """Generation with function calling support.""" tools = [self._function_to_tool(f) for f in self.functions] diff --git a/meshmind/core/node.py b/meshmind/core/node.py index c275e07..124c1ea 100644 --- a/meshmind/core/node.py +++ b/meshmind/core/node.py @@ -486,6 +486,30 @@ async def query(self, query_text: str) -> tuple[str, Any]: return await self.orchestrator.process_query(query_text) + async def query_stream(self, query_text: str): + """Stream query result chunks when possible.""" + if self.info.node_type not in ("field", "coordinator"): + if hasattr(self.ai_engine, "stream_generate"): + parts: list[str] = [] + async for chunk in self.ai_engine.stream_generate(query_text): + parts.append(chunk) + yield {"type": "chunk", "text": chunk} + yield {"type": "done", "result": "".join(parts), "trace": None} + return + result = await self.ai_engine.generate(query_text) + yield {"type": "chunk", "text": result} + yield {"type": "done", "result": result, "trace": None} + return + + if hasattr(self.orchestrator, "process_query_stream"): + async for event in self.orchestrator.process_query_stream(query_text): + yield event + return + + result, trace = await self.orchestrator.process_query(query_text) + yield {"type": "chunk", "text": result} + yield {"type": "done", "result": result, "trace": trace} + async def _heartbeat_loop(self) -> None: """Send periodic heartbeats to all known peers in parallel.""" while self._running: diff --git a/meshmind/core/orchestrator.py b/meshmind/core/orchestrator.py index 25217df..a287fde 100644 --- a/meshmind/core/orchestrator.py +++ b/meshmind/core/orchestrator.py @@ -358,6 +358,143 @@ async def process_query(self, query: str) -> tuple[str, QueryTrace]: return final_response, trace + async def process_query_stream(self, query: str): + """Process query and stream aggregated response chunks as they are generated.""" + trace = QueryTrace( + correlation_id="", + original_query=query, + ) + + self._log_event( + "query_orchestrated", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "query_preview": query[:120], + }, + ) + + trace.routing_started_at = time.time() + routing_plan = await self._classify_and_route(query) + trace.routing_completed_at = time.time() + routing_duration_ms = int((trace.routing_completed_at - trace.routing_started_at) * 1000) + + self._log_event( + "route_plan_created", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "correlation_id": trace.correlation_id, + "route_plan_creation_timestamp": trace.routing_completed_at, + "routing_duration_ms": routing_duration_ms, + "route_count": len(routing_plan), + }, + ) + + for plan_entry in routing_plan: + if plan_entry["node"] is None: + trace.unavailable_nodes.append( + { + "capability": plan_entry["capability"], + "reason": f"No node with capability '{plan_entry['capability']}' in mesh", + } + ) + + if not routing_plan or all(p["node"] is None for p in routing_plan): + trace.status = "no_nodes" + trace.final_response = "No specialized nodes available in the mesh to handle this query." + trace.end_time = time.time() + self.traces.append(trace) + yield {"type": "chunk", "text": trace.final_response} + yield {"type": "done", "result": trace.final_response, "trace": trace} + return + + tasks = [] + for plan_entry in routing_plan: + node = plan_entry["node"] + if node is None: + continue + + sub_q = create_sub_query( + source_node=self.node_id, + target_node=node.node_id, + query=plan_entry["sub_query"], + correlation_id=trace.correlation_id or "pending", + required_capability=plan_entry["capability"], + ) + + if not trace.correlation_id: + trace.correlation_id = sub_q.correlation_id or sub_q.message_id or "" + + trace.sub_queries.append( + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "target_node": node.node_name, + "target_node_id": node.node_id, + "target_address": node.address, + "target_port": node.port, + "capability": plan_entry["capability"], + "query_preview": plan_entry["sub_query"][:120], + "message_id": sub_q.message_id, + "sent_at": time.time(), + "execution_origin": "remote", + } + ) + + tasks.append(self._send_and_collect(node, sub_q, trace)) + + if tasks: + await asyncio.gather(*tasks) + + responded_ids = {r["source_node_id"] for r in trace.responses} + for sq in trace.sub_queries: + if sq["target_node_id"] not in responded_ids: + trace.unavailable_nodes.append( + { + "node_name": sq["target_node"], + "node_id": sq["target_node_id"], + "capability": sq["capability"], + "reason": "Node did not respond after retries", + } + ) + + parts: list[str] = [] + async for chunk in self._aggregate_responses_stream(query, trace): + parts.append(chunk) + yield {"type": "chunk", "text": chunk} + + final_response = "".join(parts) + trace.final_response = final_response + trace.end_time = time.time() + trace.final_response_at = trace.end_time + trace.status = "completed" + + if len(self.traces) >= self._max_traces: + self.traces = self.traces[-50:] + self.traces.append(trace) + + self._log_event( + "query_orchestration_completed", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "correlation_id": trace.correlation_id, + "response_count": len(trace.responses), + "unavailable_count": len(trace.unavailable_nodes), + "duration": trace.end_time - trace.start_time, + "final_response_timestamp": trace.final_response_at, + "routing_duration_ms": routing_duration_ms, + "aggregation_duration_ms": ( + int((trace.aggregation_completed_at - trace.aggregation_started_at) * 1000) + if trace.aggregation_started_at and trace.aggregation_completed_at + else 0 + ), + }, + ) + + yield {"type": "done", "result": final_response, "trace": trace} + async def _send_and_collect( self, node, @@ -452,22 +589,9 @@ async def _aggregate_responses(self, original_query: str, trace: QueryTrace) -> return "No responses received from the mesh." if self.ai_engine: - parts = [] - for resp in trace.responses: - parts.append(f"[{resp['capability']}] from {resp['source_node']}:\n{resp['result']}") - - aggregation_prompt = ( - "You are a coordinator aggregating responses from specialized AI nodes " - "in a local mesh network.\n\n" - f"Original query: {original_query}\n\n" - "Responses from mesh nodes:\n" - + "\n---\n".join(parts) - + "\n\nSynthesize these into a single, clear, actionable response for the " - "field worker. Maintain the structure and detail from each source. " - "Do not lose any critical information." + aggregated = await self.ai_engine.generate( + self._build_aggregation_prompt(original_query, trace) ) - - aggregated = await self.ai_engine.generate(aggregation_prompt) trace.aggregation_completed_at = time.time() self._log_event( "aggregation_completed", @@ -499,6 +623,68 @@ async def _aggregate_responses(self, original_query: str, trace: QueryTrace) -> ) return result + def _build_aggregation_prompt(self, original_query: str, trace: QueryTrace) -> str: + parts = [] + for resp in trace.responses: + parts.append(f"[{resp['capability']}] from {resp['source_node']}:\n{resp['result']}") + return ( + "You are a coordinator aggregating responses from specialized AI nodes " + "in a local mesh network.\n\n" + f"Original query: {original_query}\n\n" + "Responses from mesh nodes:\n" + + "\n---\n".join(parts) + + "\n\nSynthesize these into a single, clear, actionable response for the " + "field worker. Maintain the structure and detail from each source. " + "Do not lose any critical information." + ) + + async def _aggregate_responses_stream(self, original_query: str, trace: QueryTrace): + trace.aggregation_started_at = time.time() + self._log_event( + "aggregation_started", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "correlation_id": trace.correlation_id, + "aggregation_start_timestamp": trace.aggregation_started_at, + }, + ) + + if not trace.responses: + trace.aggregation_completed_at = time.time() + yield "No responses received from the mesh." + self._log_event( + "aggregation_completed", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "correlation_id": trace.correlation_id, + "aggregation_end_timestamp": trace.aggregation_completed_at, + "aggregation_duration_ms": int((trace.aggregation_completed_at - trace.aggregation_started_at) * 1000), + }, + ) + return + + if self.ai_engine and hasattr(self.ai_engine, "stream_generate"): + prompt = self._build_aggregation_prompt(original_query, trace) + async for chunk in self.ai_engine.stream_generate(prompt): + yield chunk + trace.aggregation_completed_at = time.time() + self._log_event( + "aggregation_completed", + { + "orchestrator_node": self.node_name, + "orchestrator_node_id": self.node_id, + "correlation_id": trace.correlation_id, + "aggregation_end_timestamp": trace.aggregation_completed_at, + "aggregation_duration_ms": int((trace.aggregation_completed_at - trace.aggregation_started_at) * 1000), + }, + ) + return + + result = await self._aggregate_responses(original_query, trace) + yield result + def get_recent_traces(self, limit: int = 20) -> list[dict]: """Return recent query traces for dashboard display.""" recent = self.traces[-limit:] diff --git a/meshmind/ui/app.py b/meshmind/ui/app.py index c75446c..07321da 100644 --- a/meshmind/ui/app.py +++ b/meshmind/ui/app.py @@ -306,16 +306,33 @@ async def event_stream(): yield _sse("accepted", {"query": query[:100]}) try: - result, trace = await node.query(query) + if hasattr(node, "query_stream"): + result_text = "" + trace = None + async for event in node.query_stream(query): + e_type = event.get("type", "") + if e_type == "chunk": + piece = str(event.get("text", "") or "") + if piece: + result_text += piece + yield _sse("chunk", {"text": piece}) + elif e_type == "done": + trace = event.get("trace") + if not result_text: + result_text = str(event.get("result", "") or "") + else: + pass + result = result_text + else: + result, trace = await node.query(query) + for chunk in _iter_stream_chunks(result): + yield _sse("chunk", {"text": chunk}) + await asyncio.sleep(0) except Exception as e: yield _sse("error", {"message": f"Error: {e}"}) yield _sse("done", {"trace": None}) return - for chunk in _iter_stream_chunks(result): - yield _sse("chunk", {"text": chunk}) - await asyncio.sleep(0) - trace_data = _trace_to_dict(trace, node) await _broadcast_ws( {"type": "query_completed", "trace": trace_data, "timestamp": time.time()} From f0a6a5b90591932a017f8be27e44df6a72c6548b Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Sat, 18 Apr 2026 14:08:18 +0530 Subject: [PATCH 8/9] feat(chat): show pre-response status and render markdown-rich stream --- meshmind/ui/template/chat.html | 82 ++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/meshmind/ui/template/chat.html b/meshmind/ui/template/chat.html index c31398c..3590c8b 100644 --- a/meshmind/ui/template/chat.html +++ b/meshmind/ui/template/chat.html @@ -399,6 +399,31 @@ border: 1px solid rgba(255, 255, 255, 0.08); color: #e9d5ff; } + .chat-msg-text--rich .chat-msg-code-block { + margin: 0.45em 0 1em; + padding: 12px 14px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + overflow-x: auto; + max-width: min(100%, 78ch); + } + .chat-msg-text--rich .chat-msg-code-block code { + font-family: ui-monospace, "Cascadia Code", "Courier New", monospace; + font-size: 0.9em; + line-height: 1.6; + color: #ede9fe; + white-space: pre; + display: block; + } + .chat-msg-text--rich .chat-msg-quote { + margin: 0.35em 0 0.9em; + padding: 0.45em 0.9em; + border-left: 3px solid rgba(196, 181, 253, 0.75); + background: rgba(255, 255, 255, 0.03); + color: rgba(232, 232, 240, 0.9); + max-width: 72ch; + } .chat-msg-text--rich a { color: #c4b5fd; text-decoration: underline; @@ -1068,6 +1093,23 @@

i++; continue; } + if (/^```/.test(trimmed)) { + i++; + const codeLines = []; + while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) { + codeLines.push(lines[i]); + i++; + } + if (i < lines.length && /^```\s*$/.test(lines[i].trim())) { + i++; + } + chunks.push( + '
' +
+							escapeHtml(codeLines.join("\n")) +
+						"
" + ); + continue; + } if (/^---+$/.test(trimmed) || /^_+$/.test(trimmed)) { chunks.push('
'); i++; @@ -1115,12 +1157,30 @@

chunks.push("
    " + items.join("") + "
"); continue; } + if (/^>\s?/.test(trimmed)) { + const quoteLines = []; + while (i < lines.length) { + const t = lines[i].trim(); + if (t === "") break; + if (!/^>\s?/.test(t)) break; + quoteLines.push(t.replace(/^>\s?/, "")); + i++; + } + chunks.push( + '
' + + formatInlineMarkdown(quoteLines.join("\n")) + + "
" + ); + continue; + } const paraLines = []; while (i < lines.length) { const t = lines[i].trim(); if (t === "") break; if (/^[\-\*•]\s/.test(t)) break; if (/^\d+\.\s/.test(t)) break; + if (/^```/.test(t)) break; + if (/^>\s?/.test(t)) break; if (/^#{2,3}\s/.test(t)) break; if (/^---+$/.test(t) || /^_+$/.test(t)) break; paraLines.push(lines[i]); @@ -1128,7 +1188,9 @@

} if (paraLines.length) { chunks.push( - '

' + formatInlineMarkdown(paraLines.join("\n")) + "

" + '

' + + formatInlineMarkdown(paraLines.join("\n")).replace(/\n/g, "
") + + "

" ); } } @@ -1140,6 +1202,10 @@

return formatStructuredMarkdown(String(text)); } + function renderStreamingMessageBody(targetEl, text) { + targetEl.innerHTML = formatMessageBody(String(text)); + } + function applyUserMessageCollapsible(div) { const content = div.querySelector(".chat-msg-text"); if (!content) return; @@ -1189,11 +1255,14 @@

meshChat.scrollTop = meshChat.scrollHeight; } - function createStreamingSystemMessage() { + function createStreamingSystemMessage(initialText = "") { const div = document.createElement("div"); div.className = "chat-msg system"; const body = document.createElement("div"); body.className = "chat-msg-text"; + if (initialText) { + body.textContent = initialText; + } div.appendChild(body); meshChat.appendChild(div); meshChat.scrollTop = meshChat.scrollHeight; @@ -1250,12 +1319,13 @@

throw new Error(`HTTP ${res.status}`); } - const { div: streamDiv, body: streamBody } = createStreamingSystemMessage(); + const { div: streamDiv, body: streamBody } = createStreamingSystemMessage("Working on your request..."); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let fullText = ""; let traceData = null; + let streamStarted = false; const processBlock = (block) => { if (!block || !block.trim()) return; @@ -1283,12 +1353,16 @@

if (event === "chunk") { const piece = String(payload.text || ""); if (piece) { + if (!streamStarted) { + streamStarted = true; + renderStreamingMessageBody(streamBody, ""); + } if (meshChatSection.classList.contains("pending")) { setMeshChatPending(false); setThinkingVisible(false); } fullText += piece; - streamBody.textContent = fullText; + renderStreamingMessageBody(streamBody, fullText); meshChat.scrollTop = meshChat.scrollHeight; } return; From 68d65fcc56cc2f5e4afe5e54068e1a7a5e8a9110 Mon Sep 17 00:00:00 2001 From: Halleys123 Date: Sat, 18 Apr 2026 14:08:22 +0530 Subject: [PATCH 9/9] docs(readme): refresh usage guide and quickstart details --- README.md | 149 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 92 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 7e15db6..2ce638e 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,56 @@ This is not distributed inference for a single giant model. MeshMind is built ar - **Parallel fan-out**: Multi-capability queries can be sent to multiple nodes concurrently. - **Coordinator aggregation**: Responses are combined into a single final answer. - **Built-in dashboard**: Monitor mesh topology, routing activity, query traces, and node availability. -- **Dashboard cards**: Hover a node to inspect capabilities, knowledge domains, tools, and system prompt previews without exposing raw knowledge file lists or plugin ids. - **CLI-first workflow**: Bootstrap, validate, run, and query the mesh from the terminal. - **Python SDK**: Create and control meshes programmatically. - **Local-model runtime**: Uses Ollama-backed local models instead of remote APIs. - **Knowledge-aware nodes**: Load local knowledge files into specific nodes. - **Manual discovery fallback**: Useful for restrictive firewalls, unusual LANs, or offline field setups. -- **Plugins**: Drop-in packages under `~/.meshmind/plugins/` (or `MESHMIND_PLUGIN_DIR`) merged into specialist nodes via `plugin: ` in `meshmind.yaml`. See [docs/plugins.md](docs/plugins.md) for layout and merge rules. -- **Init presets / wizard**: `meshmind init --preset code` for a multi-specialist template; `meshmind init --wizard` for an interactive flow. + +--- + +## What Makes MeshMind Different + +Other tools split one model across devices to make it run faster. **MeshMind does something different** — each device runs its own specialized AI agent, and they collaborate to solve complex queries that no single agent could answer alone. + +``` +Exo / mesh-llm: "Split one 70B model across three laptops" +MeshMind: "A medical AI, a logistics AI, and a coordinator + work as a team across three laptops" + +DISTRIBUTED INFERENCE VS DISTRIBUTED COLLABORATION +``` + +--- + +## How It Works + +``` +User sends query + ↓ +┌───────────────┐ +│ COORDINATOR │ Decomposes query, classifies capabilities needed +└───────────────┘ + ↓ +┌───────────────┐ ┌───────────────┐ +│ AGENT A │ │ AGENT B │ Each runs its own LLM + knowledge base +│ Medical │ │ Logistics │ Processes in parallel via function calling +└───────────────┘ └───────────────┘ + ↓ ↓ + ┌───────────────┐ + │ AGGREGATION │ Combines responses into unified answer + └───────────────┘ + ↓ +User sees one coherent response +Dashboard shows routing in real-time +``` + +1. Define your AI agents in a `meshmind.yaml` file +2. Run `meshmind up` — agents discover each other automatically via mDNS +3. Send a query — MeshMind decomposes it, routes sub-queries to the right agents, and aggregates their answers +4. Everything runs locally. No internet. No cloud. No data leaves your network + +--- ## Dashboard @@ -52,7 +94,7 @@ Enable the dashboard on a coordinator with: ```yaml coordinator: - type: "coordinator" + type: 'coordinator' port: 8403 ui: true ui_port: 8080 @@ -64,15 +106,12 @@ Then open: http://localhost:8080 ``` - Paths on the same coordinator UI: -- **`/dashboard`** — topology canvas, connected nodes, and live event log -- **`/chat`** — full-page mesh query composer (same `POST /api/query` backend) +- `/dashboard` - topology canvas, connected nodes, and live event log +- `/chat` - full-page mesh query composer (same `POST /api/query` backend) -The dashboard sidebar also shows plugin cards for installed prompt packs, with prompt previews and tool summaries. When a plugin lives inside this repository, its source folder is linked directly from the card. - -If **`/chat`** returns `{"detail":"Not Found"}`, the running process is an older MeshMind build: stop the mesh (type `stop` in the `meshmind up` REPL, or Ctrl+C if you used `--no-interactive`), then from the repo root run `pip install -e .` and start again with `meshmind up` so the coordinator loads the current `meshmind.ui.app` routes. +If `/chat` returns `{"detail":"Not Found"}`, the running process is an older MeshMind build: stop the mesh (Ctrl+C), then from the repo root run `pip install -e .` and start again with `meshmind up` so the coordinator loads the current `meshmind.ui.app` routes. ## Benchmarks @@ -92,6 +131,14 @@ Current benchmarking priorities: - disconnect and rejoin behavior - failure handling under node loss +Run current benchmark suite: + +```bash +meshmind benchmark --suite minimum --runs 5 -o benchmark-report.json +``` + +--- + ## Quick Start Devices running MeshMind can discover each other automatically over local networks. The default generated project also exposes a local dashboard on the configured `ui_port`. @@ -113,7 +160,7 @@ Prerequisites: Clone the repo and bootstrap the environment: ```bash -git clone https://github.com/Nexarion-Distributed-AI/MeshMind.git +git clone https://github.com/MeshMind-Labs/MeshMind.git cd MeshMind # Windows PowerShell @@ -133,7 +180,7 @@ source .venv/bin/activate # .\.venv\Scripts\Activate.ps1 ``` -Run the environment diagnostics: +Run environment diagnostics: ```bash meshmind doctor @@ -148,30 +195,12 @@ meshmind config validate -c meshmind.yaml meshmind up -c meshmind.yaml ``` -**Presets:** `meshmind init --preset default` (same as omitting `--preset`) uses the generic assistant + coordinator template. `meshmind init --preset code` generates a reviewer + security + coordinator mesh and, by default, copies bundled demo plugins `demo_review` and `demo_security` into your plugin directory. Use `--skip-bundled-plugins` to only write YAML. In a normal terminal, bare `meshmind init` (no name, default options) starts the same interactive wizard as `meshmind init --wizard`; the wizard shows a Rich preview and asks for confirmation before creating files. - -**Wizard:** `meshmind init --wizard` interactively chooses a preset and project name (optional NAME as the first argument). - -**Plugins CLI:** `meshmind plugin list`, `meshmind plugin list --available` / `meshmind plugin catalog`, `meshmind plugin install `, `meshmind plugin sync -c meshmind.yaml`, `meshmind plugin validate `, `meshmind plugin remove `. Details: [docs/plugins.md](docs/plugins.md). - -**Two-terminal workflow (Claude Code–style):** the terminal where you run `meshmind up` starts an **interactive REPL** with a **`›`** prompt. Mesh **INFO** logs are written to **`.meshmind/logs/mesh-.log`** next to your YAML (not mixed into that REPL). Open a **second** terminal and tail them: - -```bash -meshmind logs -f -c meshmind.yaml -``` - -**REPL behavior:** use **`/help`** (or `help`) for commands. **`/status`**, **`/urls`**, **`/query …`**, and **`/stop`** (or `stop` / `exit`) work with or without the leading slash. **Enter** submits; **Ctrl+J** inserts a newline for multi-line questions. Answers are rendered with **Rich** (metadata panel + markdown). **Tab** completes commands and configured node names. Input history is stored in **`.meshmind/repl_history`** next to your `meshmind.yaml`. - -Query from either terminal: in the REPL type a line (it is sent as a query) or **`/query …`**; or from another shell: +Query it from another terminal: ```bash meshmind query "Summarize the latest AI trends in 5 bullets" -c meshmind.yaml -t 300 ``` -**CLI output format:** `meshmind query` defaults to **`--format auto`** — **markdown** when stdout is a TTY (styled panel + markdown body), **plain text** when not (e.g. pipes/CI). Use **`--format json`** for a stable JSON object (`result`, `nodes_used`, `duration`, `unavailable_nodes`, `trace`). - -For a **single** terminal with the old log stream on stdout (e.g. CI), use `meshmind up -c meshmind.yaml --log-to-stdout`. To skip the REPL and wait until Ctrl+C only: `--no-interactive`. - ### Python SDK MeshMind also provides a Python SDK for programmatic mesh creation: @@ -208,67 +237,59 @@ await mesh.stop() ## Example Configuration -`meshmind.yaml` defines the mesh, default model settings, and the participating nodes: +`meshmind.yaml` defines the mesh, default model settings, and participating nodes: ```yaml mesh: - name: "my-team" - discovery: "mdns" + name: 'my-team' + discovery: 'mdns' defaults: - model: "gemma3:1b" + model: 'gemma3:1b' nodes: analyst: - type: "specialist" + type: 'specialist' port: 8401 system_prompt: | You are a data analyst. Analyze data and provide insights. capabilities: - - "data_analysis" - - "statistics" - knowledge_domains: - - "data" - - "statistics" - knowledge: - - ./data/knowledge.json + - 'data_analysis' + - 'statistics' writer: - type: "specialist" + type: 'specialist' port: 8402 system_prompt: | You are a technical writer. Write clear documentation. capabilities: - - "writing" - - "documentation" + - 'writing' + - 'documentation' coordinator: - type: "coordinator" + type: 'coordinator' port: 8403 ui: true ui_port: 8080 ``` -Additional committed examples: - -- [my-project/meshmind.yaml](my-project/meshmind.yaml) -- [smoke-local/meshmind.yaml](smoke-local/meshmind.yaml) - ## CLI Reference ```bash meshmind init -meshmind up [-c meshmind.yaml] [--no-interactive] [--log-to-stdout] [--log-file PATH] -meshmind logs [-c meshmind.yaml] [-f] [-n 200] +meshmind up meshmind down meshmind stats meshmind nodes meshmind query "text" +meshmind benchmark meshmind doctor meshmind config validate meshmind config show ``` +--- + ## Architecture ```text @@ -284,6 +305,8 @@ meshmind/ |- ui/ FastAPI dashboard ``` +--- + ## Current Status MeshMind is currently an early open source release focused on: @@ -302,6 +325,8 @@ Current focus areas: - offline/manual-peer workflows - mobile-node interoperability +--- + ## Current Limitations - Large models may exceed available RAM and create unstable first-run behavior. @@ -310,21 +335,25 @@ Current focus areas: - Multi-device routing is implemented, but guaranteed speedup has not yet been comprehensively benchmarked. - Mobile-node support is still experimental and not yet a stable documented flow. +--- + ## Guides - [2-device LAN guide](docs/lan-two-device.md) +- [Testing and benchmarking guide](docs/tests.md) - [Known limitations](docs/known-limitations.md) -- [Vision](VISION.md) + +--- ## Testing -Run the automated test suite from the repository root: +Run the automated test suite from repository root: ```bash python -m pytest tests -q ``` -The current test suite covers: +The current suite covers: - protocol serialization - routing behavior @@ -333,6 +362,8 @@ The current test suite covers: - CLI helper logic - SDK lifecycle behavior +--- + ## Use Cases - private local developer assistants @@ -341,6 +372,8 @@ The current test suite covers: - offline or degraded-network operations - edge and field-device intelligence coordination +--- + ## Contributing See: @@ -355,6 +388,8 @@ python -m pip install -e . python -m pytest tests -q ``` +--- + ## License MeshMind is licensed under the [Apache License 2.0](LICENSE).