koshi 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
koshi-0.4.0/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
koshi-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: koshi
3
+ Version: 0.4.0
4
+ Summary: Python client for the Koshi MCP server — retrieval, memory, context, and quality tools for any LLM workflow. Auto-downloads a native AOT binary; no .NET install required.
5
+ Project-URL: Homepage, https://github.com/jsharma1105/Koshi
6
+ Project-URL: Documentation, https://github.com/jsharma1105/Koshi#readme
7
+ Project-URL: Repository, https://github.com/jsharma1105/Koshi
8
+ Project-URL: Issues, https://github.com/jsharma1105/Koshi/issues
9
+ Project-URL: Changelog, https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md
10
+ Author: Koshi Contributors
11
+ License: MIT
12
+ Keywords: agent,ai,bm25,claude,context-engineering,copilot,cursor,llm,mcp,memory,model-context-protocol,rag,retrieval
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.2; extra == 'dev'
30
+ Requires-Dist: mypy>=1.11; extra == 'dev'
31
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.6; extra == 'dev'
34
+ Requires-Dist: twine>=5.1; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # koshi
38
+
39
+ [![PyPI version](https://img.shields.io/pypi/v/koshi.svg)](https://pypi.org/project/koshi/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/koshi.svg)](https://pypi.org/project/koshi/)
41
+ [![License](https://img.shields.io/pypi/l/koshi.svg)](https://github.com/jsharma1105/Koshi/blob/main/LICENSE)
42
+
43
+ **Python client for the [Koshi MCP server](https://github.com/jsharma1105/Koshi).** Retrieval, memory, context engineering, and quality scoring for any LLM workflow — 20 tools, all wrapped as Pythonic methods.
44
+
45
+ > No `.NET install` required. The first call auto-downloads a small native AOT binary (~15 MB) into your user cache.
46
+
47
+ ---
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install koshi
53
+ ```
54
+
55
+ Requires Python 3.10+ on Linux x64/arm64, macOS x64/arm64, or Windows x64/arm64. Zero runtime dependencies — every byte is Python stdlib.
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ from koshi import Client
61
+
62
+ with Client() as koshi:
63
+ print(koshi.version()) # diagnostics
64
+ koshi.index_directory("/path/to/your/repo") # BM25 index of all text files
65
+ print(koshi.search("auth pattern", top_k=5)) # ranked retrieval
66
+ koshi.remember(content="JWT tokens expire after 1h",
67
+ subject="auth", type="Decision")
68
+ print(koshi.recall("auth")) # memory recall with recency + confidence
69
+ ```
70
+
71
+ The first `Client()` call:
72
+
73
+ 1. Detects your OS/CPU.
74
+ 2. Looks for the matching `koshi-mcp` binary in `KOSHI_BIN` → versioned cache → `PATH` → GitHub release.
75
+ 3. Downloads, verifies SHA-256 (hashes are baked into the wheel — supply-chain safe), caches under your user cache dir.
76
+ 4. Spawns it as a subprocess, performs the MCP `initialize` handshake.
77
+ 5. Confirms the server's reported version matches the Python package version exactly. Mismatch raises `IncompatibleBinaryError`.
78
+
79
+ ## The 20 tools
80
+
81
+ | Group | Methods |
82
+ |---|---|
83
+ | **Retrieval** | `index_directory`, `index`, `search`, `list_indexed`, `clear_index` |
84
+ | **Memory** | `remember`, `recall`, `forget`, `memory_stats`, `clear_memories` |
85
+ | **Context** | `compile_context`, `token_count`, `budget_plan` |
86
+ | **Team / Quality** | `register_team`, `score_turn`, `team_dashboard`, `analyze_feedback`, `list_teams` |
87
+ | **Diagnostics** | `version`, `health` |
88
+
89
+ Every method returns the formatted text response from the MCP server. The full Koshi tool reference lives in the [main README](https://github.com/jsharma1105/Koshi#mcp-tools).
90
+
91
+ ## Environment variables
92
+
93
+ | Var | Effect |
94
+ |---|---|
95
+ | `KOSHI_BIN` | Path to a `koshi-mcp` binary. Skips auto-download, overrides PATH lookup. Version still verified at spawn time. |
96
+ | `KOSHI_MEMORY_FILE` | Persist memories to this JSON file across server restarts. Default: in-memory only. |
97
+ | `KOSHI_INDEX_PATH` | Default directory for `search` / `index_directory` if you don't pass one. |
98
+
99
+ ## Where binaries live
100
+
101
+ | OS | Cache path |
102
+ |---|---|
103
+ | Linux | `$XDG_CACHE_HOME/koshi/bin/<version>/` or `~/.cache/koshi/bin/<version>/` |
104
+ | macOS | `~/Library/Caches/koshi/bin/<version>/` |
105
+ | Windows | `%LOCALAPPDATA%\koshi\bin\<version>\` |
106
+
107
+ The version is part of the path, so upgrading `pip install -U koshi` does not invalidate the cache for older Python projects pinned to an older `koshi` version.
108
+
109
+ ## Air-gapped / offline
110
+
111
+ Download the binary that matches your platform from a GitHub release of [Koshi](https://github.com/jsharma1105/Koshi/releases) on a machine with internet, copy it onto the target machine, and point `KOSHI_BIN` at it. The version must match the installed `koshi` Python package exactly.
112
+
113
+ ## Error handling
114
+
115
+ ```python
116
+ from koshi import (
117
+ Client,
118
+ KoshiError, # base class
119
+ BinaryNotFoundError, # nothing on disk + download failed
120
+ IncompatibleBinaryError, # version mismatch
121
+ BinaryCorruptedError, # SHA-256 mismatch
122
+ UnsupportedPlatformError, # no AOT artifact for this OS/CPU
123
+ ProtocolError, # malformed MCP message
124
+ ToolError, # tool returned an error response
125
+ KoshiTimeoutError, # request took longer than client.timeout
126
+ )
127
+ ```
128
+
129
+ ## Why this exists
130
+
131
+ Koshi's engine is .NET. That's a deliberate choice — the runtime gets us best-in-class JSON, threading, and a great MCP SDK. But it's also adoption friction: most MCP users live in Python.
132
+
133
+ `koshi` (Python) closes the gap. You `pip install koshi` and never think about .NET. Under the hood, you're talking to the same engine the .NET ecosystem uses (`Koshi.Mcp` on NuGet), wire-compatible byte-for-byte.
134
+
135
+ ## Links
136
+
137
+ - **Source & issues**: <https://github.com/jsharma1105/Koshi>
138
+ - **Release binaries**: <https://github.com/jsharma1105/Koshi/releases>
139
+ - **NuGet (.NET tool)**: <https://www.nuget.org/packages/Koshi.Mcp>
140
+ - **Changelog**: <https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md>
141
+
142
+ ## License
143
+
144
+ MIT — see [LICENSE](https://github.com/jsharma1105/Koshi/blob/main/LICENSE).
koshi-0.4.0/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # koshi
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/koshi.svg)](https://pypi.org/project/koshi/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/koshi.svg)](https://pypi.org/project/koshi/)
5
+ [![License](https://img.shields.io/pypi/l/koshi.svg)](https://github.com/jsharma1105/Koshi/blob/main/LICENSE)
6
+
7
+ **Python client for the [Koshi MCP server](https://github.com/jsharma1105/Koshi).** Retrieval, memory, context engineering, and quality scoring for any LLM workflow — 20 tools, all wrapped as Pythonic methods.
8
+
9
+ > No `.NET install` required. The first call auto-downloads a small native AOT binary (~15 MB) into your user cache.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install koshi
17
+ ```
18
+
19
+ Requires Python 3.10+ on Linux x64/arm64, macOS x64/arm64, or Windows x64/arm64. Zero runtime dependencies — every byte is Python stdlib.
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ from koshi import Client
25
+
26
+ with Client() as koshi:
27
+ print(koshi.version()) # diagnostics
28
+ koshi.index_directory("/path/to/your/repo") # BM25 index of all text files
29
+ print(koshi.search("auth pattern", top_k=5)) # ranked retrieval
30
+ koshi.remember(content="JWT tokens expire after 1h",
31
+ subject="auth", type="Decision")
32
+ print(koshi.recall("auth")) # memory recall with recency + confidence
33
+ ```
34
+
35
+ The first `Client()` call:
36
+
37
+ 1. Detects your OS/CPU.
38
+ 2. Looks for the matching `koshi-mcp` binary in `KOSHI_BIN` → versioned cache → `PATH` → GitHub release.
39
+ 3. Downloads, verifies SHA-256 (hashes are baked into the wheel — supply-chain safe), caches under your user cache dir.
40
+ 4. Spawns it as a subprocess, performs the MCP `initialize` handshake.
41
+ 5. Confirms the server's reported version matches the Python package version exactly. Mismatch raises `IncompatibleBinaryError`.
42
+
43
+ ## The 20 tools
44
+
45
+ | Group | Methods |
46
+ |---|---|
47
+ | **Retrieval** | `index_directory`, `index`, `search`, `list_indexed`, `clear_index` |
48
+ | **Memory** | `remember`, `recall`, `forget`, `memory_stats`, `clear_memories` |
49
+ | **Context** | `compile_context`, `token_count`, `budget_plan` |
50
+ | **Team / Quality** | `register_team`, `score_turn`, `team_dashboard`, `analyze_feedback`, `list_teams` |
51
+ | **Diagnostics** | `version`, `health` |
52
+
53
+ Every method returns the formatted text response from the MCP server. The full Koshi tool reference lives in the [main README](https://github.com/jsharma1105/Koshi#mcp-tools).
54
+
55
+ ## Environment variables
56
+
57
+ | Var | Effect |
58
+ |---|---|
59
+ | `KOSHI_BIN` | Path to a `koshi-mcp` binary. Skips auto-download, overrides PATH lookup. Version still verified at spawn time. |
60
+ | `KOSHI_MEMORY_FILE` | Persist memories to this JSON file across server restarts. Default: in-memory only. |
61
+ | `KOSHI_INDEX_PATH` | Default directory for `search` / `index_directory` if you don't pass one. |
62
+
63
+ ## Where binaries live
64
+
65
+ | OS | Cache path |
66
+ |---|---|
67
+ | Linux | `$XDG_CACHE_HOME/koshi/bin/<version>/` or `~/.cache/koshi/bin/<version>/` |
68
+ | macOS | `~/Library/Caches/koshi/bin/<version>/` |
69
+ | Windows | `%LOCALAPPDATA%\koshi\bin\<version>\` |
70
+
71
+ The version is part of the path, so upgrading `pip install -U koshi` does not invalidate the cache for older Python projects pinned to an older `koshi` version.
72
+
73
+ ## Air-gapped / offline
74
+
75
+ Download the binary that matches your platform from a GitHub release of [Koshi](https://github.com/jsharma1105/Koshi/releases) on a machine with internet, copy it onto the target machine, and point `KOSHI_BIN` at it. The version must match the installed `koshi` Python package exactly.
76
+
77
+ ## Error handling
78
+
79
+ ```python
80
+ from koshi import (
81
+ Client,
82
+ KoshiError, # base class
83
+ BinaryNotFoundError, # nothing on disk + download failed
84
+ IncompatibleBinaryError, # version mismatch
85
+ BinaryCorruptedError, # SHA-256 mismatch
86
+ UnsupportedPlatformError, # no AOT artifact for this OS/CPU
87
+ ProtocolError, # malformed MCP message
88
+ ToolError, # tool returned an error response
89
+ KoshiTimeoutError, # request took longer than client.timeout
90
+ )
91
+ ```
92
+
93
+ ## Why this exists
94
+
95
+ Koshi's engine is .NET. That's a deliberate choice — the runtime gets us best-in-class JSON, threading, and a great MCP SDK. But it's also adoption friction: most MCP users live in Python.
96
+
97
+ `koshi` (Python) closes the gap. You `pip install koshi` and never think about .NET. Under the hood, you're talking to the same engine the .NET ecosystem uses (`Koshi.Mcp` on NuGet), wire-compatible byte-for-byte.
98
+
99
+ ## Links
100
+
101
+ - **Source & issues**: <https://github.com/jsharma1105/Koshi>
102
+ - **Release binaries**: <https://github.com/jsharma1105/Koshi/releases>
103
+ - **NuGet (.NET tool)**: <https://www.nuget.org/packages/Koshi.Mcp>
104
+ - **Changelog**: <https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md>
105
+
106
+ ## License
107
+
108
+ MIT — see [LICENSE](https://github.com/jsharma1105/Koshi/blob/main/LICENSE).
@@ -0,0 +1,108 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "koshi"
7
+ version = "0.4.0"
8
+ description = "Python client for the Koshi MCP server — retrieval, memory, context, and quality tools for any LLM workflow. Auto-downloads a native AOT binary; no .NET install required."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Koshi Contributors" },
14
+ ]
15
+ keywords = [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "ai",
19
+ "llm",
20
+ "rag",
21
+ "retrieval",
22
+ "bm25",
23
+ "context-engineering",
24
+ "memory",
25
+ "agent",
26
+ "claude",
27
+ "copilot",
28
+ "cursor",
29
+ ]
30
+ classifiers = [
31
+ "Development Status :: 4 - Beta",
32
+ "Intended Audience :: Developers",
33
+ "License :: OSI Approved :: MIT License",
34
+ "Operating System :: POSIX :: Linux",
35
+ "Operating System :: MacOS :: MacOS X",
36
+ "Operating System :: Microsoft :: Windows",
37
+ "Programming Language :: Python :: 3",
38
+ "Programming Language :: Python :: 3 :: Only",
39
+ "Programming Language :: Python :: 3.10",
40
+ "Programming Language :: Python :: 3.11",
41
+ "Programming Language :: Python :: 3.12",
42
+ "Programming Language :: Python :: 3.13",
43
+ "Topic :: Software Development :: Libraries :: Python Modules",
44
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
45
+ ]
46
+ # Intentionally zero runtime dependencies — every byte is stdlib.
47
+ dependencies = []
48
+
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "pytest>=8.0",
52
+ "pytest-timeout>=2.3",
53
+ "ruff>=0.6",
54
+ "mypy>=1.11",
55
+ "build>=1.2",
56
+ "twine>=5.1",
57
+ ]
58
+
59
+ [project.urls]
60
+ Homepage = "https://github.com/jsharma1105/Koshi"
61
+ Documentation = "https://github.com/jsharma1105/Koshi#readme"
62
+ Repository = "https://github.com/jsharma1105/Koshi"
63
+ Issues = "https://github.com/jsharma1105/Koshi/issues"
64
+ Changelog = "https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md"
65
+
66
+ [tool.hatch.build.targets.wheel]
67
+ packages = ["src/koshi"]
68
+
69
+ [tool.hatch.build.targets.sdist]
70
+ include = [
71
+ "src/koshi/**/*.py",
72
+ "src/koshi/py.typed",
73
+ "README.md",
74
+ "pyproject.toml",
75
+ ]
76
+
77
+ [tool.ruff]
78
+ line-length = 100
79
+ target-version = "py310"
80
+ src = ["src", "tests"]
81
+
82
+ [tool.ruff.lint]
83
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
84
+ ignore = [
85
+ "E501", # handled by formatter
86
+ ]
87
+
88
+ [tool.mypy]
89
+ python_version = "3.10"
90
+ strict = true
91
+ files = ["src/koshi"]
92
+ warn_unused_configs = true
93
+ warn_redundant_casts = true
94
+ warn_unused_ignores = true
95
+ no_implicit_reexport = true
96
+
97
+ [tool.pytest.ini_options]
98
+ minversion = "8.0"
99
+ testpaths = ["tests"]
100
+ addopts = [
101
+ "-ra",
102
+ "--strict-markers",
103
+ "--strict-config",
104
+ ]
105
+ markers = [
106
+ "slow: tests that spawn a real koshi-mcp subprocess (deselect with -m 'not slow')",
107
+ ]
108
+ timeout = 60
@@ -0,0 +1,45 @@
1
+ """
2
+ koshi — Python client for the Koshi MCP server.
3
+
4
+ Quickstart::
5
+
6
+ from koshi import Client
7
+
8
+ with Client() as koshi:
9
+ print(koshi.version())
10
+ koshi.index_directory("/path/to/repo")
11
+ print(koshi.search("auth pattern", top_k=5))
12
+
13
+ The first call auto-downloads the matching native AOT binary (~15 MB)
14
+ into your user cache. No .NET install required. Set ``KOSHI_BIN`` to
15
+ override the resolver with an explicit path (e.g. an air-gapped
16
+ deployment, or a build of your own).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ._manifest import VERSION as __version__
22
+ from .client import Client
23
+ from .errors import (
24
+ BinaryCorruptedError,
25
+ BinaryNotFoundError,
26
+ IncompatibleBinaryError,
27
+ KoshiError,
28
+ KoshiTimeoutError,
29
+ ProtocolError,
30
+ ToolError,
31
+ UnsupportedPlatformError,
32
+ )
33
+
34
+ __all__ = [
35
+ "BinaryCorruptedError",
36
+ "BinaryNotFoundError",
37
+ "Client",
38
+ "IncompatibleBinaryError",
39
+ "KoshiError",
40
+ "KoshiTimeoutError",
41
+ "ProtocolError",
42
+ "ToolError",
43
+ "UnsupportedPlatformError",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,38 @@
1
+ """
2
+ AUTO-GENERATED at release time by scripts/inject-manifest.py.
3
+ DO NOT EDIT BY HAND. Re-run from the release workflow if it needs to change.
4
+
5
+ Binds this Python wheel to the exact AOT binaries published in the
6
+ matching GitHub release. The binary auto-downloader verifies every
7
+ fetched binary against EXPECTED_BINARIES; a tampered binary on the
8
+ GitHub release alone is not enough to compromise users.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ VERSION: str = "0.4.0"
14
+
15
+ RELEASE_URL_TEMPLATE: str = "https://github.com/jsharma1105/Koshi/releases/download/v{version}/{filename}"
16
+
17
+ EXPECTED_BINARIES: dict[str, dict[str, str]] = {
18
+ "linux-arm64": {
19
+ "filename": "koshi-mcp-linux-arm64",
20
+ "sha256": "9534bb3bf856d84c7b62d05dc04a9749b43c92f24e5b474f12cd5e7276ffd347",
21
+ },
22
+ "linux-x64": {
23
+ "filename": "koshi-mcp-linux-x64",
24
+ "sha256": "50580dd397bcca12b97a2385cc75babb32dc426015b03fbee1d3eb3ff4baac97",
25
+ },
26
+ "osx-arm64": {
27
+ "filename": "koshi-mcp-osx-arm64",
28
+ "sha256": "2bc707ffee8f1a42208da68fcbe2814ce849dc7594b57915ef524d362e1c1768",
29
+ },
30
+ "win-arm64": {
31
+ "filename": "koshi-mcp-win-arm64.exe",
32
+ "sha256": "0b1b8e8469803113ef0cc74e5a87219a51f8977097fea1fb4e9f38d7e4c3546c",
33
+ },
34
+ "win-x64": {
35
+ "filename": "koshi-mcp-win-x64.exe",
36
+ "sha256": "9c9645c1d635aa5be27837d88644b83411169acf91c68bd292e49f5b624f061c",
37
+ },
38
+ }
@@ -0,0 +1,251 @@
1
+ """
2
+ Resolve and (if needed) download the koshi-mcp AOT binary.
3
+
4
+ Resolution order:
5
+ 1. ``KOSHI_BIN`` env var (explicit override; version is verified after spawn).
6
+ 2. Versioned cache: ``<cache>/koshi/bin/<VERSION>/koshi-mcp-<rid>[.exe]``.
7
+ The path itself is version-pinned, so a hit cannot be stale.
8
+ 3. ``shutil.which("koshi-mcp")`` (e.g. installed via ``dotnet tool install``);
9
+ version is verified after spawn.
10
+ 4. Download from the matching GitHub release with strict SHA-256
11
+ verification against the hashes embedded in ``_manifest.py``.
12
+
13
+ Atomicity: downloads write to a temp file, are hash-verified, then renamed
14
+ with ``os.replace`` (atomic on every supported OS).
15
+
16
+ Concurrency: a per-version file lock protects the cache so two processes
17
+ constructing ``Client()`` at the same time cannot both fetch the binary.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import hashlib
24
+ import os
25
+ import platform
26
+ import shutil
27
+ import sys
28
+ import time
29
+ from collections.abc import Iterator
30
+ from contextlib import contextmanager
31
+ from pathlib import Path
32
+ from urllib.error import HTTPError, URLError
33
+ from urllib.request import Request, urlopen
34
+
35
+ from ._manifest import EXPECTED_BINARIES, RELEASE_URL_TEMPLATE, VERSION
36
+ from .errors import (
37
+ BinaryCorruptedError,
38
+ BinaryNotFoundError,
39
+ UnsupportedPlatformError,
40
+ )
41
+
42
+ _RID_MAP: dict[tuple[str, str], str] = {
43
+ ("Linux", "x86_64"): "linux-x64",
44
+ ("Linux", "aarch64"): "linux-arm64",
45
+ ("Linux", "arm64"): "linux-arm64",
46
+ # ("Darwin", "x86_64"): "osx-x64", # Intel Mac not published as of v0.4.0
47
+ ("Darwin", "arm64"): "osx-arm64",
48
+ ("Windows", "AMD64"): "win-x64",
49
+ ("Windows", "x86_64"): "win-x64",
50
+ ("Windows", "ARM64"): "win-arm64",
51
+ }
52
+
53
+ _DOWNLOAD_TIMEOUT_S = 60
54
+ _DOWNLOAD_CHUNK = 1 << 16 # 64 KiB
55
+
56
+
57
+ def detect_rid() -> str:
58
+ """Return the .NET RID for the current OS/CPU, raising on unsupported combos."""
59
+ system = platform.system()
60
+ machine = platform.machine()
61
+ rid = _RID_MAP.get((system, machine))
62
+ if rid is None:
63
+ hint = ""
64
+ if system == "Darwin" and machine == "x86_64":
65
+ hint = (
66
+ " (Intel Macs are not in the AOT release matrix as of v0.4.0; "
67
+ "Apple stopped shipping Intel Macs in 2023.)"
68
+ )
69
+ raise UnsupportedPlatformError(
70
+ f"No published Koshi AOT artifact for {system}/{machine}.{hint} "
71
+ f"Install .NET 10 and `dotnet tool install --global Koshi.Mcp` instead, "
72
+ f"then set KOSHI_BIN to the resulting koshi-mcp executable."
73
+ )
74
+ return rid
75
+
76
+
77
+ def _cache_root() -> Path:
78
+ """Return the OS-appropriate cache root (stdlib-only — no platformdirs)."""
79
+ if sys.platform == "win32":
80
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
81
+ elif sys.platform == "darwin":
82
+ base = str(Path.home() / "Library" / "Caches")
83
+ else:
84
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
85
+ return Path(base) / "koshi"
86
+
87
+
88
+ def versioned_cache_dir() -> Path:
89
+ return _cache_root() / "bin" / VERSION
90
+
91
+
92
+ def _binary_filename(rid: str) -> str:
93
+ info = EXPECTED_BINARIES.get(rid)
94
+ if info is not None:
95
+ return info["filename"]
96
+ return f"koshi-mcp-{rid}{'.exe' if rid.startswith('win-') else ''}"
97
+
98
+
99
+ def _sha256_file(path: Path) -> str:
100
+ h = hashlib.sha256()
101
+ with path.open("rb") as f:
102
+ for chunk in iter(lambda: f.read(_DOWNLOAD_CHUNK), b""):
103
+ h.update(chunk)
104
+ return h.hexdigest()
105
+
106
+
107
+ @contextmanager
108
+ def _file_lock(lock_path: Path) -> Iterator[None]:
109
+ """Best-effort cross-platform exclusive lock on ``lock_path``."""
110
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
111
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
112
+ try:
113
+ if sys.platform == "win32":
114
+ import msvcrt
115
+
116
+ for _ in range(600): # ≤ 60 s
117
+ try:
118
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
119
+ break
120
+ except OSError:
121
+ time.sleep(0.1)
122
+ else:
123
+ raise BinaryNotFoundError(
124
+ f"Timed out acquiring cache lock {lock_path}"
125
+ )
126
+ else:
127
+ import fcntl
128
+
129
+ fcntl.flock(fd, fcntl.LOCK_EX)
130
+ yield
131
+ finally:
132
+ try:
133
+ if sys.platform == "win32":
134
+ import msvcrt
135
+
136
+ with contextlib.suppress(OSError):
137
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
138
+ else:
139
+ import fcntl
140
+
141
+ fcntl.flock(fd, fcntl.LOCK_UN)
142
+ finally:
143
+ os.close(fd)
144
+
145
+
146
+ def _atomic_download(url: str, dest: Path, expected_sha256: str | None) -> None:
147
+ """Download ``url`` to ``dest`` atomically. Raise on hash mismatch."""
148
+ dest.parent.mkdir(parents=True, exist_ok=True)
149
+ tmp = dest.parent / f".{dest.name}.{os.getpid()}.{int(time.time_ns())}.tmp"
150
+
151
+ headers = {"User-Agent": f"koshi-py/{VERSION}", "Accept": "application/octet-stream"}
152
+ request = Request(url, headers=headers)
153
+ try:
154
+ with urlopen(request, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
155
+ if resp.status != 200:
156
+ raise BinaryNotFoundError(
157
+ f"Failed to download {url}: HTTP {resp.status}"
158
+ )
159
+ with tmp.open("wb") as f:
160
+ while True:
161
+ chunk = resp.read(_DOWNLOAD_CHUNK)
162
+ if not chunk:
163
+ break
164
+ f.write(chunk)
165
+ except HTTPError as e:
166
+ tmp.unlink(missing_ok=True)
167
+ raise BinaryNotFoundError(f"HTTP {e.code} fetching {url}: {e.reason}") from e
168
+ except URLError as e:
169
+ tmp.unlink(missing_ok=True)
170
+ raise BinaryNotFoundError(f"Network error fetching {url}: {e.reason}") from e
171
+
172
+ try:
173
+ if expected_sha256 is not None:
174
+ actual = _sha256_file(tmp)
175
+ if actual.lower() != expected_sha256.lower():
176
+ raise BinaryCorruptedError(
177
+ f"Downloaded binary hash mismatch for {dest.name}: "
178
+ f"expected {expected_sha256}, got {actual}"
179
+ )
180
+
181
+ if os.name != "nt":
182
+ os.chmod(tmp, 0o755)
183
+
184
+ os.replace(tmp, dest)
185
+ finally:
186
+ # If replace() above succeeded, tmp no longer exists; this is a safety net.
187
+ with contextlib.suppress(FileNotFoundError):
188
+ tmp.unlink()
189
+
190
+
191
+ def _download_to_cache(rid: str) -> Path:
192
+ """Download the matching AOT binary into the versioned cache."""
193
+ expected = EXPECTED_BINARIES.get(rid)
194
+ if expected is None:
195
+ raise BinaryNotFoundError(
196
+ f"This dev build of koshi has no manifest entry for {rid}. "
197
+ f"Either install koshi-mcp via `dotnet tool install --global Koshi.Mcp`, "
198
+ f"or set KOSHI_BIN to a built koshi-mcp binary, or install koshi from PyPI."
199
+ )
200
+
201
+ filename = expected["filename"]
202
+ sha256 = expected["sha256"]
203
+ url = RELEASE_URL_TEMPLATE.format(version=VERSION, filename=filename)
204
+
205
+ cache_dir = versioned_cache_dir()
206
+ dest = cache_dir / filename
207
+ lock = cache_dir / ".lock"
208
+
209
+ with _file_lock(lock):
210
+ # Another process may have completed the download while we waited.
211
+ if dest.exists() and _sha256_file(dest) == sha256:
212
+ return dest
213
+ _atomic_download(url, dest, expected_sha256=sha256)
214
+ return dest
215
+
216
+
217
+ def resolve_binary() -> Path:
218
+ """
219
+ Resolve the koshi-mcp binary by walking the resolution chain.
220
+
221
+ Version-matching is enforced at spawn time by :class:`koshi.Client` via
222
+ the ``serverInfo.version`` returned during MCP initialize, not here.
223
+ This function's job is only to return *some* path that exists.
224
+ """
225
+ rid = detect_rid()
226
+
227
+ env_bin = os.environ.get("KOSHI_BIN")
228
+ if env_bin:
229
+ p = Path(env_bin)
230
+ if not p.exists():
231
+ raise BinaryNotFoundError(
232
+ f"KOSHI_BIN is set to '{env_bin}' but that path does not exist."
233
+ )
234
+ return p
235
+
236
+ cached = versioned_cache_dir() / _binary_filename(rid)
237
+ if cached.exists():
238
+ info = EXPECTED_BINARIES.get(rid)
239
+ if info is not None:
240
+ if _sha256_file(cached) == info["sha256"]:
241
+ return cached
242
+ with contextlib.suppress(OSError):
243
+ cached.unlink()
244
+ else:
245
+ return cached
246
+
247
+ on_path = shutil.which("koshi-mcp") or shutil.which("koshi-mcp.exe")
248
+ if on_path:
249
+ return Path(on_path)
250
+
251
+ return _download_to_cache(rid)
@@ -0,0 +1,471 @@
1
+ """
2
+ Synchronous MCP client for the koshi-mcp server.
3
+
4
+ Spawns the koshi-mcp binary as a subprocess, performs the JSON-RPC 2.0
5
+ ``initialize`` / ``notifications/initialized`` handshake, then exposes
6
+ one Python method per Koshi MCP tool. Every tool method is a thin
7
+ wrapper around the MCP ``tools/call`` envelope — the wire format is
8
+ fixed and asserted in the test suite.
9
+
10
+ Threading: each ``Client`` is safe for use from a single thread (or
11
+ under a user-supplied lock). The internal lock serialises request /
12
+ response correlation; it is not a re-entrant lock.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ from collections.abc import Callable, Mapping
24
+ from pathlib import Path
25
+ from types import TracebackType
26
+ from typing import Any
27
+
28
+ from ._manifest import VERSION
29
+ from .binary import resolve_binary
30
+ from .errors import (
31
+ IncompatibleBinaryError,
32
+ KoshiError,
33
+ KoshiTimeoutError,
34
+ ProtocolError,
35
+ ToolError,
36
+ )
37
+
38
+ __all__ = ["Client"]
39
+
40
+ _LogHandler = Callable[[str], None]
41
+
42
+
43
+ class Client:
44
+ """Stdio MCP client bound to a single koshi-mcp subprocess.
45
+
46
+ Use as a context manager::
47
+
48
+ with koshi.Client() as c:
49
+ print(c.version())
50
+ c.index_directory("/path/to/repo")
51
+ print(c.search("auth pattern"))
52
+
53
+ Or manage lifecycle manually::
54
+
55
+ c = koshi.Client()
56
+ c.open()
57
+ try:
58
+ print(c.health())
59
+ finally:
60
+ c.close()
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ binary: str | Path | None = None,
66
+ log_handler: _LogHandler | None = None,
67
+ timeout: float = 15.0,
68
+ ) -> None:
69
+ self._binary_arg: Path | None = Path(binary) if binary is not None else None
70
+ self._log_handler = log_handler
71
+ self.timeout = timeout
72
+
73
+ self._binary: Path | None = None
74
+ self._proc: subprocess.Popen[str] | None = None
75
+ self._next_id = 0
76
+ self._lock = threading.Lock()
77
+ self._stderr_thread: threading.Thread | None = None
78
+ self._server_version: str | None = None
79
+
80
+ # ─── lifecycle ───────────────────────────────────────────────────────
81
+
82
+ def __enter__(self) -> Client:
83
+ self.open()
84
+ return self
85
+
86
+ def __exit__(
87
+ self,
88
+ exc_type: type[BaseException] | None,
89
+ exc: BaseException | None,
90
+ tb: TracebackType | None,
91
+ ) -> None:
92
+ self.close()
93
+
94
+ def open(self) -> None:
95
+ if self._proc is not None:
96
+ return
97
+
98
+ self._binary = self._binary_arg or resolve_binary()
99
+
100
+ # text=True + line buffering means every JSON document is a single
101
+ # readline()/write() call. Koshi-mcp emits one document per line.
102
+ self._proc = subprocess.Popen(
103
+ [str(self._binary)],
104
+ stdin=subprocess.PIPE,
105
+ stdout=subprocess.PIPE,
106
+ stderr=subprocess.PIPE,
107
+ text=True,
108
+ bufsize=1,
109
+ encoding="utf-8",
110
+ )
111
+
112
+ self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
113
+ self._stderr_thread.start()
114
+
115
+ try:
116
+ init = self._rpc(
117
+ "initialize",
118
+ {
119
+ "protocolVersion": "2024-11-05",
120
+ "capabilities": {},
121
+ "clientInfo": {"name": "koshi-py", "version": VERSION},
122
+ },
123
+ )
124
+ except KoshiError:
125
+ self.close()
126
+ raise
127
+
128
+ server_info = init.get("serverInfo") or {}
129
+ raw_version = str(server_info.get("version", ""))
130
+ # Strip "+<git-sha>" if present (InformationalVersionAttribute appends it).
131
+ version_core = raw_version.split("+", 1)[0]
132
+ self._server_version = version_core
133
+
134
+ if version_core and version_core != VERSION:
135
+ self.close()
136
+ raise IncompatibleBinaryError(
137
+ f"Resolved koshi-mcp binary reports version '{raw_version}', "
138
+ f"but Python koshi {VERSION} requires an exact-version match. "
139
+ f"Either upgrade koshi-mcp to {VERSION}, downgrade `pip install koshi=={version_core}`, "
140
+ f"unset KOSHI_BIN, or remove the stale `dotnet tool install` of Koshi.Mcp from PATH."
141
+ )
142
+
143
+ self._notify("notifications/initialized")
144
+
145
+ def close(self) -> None:
146
+ proc = self._proc
147
+ if proc is None:
148
+ return
149
+ self._proc = None
150
+ try:
151
+ if proc.stdin is not None:
152
+ with contextlib.suppress(OSError):
153
+ proc.stdin.close()
154
+ try:
155
+ proc.wait(timeout=5)
156
+ except subprocess.TimeoutExpired:
157
+ proc.kill()
158
+ with contextlib.suppress(subprocess.TimeoutExpired):
159
+ proc.wait(timeout=2)
160
+ finally:
161
+ self._server_version = None
162
+
163
+ # ─── transport ──────────────────────────────────────────────────────
164
+
165
+ def _drain_stderr(self) -> None:
166
+ proc = self._proc
167
+ if proc is None or proc.stderr is None:
168
+ return
169
+ try:
170
+ for line in proc.stderr:
171
+ line = line.rstrip("\n")
172
+ if self._log_handler is not None:
173
+ # Never let a user log handler crash the drainer.
174
+ with contextlib.suppress(Exception):
175
+ self._log_handler(line)
176
+ except (ValueError, OSError):
177
+ return
178
+
179
+ def _ensure_open(self) -> subprocess.Popen[str]:
180
+ if self._proc is None:
181
+ raise ProtocolError("Client is not open. Call .open() or use `with` statement.")
182
+ return self._proc
183
+
184
+ def _send_line(self, payload: Mapping[str, Any]) -> None:
185
+ proc = self._ensure_open()
186
+ if proc.stdin is None:
187
+ raise ProtocolError("subprocess stdin is unexpectedly None")
188
+ proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
189
+ proc.stdin.flush()
190
+
191
+ def _rpc(self, method: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
192
+ with self._lock:
193
+ self._next_id += 1
194
+ req_id = self._next_id
195
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
196
+ if params is not None:
197
+ payload["params"] = params
198
+
199
+ self._send_line(payload)
200
+
201
+ proc = self._ensure_open()
202
+ assert proc.stdout is not None
203
+
204
+ deadline = time.monotonic() + self.timeout
205
+ while True:
206
+ remaining = deadline - time.monotonic()
207
+ if remaining <= 0:
208
+ raise KoshiTimeoutError(
209
+ f"No response to '{method}' within {self.timeout:.1f}s"
210
+ )
211
+
212
+ line = proc.stdout.readline()
213
+ if not line:
214
+ raise ProtocolError(
215
+ f"koshi-mcp closed stdout while waiting for '{method}' response"
216
+ )
217
+
218
+ line = line.strip()
219
+ if not line:
220
+ continue
221
+
222
+ try:
223
+ msg = json.loads(line)
224
+ except json.JSONDecodeError as e:
225
+ raise ProtocolError(
226
+ f"Non-JSON line on stdout while waiting for '{method}': {line!r}"
227
+ ) from e
228
+
229
+ if msg.get("id") != req_id:
230
+ # Stray notification or out-of-order reply; ignore and keep reading.
231
+ continue
232
+
233
+ if "error" in msg:
234
+ err = msg["error"]
235
+ raise ProtocolError(
236
+ f"{method}: {err.get('message', err)} (code {err.get('code')})"
237
+ )
238
+
239
+ return msg.get("result") or {}
240
+
241
+ def _notify(self, method: str, params: Mapping[str, Any] | None = None) -> None:
242
+ with self._lock:
243
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
244
+ if params is not None:
245
+ payload["params"] = params
246
+ self._send_line(payload)
247
+
248
+ def _tool_call(self, tool: str, arguments: Mapping[str, Any] | None = None) -> str:
249
+ result = self._rpc("tools/call", {"name": tool, "arguments": dict(arguments or {})})
250
+ content = result.get("content") or []
251
+ text = ""
252
+ if content and isinstance(content, list):
253
+ first = content[0]
254
+ if isinstance(first, dict):
255
+ text = str(first.get("text", ""))
256
+
257
+ is_error = bool(result.get("isError"))
258
+ if is_error or text.startswith("❌"):
259
+ raise ToolError(tool, text or "tool returned an error", raw=result)
260
+ return text
261
+
262
+ # ─── server metadata ────────────────────────────────────────────────
263
+
264
+ @property
265
+ def server_version(self) -> str | None:
266
+ """Version reported by the koshi-mcp server during initialize (or None)."""
267
+ return self._server_version
268
+
269
+ @property
270
+ def binary_path(self) -> Path | None:
271
+ """Filesystem path of the koshi-mcp binary actually spawned."""
272
+ return self._binary
273
+
274
+ # ─── Retrieval (5) ──────────────────────────────────────────────────
275
+
276
+ def index(self, documents: list[Mapping[str, Any]]) -> str:
277
+ """Index a list of in-memory documents for BM25 retrieval."""
278
+ return self._tool_call("koshi_index", {"documents": json.dumps(documents)})
279
+
280
+ def index_directory(
281
+ self,
282
+ path: str | None = None,
283
+ pattern: str | None = None,
284
+ max_file_size_kb: int = 256,
285
+ max_files: int = 5000,
286
+ ) -> str:
287
+ """Recursively index supported text files under ``path``."""
288
+ args: dict[str, Any] = {"maxFileSizeKb": max_file_size_kb, "maxFiles": max_files}
289
+ if path is not None:
290
+ args["path"] = path
291
+ if pattern is not None:
292
+ args["pattern"] = pattern
293
+ return self._tool_call("koshi_index_directory", args)
294
+
295
+ def search(self, query: str, top_k: int = 5) -> str:
296
+ """Search the indexed corpus with BM25; return the top ``top_k`` chunks."""
297
+ return self._tool_call("koshi_search", {"query": query, "topK": top_k})
298
+
299
+ def list_indexed(self) -> str:
300
+ """List all indexed documents grouped by source."""
301
+ return self._tool_call("koshi_list_indexed")
302
+
303
+ def clear_index(self) -> str:
304
+ """Clear the indexed corpus."""
305
+ return self._tool_call("koshi_clear_index")
306
+
307
+ # ─── Memory (5) ─────────────────────────────────────────────────────
308
+
309
+ def remember(
310
+ self,
311
+ content: str,
312
+ subject: str,
313
+ type: str = "Fact",
314
+ confidence: float = 0.8,
315
+ source: str = "user",
316
+ ) -> str:
317
+ """Store a fact / decision / pattern / preference in memory."""
318
+ return self._tool_call(
319
+ "koshi_remember",
320
+ {
321
+ "content": content,
322
+ "subject": subject,
323
+ "type": type,
324
+ "confidence": confidence,
325
+ "source": source,
326
+ },
327
+ )
328
+
329
+ def recall(self, query: str, type: str = "All", top_k: int = 5) -> str:
330
+ """Recall memories relevant to ``query``."""
331
+ return self._tool_call(
332
+ "koshi_recall",
333
+ {"query": query, "type": type, "topK": top_k},
334
+ )
335
+
336
+ def memory_stats(self) -> str:
337
+ """Return summary stats about the memory store."""
338
+ return self._tool_call("koshi_memory_stats")
339
+
340
+ def forget(self, subject: str) -> str:
341
+ """Remove all memories with the given subject (case-insensitive)."""
342
+ return self._tool_call("koshi_forget", {"subject": subject})
343
+
344
+ def clear_memories(self, confirm: bool = False) -> str:
345
+ """Clear ALL stored memories. Pass ``confirm=True`` to actually delete."""
346
+ return self._tool_call("koshi_clear_memories", {"confirm": confirm})
347
+
348
+ # ─── Context (3) ────────────────────────────────────────────────────
349
+
350
+ def compile_context(
351
+ self,
352
+ system_prompt: str,
353
+ user_query: str,
354
+ retrieved_content: str | None = None,
355
+ memories: str | None = None,
356
+ team_context: str | None = None,
357
+ token_budget: int = 8192,
358
+ strategy: str = "CacheOptimized",
359
+ ) -> str:
360
+ """Compile a context bundle suitable for an LLM call."""
361
+ args: dict[str, Any] = {
362
+ "systemPrompt": system_prompt,
363
+ "userQuery": user_query,
364
+ "tokenBudget": token_budget,
365
+ "strategy": strategy,
366
+ }
367
+ if retrieved_content is not None:
368
+ args["retrievedContent"] = retrieved_content
369
+ if memories is not None:
370
+ args["memories"] = memories
371
+ if team_context is not None:
372
+ args["teamContext"] = team_context
373
+ return self._tool_call("koshi_compile_context", args)
374
+
375
+ def token_count(self, text: str) -> str:
376
+ """Count GPT-4 (cl100k) tokens in ``text``."""
377
+ return self._tool_call("koshi_token_count", {"text": text})
378
+
379
+ def budget_plan(
380
+ self,
381
+ total_budget: int = 8192,
382
+ system_prompt: str | None = None,
383
+ team_context: str | None = None,
384
+ ) -> str:
385
+ """Plan a token budget across system / retrieval / memory / history."""
386
+ args: dict[str, Any] = {"totalBudget": total_budget}
387
+ if system_prompt is not None:
388
+ args["systemPrompt"] = system_prompt
389
+ if team_context is not None:
390
+ args["teamContext"] = team_context
391
+ return self._tool_call("koshi_budget_plan", args)
392
+
393
+ # ─── Team / Quality (5) ─────────────────────────────────────────────
394
+
395
+ def register_team(
396
+ self,
397
+ team_id: str,
398
+ name: str,
399
+ description: str | None = None,
400
+ token_budget: int = 8192,
401
+ top_k: int = 5,
402
+ quality_target: float = 0.7,
403
+ system_prompt: str | None = None,
404
+ team_context: str | None = None,
405
+ ) -> str:
406
+ """Register a team for quality scoring."""
407
+ args: dict[str, Any] = {
408
+ "teamId": team_id,
409
+ "name": name,
410
+ "tokenBudget": token_budget,
411
+ "topK": top_k,
412
+ "qualityTarget": quality_target,
413
+ }
414
+ if description is not None:
415
+ args["description"] = description
416
+ if system_prompt is not None:
417
+ args["systemPrompt"] = system_prompt
418
+ if team_context is not None:
419
+ args["teamContext"] = team_context
420
+ return self._tool_call("koshi_register_team", args)
421
+
422
+ def score_turn(
423
+ self,
424
+ team_id: str,
425
+ retrieved_chunks: int = 0,
426
+ memories_recalled: int = 0,
427
+ budget_utilization: float = 0.5,
428
+ cache_ratio: float = 0.0,
429
+ latency_ms: int = 3000,
430
+ user_rating: int = 0,
431
+ issues: str | None = None,
432
+ ) -> str:
433
+ """Score a single turn and update the team's running quality score."""
434
+ args: dict[str, Any] = {
435
+ "teamId": team_id,
436
+ "retrievedChunks": retrieved_chunks,
437
+ "memoriesRecalled": memories_recalled,
438
+ "budgetUtilization": budget_utilization,
439
+ "cacheRatio": cache_ratio,
440
+ "latencyMs": latency_ms,
441
+ "userRating": user_rating,
442
+ }
443
+ if issues is not None:
444
+ args["issues"] = issues
445
+ return self._tool_call("koshi_score_turn", args)
446
+
447
+ def team_dashboard(self, team_id: str) -> str:
448
+ """Render the quality dashboard for a team."""
449
+ return self._tool_call("koshi_team_dashboard", {"teamId": team_id})
450
+
451
+ def analyze_feedback(self, team_id: str) -> str:
452
+ """Analyse user feedback trends for a team."""
453
+ return self._tool_call("koshi_analyze_feedback", {"teamId": team_id})
454
+
455
+ def list_teams(self) -> str:
456
+ """List all registered teams."""
457
+ return self._tool_call("koshi_list_teams")
458
+
459
+ # ─── Diagnostics (2) ────────────────────────────────────────────────
460
+
461
+ def version(self) -> str:
462
+ """Report Koshi MCP server version, .NET runtime, OS, and uptime."""
463
+ return self._tool_call("koshi_version")
464
+
465
+ def health(self) -> str:
466
+ """Report runtime health: indexed corpus, memory, persistence, uptime."""
467
+ return self._tool_call("koshi_health")
468
+
469
+
470
+ if sys.version_info < (3, 10): # pragma: no cover # noqa: UP036
471
+ raise RuntimeError("koshi requires Python 3.10 or newer.")
@@ -0,0 +1,50 @@
1
+ """Exception hierarchy for the koshi Python client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class KoshiError(Exception):
7
+ """Base class for all koshi errors."""
8
+
9
+
10
+ class BinaryNotFoundError(KoshiError):
11
+ """No suitable koshi-mcp binary was found on this system."""
12
+
13
+
14
+ class IncompatibleBinaryError(KoshiError):
15
+ """The resolved binary's version does not match this koshi package.
16
+
17
+ Raised when:
18
+ * KOSHI_BIN points to a binary whose `serverInfo.version` differs from
19
+ koshi.__version__.
20
+ * A binary found on PATH (e.g. an old `dotnet tool install` of
21
+ Koshi.Mcp) has a mismatched version.
22
+
23
+ The Python package and the koshi-mcp binary version must match exactly.
24
+ """
25
+
26
+
27
+ class BinaryCorruptedError(KoshiError):
28
+ """A downloaded binary's SHA-256 hash did not match the expected hash."""
29
+
30
+
31
+ class UnsupportedPlatformError(KoshiError):
32
+ """No AOT artifact is published for this OS/CPU combination."""
33
+
34
+
35
+ class ProtocolError(KoshiError):
36
+ """Received an unexpected or malformed message from the koshi-mcp server."""
37
+
38
+
39
+ class ToolError(KoshiError):
40
+ """The MCP server returned an error response for a tools/call request."""
41
+
42
+ def __init__(self, tool: str, message: str, raw: object | None = None) -> None:
43
+ self.tool = tool
44
+ self.message = message
45
+ self.raw = raw
46
+ super().__init__(f"{tool}: {message}")
47
+
48
+
49
+ class KoshiTimeoutError(KoshiError):
50
+ """A request to the koshi-mcp server timed out before a response arrived."""
File without changes