kimetsu 0.1.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.
@@ -0,0 +1,25 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ lint:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with:
10
+ python-version: "3.12"
11
+ - run: pip install -e ".[dev]"
12
+ - run: ruff check .
13
+ - run: mypy src
14
+ test:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ matrix:
18
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - run: pip install -e ".[dev]"
25
+ - run: pytest -q
@@ -0,0 +1,20 @@
1
+ name: publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+ permissions:
6
+ contents: read
7
+ id-token: write
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - name: Publish to PyPI
20
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .superpowers/
kimetsu-0.1.0/LICENSE ADDED
@@ -0,0 +1,150 @@
1
+ Licensed under either of MIT or Apache-2.0 at your option.
2
+
3
+ ================================================================================
4
+ MIT License
5
+ ================================================================================
6
+
7
+ Copyright (c) 2026 RodCor
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ ================================================================================
28
+ Apache License
29
+ Version 2.0, January 2004
30
+ http://www.apache.org/licenses/
31
+ ================================================================================
32
+
33
+ Copyright 2026 RodCor
34
+
35
+ Licensed under the Apache License, Version 2.0 (the "License");
36
+ you may not use this file except in compliance with the License.
37
+ You may obtain a copy of the License at
38
+
39
+ http://www.apache.org/licenses/LICENSE-2.0
40
+
41
+ Unless required by applicable law or agreed to in writing, software
42
+ distributed under the License is distributed on an "AS IS" BASIS,
43
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
44
+ See the License for the specific language governing permissions and
45
+ limitations under the License.
46
+
47
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
48
+
49
+ 1. Definitions.
50
+
51
+ "License" shall mean the terms and conditions for use, reproduction, and
52
+ distribution as defined by Sections 1 through 9 of this document.
53
+
54
+ "Licensor" shall mean the copyright owner or entity authorized by the
55
+ copyright owner that is granting the License.
56
+
57
+ "Legal Entity" shall mean the union of the acting entity and all other
58
+ entities that control, are controlled by, or are under common control with
59
+ that entity. For the purposes of this definition, "control" means (i) the
60
+ power, direct or indirect, to cause the direction or management of such
61
+ entity, whether by contract or otherwise, or (ii) ownership of fifty percent
62
+ (50%) or more of the outstanding shares, or (iii) beneficial ownership of such
63
+ entity.
64
+
65
+ "You" (or "Your") shall mean an individual or Legal Entity exercising
66
+ permissions granted by this License.
67
+
68
+ "Source" form shall mean the preferred form for making modifications,
69
+ including but not limited to software source code, documentation source, and
70
+ configuration files.
71
+
72
+ "Object" form shall mean any form resulting from mechanical transformation or
73
+ translation of a Source form, including but not limited to compiled object
74
+ code, generated documentation, and conversions to other media types.
75
+
76
+ "Work" shall mean the work of authorship, whether in Source or Object form,
77
+ made available under the License, as indicated by a copyright notice that is
78
+ included in or attached to the work (an example is provided in the Appendix
79
+ below).
80
+
81
+ "Derivative Works" shall mean any work, whether in Source or Object form,
82
+ that is based on (or derived from) the Work and for which the editorial
83
+ revisions, annotations, elaborations, or other modifications represent, as a
84
+ whole, an original work of authorship.
85
+
86
+ "Contribution" shall mean any work of authorship, including the original
87
+ version of the Work and any modifications or additions to that Work or
88
+ Derivative Works thereof, that is intentionally submitted to Licensor for
89
+ inclusion in the Work by the copyright owner or by an individual or Legal
90
+ Entity authorized to submit on behalf of the copyright owner.
91
+
92
+ "Contributor" shall mean Licensor and any individual or Legal Entity on
93
+ behalf of whom a Contribution has been received by Licensor and subsequently
94
+ incorporated within the Work.
95
+
96
+ 2. Grant of Copyright License. Subject to the terms and conditions of this
97
+ License, each Contributor hereby grants to You a perpetual, worldwide,
98
+ non-exclusive, no-charge, royalty-free, irrevocable copyright license to
99
+ reproduce, prepare Derivative Works of, publicly display, publicly perform,
100
+ sublicense, and distribute the Work and such Derivative Works in Source or
101
+ Object form.
102
+
103
+ 3. Grant of Patent License. Subject to the terms and conditions of this
104
+ License, each Contributor hereby grants to You a perpetual, worldwide,
105
+ non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
106
+ section) patent license to make, have made, use, offer to sell, sell, import,
107
+ and otherwise transfer the Work.
108
+
109
+ 4. Redistribution. You may reproduce and distribute copies of the Work or
110
+ Derivative Works thereof in any medium, with or without modifications, and in
111
+ Source or Object form, provided that You meet the following conditions:
112
+
113
+ (a) You must give any other recipients of the Work or Derivative Works a
114
+ copy of this License; and
115
+
116
+ (b) You must cause any modified files to carry prominent notices stating
117
+ that You changed the files; and
118
+
119
+ (c) You must retain, in the Source form of any Derivative Works that You
120
+ distribute, all copyright, patent, trademark, and attribution notices
121
+ from the Source form of the Work; and
122
+
123
+ (d) If the Work includes a "NOTICE" text file as part of its
124
+ distribution, then any Derivative Works that You distribute must
125
+ include a readable copy of the attribution notices contained within
126
+ such NOTICE file.
127
+
128
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any
129
+ Contribution intentionally submitted for inclusion in the Work by You to the
130
+ Licensor shall be under the terms and conditions of this License, without any
131
+ additional terms or conditions.
132
+
133
+ 6. Trademarks. This License does not grant permission to use the trade
134
+ names, trademarks, service marks, or product names of the Licensor.
135
+
136
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
137
+ writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES
138
+ OR CONDITIONS OF ANY KIND, either express or implied.
139
+
140
+ 8. Limitation of Liability. In no event and under no legal theory shall any
141
+ Contributor be liable to You for damages, including any direct, indirect,
142
+ special, incidental, or consequential damages arising as a result of this
143
+ License.
144
+
145
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work
146
+ or Derivative Works thereof, You may choose to offer, and charge a fee for,
147
+ acceptance of support, warranty, indemnity, or other liability obligations
148
+ consistent with this License.
149
+
150
+ END OF TERMS AND CONDITIONS
kimetsu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: kimetsu
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for the Kimetsu remote brain
5
+ Project-URL: Homepage, https://github.com/RodCor/kimetsu-py
6
+ Project-URL: Repository, https://github.com/RodCor/kimetsu-py
7
+ Project-URL: Main project, https://github.com/RodCor/kimetsu
8
+ Author: Rodrigo Cordoba
9
+ License-Expression: MIT OR Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agent,kimetsu,llm,mcp,memory,sdk
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: pydantic>=2
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: respx>=0.21; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # kimetsu
28
+
29
+ Typed Python client for the Kimetsu remote brain.
30
+
31
+ ## What it is
32
+
33
+ A sync + async client over the Kimetsu remote brain's MCP JSON-RPC endpoint.
34
+ Requires a running `kimetsu-remote` server; you supply its base URL, a bearer
35
+ token, and the repo name.
36
+
37
+ ## Install
38
+
39
+ ```
40
+ pip install kimetsu
41
+ ```
42
+
43
+ ## Quickstart (sync)
44
+
45
+ ```python
46
+ from kimetsu import KimetsuClient
47
+
48
+ client = KimetsuClient(base_url="https://brain.example.com", token="tok_...", repo="web")
49
+ client.record("Use `just migrate`, never raw sqlx", tags=["db", "workflow"])
50
+ ctx = client.context("how do we run migrations?", tags=["db"])
51
+ print(ctx.capsules)
52
+ ```
53
+
54
+ ## Quickstart (async)
55
+
56
+ ```python
57
+ import asyncio
58
+ from kimetsu import AsyncKimetsuClient
59
+
60
+ async def main():
61
+ async with AsyncKimetsuClient(base_url="https://brain.example.com", token="tok_...", repo="web") as client:
62
+ ctx = await client.context("how do we run migrations?", tags=["db"])
63
+ print(ctx.capsules)
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ## Configuration / env vars
69
+
70
+ `KIMETSU_REMOTE_URL`, `KIMETSU_REMOTE_TOKEN`, `KIMETSU_REMOTE_REPO` (used when
71
+ the matching constructor arg is omitted). Optional `timeout` (default 30s).
72
+
73
+ ## API surface — method → remote tool
74
+
75
+ | SDK call | Remote tool |
76
+ | --- | --- |
77
+ | `context` | `kimetsu_brain_context` |
78
+ | `record` | `kimetsu_brain_record` |
79
+ | `status` | `kimetsu_brain_status` |
80
+ | `insights` | `kimetsu_brain_insights` |
81
+ | `memory.search` | `kimetsu_brain_memory_search` |
82
+ | `memory.add` | `kimetsu_brain_memory_add` |
83
+ | `memory.list` | `kimetsu_brain_memory_list` |
84
+ | `memory.top` | `kimetsu_brain_memory_top` |
85
+ | `memory.accept` | `kimetsu_brain_memory_accept` |
86
+ | `memory.reject` | `kimetsu_brain_memory_reject` |
87
+ | `memory.invalidate` | `kimetsu_brain_memory_invalidate` |
88
+ | `memory.blame` | `kimetsu_brain_memory_blame` (per-run citation attribution — pass a `run_id`, returns which memories that run cited) |
89
+ | `memory.proposals` | `kimetsu_brain_memory_proposals` |
90
+ | `memory.conflicts` | `kimetsu_brain_memory_conflicts` |
91
+ | `memory.conflict_resolve` | `kimetsu_brain_conflict_resolve` |
92
+ | `memory.prune` | `kimetsu_brain_prune` |
93
+ | `config.show` | `kimetsu_brain_config_show` |
94
+ | `models.list` | `kimetsu_brain_model_list` |
95
+ | `benchmark.context` | `kimetsu_benchmark_context` |
96
+ | `benchmark.record_outcome` | `kimetsu_benchmark_record_outcome` |
97
+
98
+ (`AsyncKimetsuClient` mirrors every method with `await`.)
99
+
100
+ Required arguments: `record` requires `tags`, `memory.add` requires `scope`,
101
+ and `benchmark.record_outcome` requires `task` — all three raise `TypeError`
102
+ if omitted, e.g. `client.benchmark.record_outcome(task="my-task", passed=True)`.
103
+
104
+ ## Errors
105
+
106
+ All error types are exported from the top level: `from kimetsu import KimetsuAuthError`.
107
+
108
+ `KimetsuError` (base), `KimetsuAuthError` (401/403), `KimetsuRateLimitError`
109
+ (429, `.retry_after`), `KimetsuToolError` (JSON-RPC tool error, `.code`),
110
+ `KimetsuProtocolError` (handshake/malformed envelope).
111
+
112
+ ## Typed returns
113
+
114
+ `context`→`Context` (`.capsules`), `status`→`Status` (`.initialized`,
115
+ `.accepted`), `memory.add`→`Memory` (`.id`, `.text`, `.tags`). Other methods
116
+ return `dict`.
117
+
118
+ ## License
119
+
120
+ MIT OR Apache-2.0.
121
+
122
+ ## Links
123
+
124
+ Main project: https://github.com/RodCor/kimetsu
@@ -0,0 +1,98 @@
1
+ # kimetsu
2
+
3
+ Typed Python client for the Kimetsu remote brain.
4
+
5
+ ## What it is
6
+
7
+ A sync + async client over the Kimetsu remote brain's MCP JSON-RPC endpoint.
8
+ Requires a running `kimetsu-remote` server; you supply its base URL, a bearer
9
+ token, and the repo name.
10
+
11
+ ## Install
12
+
13
+ ```
14
+ pip install kimetsu
15
+ ```
16
+
17
+ ## Quickstart (sync)
18
+
19
+ ```python
20
+ from kimetsu import KimetsuClient
21
+
22
+ client = KimetsuClient(base_url="https://brain.example.com", token="tok_...", repo="web")
23
+ client.record("Use `just migrate`, never raw sqlx", tags=["db", "workflow"])
24
+ ctx = client.context("how do we run migrations?", tags=["db"])
25
+ print(ctx.capsules)
26
+ ```
27
+
28
+ ## Quickstart (async)
29
+
30
+ ```python
31
+ import asyncio
32
+ from kimetsu import AsyncKimetsuClient
33
+
34
+ async def main():
35
+ async with AsyncKimetsuClient(base_url="https://brain.example.com", token="tok_...", repo="web") as client:
36
+ ctx = await client.context("how do we run migrations?", tags=["db"])
37
+ print(ctx.capsules)
38
+
39
+ asyncio.run(main())
40
+ ```
41
+
42
+ ## Configuration / env vars
43
+
44
+ `KIMETSU_REMOTE_URL`, `KIMETSU_REMOTE_TOKEN`, `KIMETSU_REMOTE_REPO` (used when
45
+ the matching constructor arg is omitted). Optional `timeout` (default 30s).
46
+
47
+ ## API surface — method → remote tool
48
+
49
+ | SDK call | Remote tool |
50
+ | --- | --- |
51
+ | `context` | `kimetsu_brain_context` |
52
+ | `record` | `kimetsu_brain_record` |
53
+ | `status` | `kimetsu_brain_status` |
54
+ | `insights` | `kimetsu_brain_insights` |
55
+ | `memory.search` | `kimetsu_brain_memory_search` |
56
+ | `memory.add` | `kimetsu_brain_memory_add` |
57
+ | `memory.list` | `kimetsu_brain_memory_list` |
58
+ | `memory.top` | `kimetsu_brain_memory_top` |
59
+ | `memory.accept` | `kimetsu_brain_memory_accept` |
60
+ | `memory.reject` | `kimetsu_brain_memory_reject` |
61
+ | `memory.invalidate` | `kimetsu_brain_memory_invalidate` |
62
+ | `memory.blame` | `kimetsu_brain_memory_blame` (per-run citation attribution — pass a `run_id`, returns which memories that run cited) |
63
+ | `memory.proposals` | `kimetsu_brain_memory_proposals` |
64
+ | `memory.conflicts` | `kimetsu_brain_memory_conflicts` |
65
+ | `memory.conflict_resolve` | `kimetsu_brain_conflict_resolve` |
66
+ | `memory.prune` | `kimetsu_brain_prune` |
67
+ | `config.show` | `kimetsu_brain_config_show` |
68
+ | `models.list` | `kimetsu_brain_model_list` |
69
+ | `benchmark.context` | `kimetsu_benchmark_context` |
70
+ | `benchmark.record_outcome` | `kimetsu_benchmark_record_outcome` |
71
+
72
+ (`AsyncKimetsuClient` mirrors every method with `await`.)
73
+
74
+ Required arguments: `record` requires `tags`, `memory.add` requires `scope`,
75
+ and `benchmark.record_outcome` requires `task` — all three raise `TypeError`
76
+ if omitted, e.g. `client.benchmark.record_outcome(task="my-task", passed=True)`.
77
+
78
+ ## Errors
79
+
80
+ All error types are exported from the top level: `from kimetsu import KimetsuAuthError`.
81
+
82
+ `KimetsuError` (base), `KimetsuAuthError` (401/403), `KimetsuRateLimitError`
83
+ (429, `.retry_after`), `KimetsuToolError` (JSON-RPC tool error, `.code`),
84
+ `KimetsuProtocolError` (handshake/malformed envelope).
85
+
86
+ ## Typed returns
87
+
88
+ `context`→`Context` (`.capsules`), `status`→`Status` (`.initialized`,
89
+ `.accepted`), `memory.add`→`Memory` (`.id`, `.text`, `.tags`). Other methods
90
+ return `dict`.
91
+
92
+ ## License
93
+
94
+ MIT OR Apache-2.0.
95
+
96
+ ## Links
97
+
98
+ Main project: https://github.com/RodCor/kimetsu
@@ -0,0 +1,25 @@
1
+ """Minimal Kimetsu SDK example. Run against a live kimetsu-remote:
2
+
3
+ KIMETSU_REMOTE_URL=https://brain.example.com \
4
+ KIMETSU_REMOTE_TOKEN=tok_... \
5
+ KIMETSU_REMOTE_REPO=web \
6
+ python examples/quickstart.py
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from kimetsu import KimetsuClient
11
+
12
+
13
+ def main() -> None:
14
+ # base_url/token/repo fall back to KIMETSU_REMOTE_* env vars.
15
+ client = KimetsuClient()
16
+ client.record("Example lesson from the quickstart", tags=["example"])
17
+ ctx = client.context("what did the quickstart record?", tags=["example"])
18
+ print("capsules:", ctx.capsules)
19
+ hits = client.memory.search("quickstart")
20
+ print("search:", hits)
21
+ client.close()
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kimetsu"
7
+ version = "0.1.0"
8
+ description = "Typed Python client for the Kimetsu remote brain"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT OR Apache-2.0"
12
+ authors = [{ name = "Rodrigo Cordoba" }]
13
+ dependencies = ["httpx>=0.27", "pydantic>=2"]
14
+ keywords = ["kimetsu", "memory", "mcp", "sdk", "llm", "agent"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/RodCor/kimetsu-py"
24
+ Repository = "https://github.com/RodCor/kimetsu-py"
25
+ "Main project" = "https://github.com/RodCor/kimetsu"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "ruff", "mypy"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/kimetsu"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.mypy]
37
+ strict = true
38
+ python_version = "3.10"
39
+
40
+ [tool.ruff]
41
+ target-version = "py39"
@@ -0,0 +1,26 @@
1
+ from .async_client import AsyncKimetsuClient
2
+ from .client import KimetsuClient
3
+ from .errors import (
4
+ KimetsuError,
5
+ KimetsuAuthError,
6
+ KimetsuRateLimitError,
7
+ KimetsuToolError,
8
+ KimetsuProtocolError,
9
+ )
10
+ from .models import Context, Memory, Status
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "AsyncKimetsuClient",
16
+ "KimetsuClient",
17
+ "__version__",
18
+ "Context",
19
+ "Memory",
20
+ "Status",
21
+ "KimetsuError",
22
+ "KimetsuAuthError",
23
+ "KimetsuRateLimitError",
24
+ "KimetsuToolError",
25
+ "KimetsuProtocolError",
26
+ ]
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools
5
+ from typing import Any
6
+ from typing import Dict
7
+ from typing import Optional
8
+
9
+ import httpx
10
+
11
+ from .errors import KimetsuAuthError
12
+ from .errors import KimetsuProtocolError
13
+ from .errors import KimetsuRateLimitError
14
+ from .errors import KimetsuToolError
15
+
16
+ PROTOCOL_VERSION = "2024-11-05"
17
+
18
+
19
+ def build_call(name: str, arguments: Dict[str, Any], *, request_id: int) -> Dict[str, Any]:
20
+ return {"jsonrpc": "2.0", "id": request_id, "method": "tools/call",
21
+ "params": {"name": name, "arguments": arguments}}
22
+
23
+
24
+ def _build_initialize(request_id: int, protocol_version: str) -> Dict[str, Any]:
25
+ return {"jsonrpc": "2.0", "id": request_id, "method": "initialize",
26
+ "params": {"protocolVersion": protocol_version, "capabilities": {},
27
+ "clientInfo": {"name": "kimetsu-py", "version": "0.1.0"}}}
28
+
29
+
30
+ def parse_envelope(status_code: int, body: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]:
31
+ if status_code in (401, 403):
32
+ raise KimetsuAuthError(f"authentication failed (HTTP {status_code})")
33
+ if status_code == 429:
34
+ ra = headers.get("retry-after")
35
+ retry_after: Optional[float] = None
36
+ if ra:
37
+ try:
38
+ retry_after = float(ra)
39
+ except ValueError:
40
+ retry_after = None
41
+ raise KimetsuRateLimitError("rate limited (HTTP 429)", retry_after=retry_after)
42
+ if status_code >= 400:
43
+ raise KimetsuProtocolError(f"unexpected HTTP {status_code}")
44
+ if "error" in body:
45
+ err = body["error"]
46
+ raise KimetsuToolError(err.get("message", "tool error"), code=err.get("code"))
47
+ if "result" not in body:
48
+ raise KimetsuProtocolError("missing result in JSON-RPC envelope")
49
+ result: Dict[str, Any] = body["result"]
50
+ return result
51
+
52
+
53
+ class SyncTransport:
54
+ def __init__(self, base_url: str, token: str, repo: str, *,
55
+ timeout: float = 30.0, protocol_version: str = PROTOCOL_VERSION,
56
+ client: Optional[httpx.Client] = None) -> None:
57
+ self._url = f"{base_url.rstrip('/')}/mcp/{repo}"
58
+ self._headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
59
+ self._protocol_version = protocol_version
60
+ self._ids = itertools.count(1)
61
+ self._client = client or httpx.Client(timeout=timeout)
62
+ self._initialized = False
63
+
64
+ def _post(self, payload: Dict[str, Any]) -> Dict[str, Any]:
65
+ resp = self._client.post(self._url, headers=self._headers, json=payload)
66
+ try:
67
+ body = resp.json() if resp.content else {}
68
+ except ValueError:
69
+ body = {}
70
+ return parse_envelope(resp.status_code, body, dict(resp.headers))
71
+
72
+ def _ensure_init(self) -> None:
73
+ if self._initialized:
74
+ return
75
+ result = self._post(_build_initialize(next(self._ids), self._protocol_version))
76
+ got = result.get("protocolVersion")
77
+ if got and got != self._protocol_version:
78
+ raise KimetsuProtocolError(f"server protocol {got} != {self._protocol_version}")
79
+ self._initialized = True
80
+
81
+ def call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
82
+ self._ensure_init()
83
+ return self._post(build_call(name, arguments, request_id=next(self._ids)))
84
+
85
+ def close(self) -> None:
86
+ self._client.close()
87
+
88
+
89
+ class AsyncTransport:
90
+ def __init__(self, base_url: str, token: str, repo: str, *,
91
+ timeout: float = 30.0, protocol_version: str = PROTOCOL_VERSION,
92
+ client: Optional[httpx.AsyncClient] = None) -> None:
93
+ self._url = f"{base_url.rstrip('/')}/mcp/{repo}"
94
+ self._headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
95
+ self._protocol_version = protocol_version
96
+ self._ids = itertools.count(1)
97
+ self._client = client or httpx.AsyncClient(timeout=timeout)
98
+ self._initialized = False
99
+ self._init_lock = asyncio.Lock()
100
+
101
+ async def _post(self, payload: Dict[str, Any]) -> Dict[str, Any]:
102
+ resp = await self._client.post(self._url, headers=self._headers, json=payload)
103
+ try:
104
+ body = resp.json() if resp.content else {}
105
+ except ValueError:
106
+ body = {}
107
+ return parse_envelope(resp.status_code, body, dict(resp.headers))
108
+
109
+ async def _ensure_init(self) -> None:
110
+ if self._initialized:
111
+ return
112
+ async with self._init_lock:
113
+ if self._initialized:
114
+ return
115
+ result = await self._post(_build_initialize(next(self._ids), self._protocol_version))
116
+ got = result.get("protocolVersion")
117
+ if got and got != self._protocol_version:
118
+ raise KimetsuProtocolError(f"server protocol {got} != {self._protocol_version}")
119
+ self._initialized = True
120
+
121
+ async def call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
122
+ await self._ensure_init()
123
+ return await self._post(build_call(name, arguments, request_id=next(self._ids)))
124
+
125
+ async def aclose(self) -> None:
126
+ await self._client.aclose()
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+ import os
4
+ from ._transport import AsyncTransport
5
+ from .client import _clean
6
+ from .models import Context, Memory, Status
7
+
8
+
9
+ class _AsyncMemory:
10
+ def __init__(self, t: Any) -> None:
11
+ self._t = t
12
+
13
+ async def search(self, query: str, **kw: Any) -> Dict[str, Any]:
14
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_search", _clean({"query": query, **kw}))
15
+ return res
16
+
17
+ async def add(self, text: str, *, scope: str, kind: Optional[str] = None, **kw: Any) -> Memory:
18
+ return Memory.model_validate(await self._t.call("kimetsu_brain_memory_add", _clean({"text": text, "scope": scope, "kind": kind, **kw})))
19
+
20
+ async def list(self, **kw: Any) -> Dict[str, Any]:
21
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_list", _clean(kw))
22
+ return res
23
+
24
+ async def top(self, **kw: Any) -> Dict[str, Any]:
25
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_top", _clean(kw))
26
+ return res
27
+
28
+ async def accept(self, proposal_id: str, **kw: Any) -> Dict[str, Any]:
29
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_accept", _clean({"proposal_id": proposal_id, **kw}))
30
+ return res
31
+
32
+ async def reject(self, proposal_id: str, **kw: Any) -> Dict[str, Any]:
33
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_reject", _clean({"proposal_id": proposal_id, **kw}))
34
+ return res
35
+
36
+ async def invalidate(self, memory_id: str, **kw: Any) -> Dict[str, Any]:
37
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_invalidate", _clean({"memory_id": memory_id, **kw}))
38
+ return res
39
+
40
+ async def blame(self, run_id: str, **kw: Any) -> Dict[str, Any]:
41
+ """Per-run citation attribution: which memories a run cited. Pass a run_id (ULID)."""
42
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_blame", _clean({"run_id": run_id, **kw}))
43
+ return res
44
+
45
+ async def proposals(self, **kw: Any) -> Dict[str, Any]:
46
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_proposals", _clean(kw))
47
+ return res
48
+
49
+ async def conflicts(self, **kw: Any) -> Dict[str, Any]:
50
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_memory_conflicts", _clean(kw))
51
+ return res
52
+
53
+ async def conflict_resolve(self, **kw: Any) -> Dict[str, Any]:
54
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_conflict_resolve", _clean(kw))
55
+ return res
56
+
57
+ async def prune(self, **kw: Any) -> Dict[str, Any]:
58
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_prune", _clean(kw))
59
+ return res
60
+
61
+
62
+ class _AsyncConfig:
63
+ def __init__(self, t: Any) -> None:
64
+ self._t = t
65
+
66
+ async def show(self, **kw: Any) -> Dict[str, Any]:
67
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_config_show", _clean(kw))
68
+ return res
69
+
70
+
71
+ class _AsyncModels:
72
+ def __init__(self, t: Any) -> None:
73
+ self._t = t
74
+
75
+ async def list(self, **kw: Any) -> Dict[str, Any]:
76
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_model_list", _clean(kw))
77
+ return res
78
+
79
+
80
+ class _AsyncBenchmark:
81
+ def __init__(self, t: Any) -> None:
82
+ self._t = t
83
+
84
+ async def context(self, query: str, **kw: Any) -> Dict[str, Any]:
85
+ res: Dict[str, Any] = await self._t.call("kimetsu_benchmark_context", _clean({"query": query, **kw}))
86
+ return res
87
+
88
+ async def record_outcome(self, task: str, **kw: Any) -> Dict[str, Any]:
89
+ res: Dict[str, Any] = await self._t.call("kimetsu_benchmark_record_outcome", _clean({"task": task, **kw}))
90
+ return res
91
+
92
+
93
+ class AsyncKimetsuClient:
94
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
95
+ repo: Optional[str] = None, *, timeout: float = 30.0,
96
+ transport: Optional[Any] = None) -> None:
97
+ if transport is not None:
98
+ self._t = transport
99
+ else:
100
+ base_url = base_url or os.environ["KIMETSU_REMOTE_URL"]
101
+ token = token or os.environ["KIMETSU_REMOTE_TOKEN"]
102
+ repo = repo or os.environ["KIMETSU_REMOTE_REPO"]
103
+ self._t = AsyncTransport(base_url, token, repo, timeout=timeout)
104
+ self.memory = _AsyncMemory(self._t)
105
+ self.config = _AsyncConfig(self._t)
106
+ self.models = _AsyncModels(self._t)
107
+ self.benchmark = _AsyncBenchmark(self._t)
108
+
109
+ async def context(self, query: str, *, tags: Optional[List[str]] = None, **kw: Any) -> Context:
110
+ return Context.model_validate(await self._t.call("kimetsu_brain_context", _clean({"query": query, "tags": tags, **kw})))
111
+
112
+ async def record(self, lesson: str, *, tags: List[str],
113
+ kind: Optional[str] = None, **kw: Any) -> Dict[str, Any]:
114
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_record", _clean({"lesson": lesson, "tags": tags, "kind": kind, **kw}))
115
+ return res
116
+
117
+ async def status(self) -> Status:
118
+ return Status.model_validate(await self._t.call("kimetsu_brain_status", {}))
119
+
120
+ async def insights(self) -> Dict[str, Any]:
121
+ res: Dict[str, Any] = await self._t.call("kimetsu_brain_insights", {})
122
+ return res
123
+
124
+ async def aclose(self) -> None:
125
+ if hasattr(self._t, "aclose"):
126
+ await self._t.aclose()
127
+
128
+ async def __aenter__(self) -> "AsyncKimetsuClient":
129
+ return self
130
+
131
+ async def __aexit__(self, *exc: Any) -> None:
132
+ await self.aclose()
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+ import os
4
+ from ._transport import SyncTransport
5
+ from .models import Context, Memory, Status
6
+
7
+
8
+ def _clean(d: Dict[str, Any]) -> Dict[str, Any]:
9
+ return {k: v for k, v in d.items() if v is not None}
10
+
11
+
12
+ class _Memory:
13
+ def __init__(self, t: Any) -> None:
14
+ self._t = t
15
+
16
+ def search(self, query: str, **kw: Any) -> Dict[str, Any]:
17
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_search", _clean({"query": query, **kw}))
18
+ return res
19
+
20
+ def add(self, text: str, *, scope: str, kind: Optional[str] = None, **kw: Any) -> Memory:
21
+ return Memory.model_validate(
22
+ self._t.call("kimetsu_brain_memory_add", _clean({"text": text, "scope": scope, "kind": kind, **kw}))
23
+ )
24
+
25
+ def list(self, **kw: Any) -> Dict[str, Any]:
26
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_list", _clean(kw))
27
+ return res
28
+
29
+ def top(self, **kw: Any) -> Dict[str, Any]:
30
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_top", _clean(kw))
31
+ return res
32
+
33
+ def accept(self, proposal_id: str, **kw: Any) -> Dict[str, Any]:
34
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_accept", _clean({"proposal_id": proposal_id, **kw}))
35
+ return res
36
+
37
+ def reject(self, proposal_id: str, **kw: Any) -> Dict[str, Any]:
38
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_reject", _clean({"proposal_id": proposal_id, **kw}))
39
+ return res
40
+
41
+ def invalidate(self, memory_id: str, **kw: Any) -> Dict[str, Any]:
42
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_invalidate", _clean({"memory_id": memory_id, **kw}))
43
+ return res
44
+
45
+ def blame(self, run_id: str, **kw: Any) -> Dict[str, Any]:
46
+ """Per-run citation attribution: which memories a run cited. Pass a run_id (ULID)."""
47
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_blame", _clean({"run_id": run_id, **kw}))
48
+ return res
49
+
50
+ def proposals(self, **kw: Any) -> Dict[str, Any]:
51
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_proposals", _clean(kw))
52
+ return res
53
+
54
+ def conflicts(self, **kw: Any) -> Dict[str, Any]:
55
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_memory_conflicts", _clean(kw))
56
+ return res
57
+
58
+ def conflict_resolve(self, **kw: Any) -> Dict[str, Any]:
59
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_conflict_resolve", _clean(kw))
60
+ return res
61
+
62
+ def prune(self, **kw: Any) -> Dict[str, Any]:
63
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_prune", _clean(kw))
64
+ return res
65
+
66
+
67
+ class _Config:
68
+ def __init__(self, t: Any) -> None:
69
+ self._t = t
70
+
71
+ def show(self, **kw: Any) -> Dict[str, Any]:
72
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_config_show", _clean(kw))
73
+ return res
74
+
75
+
76
+ class _Models:
77
+ def __init__(self, t: Any) -> None:
78
+ self._t = t
79
+
80
+ def list(self, **kw: Any) -> Dict[str, Any]:
81
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_model_list", _clean(kw))
82
+ return res
83
+
84
+
85
+ class _Benchmark:
86
+ def __init__(self, t: Any) -> None:
87
+ self._t = t
88
+
89
+ def context(self, query: str, **kw: Any) -> Dict[str, Any]:
90
+ res: Dict[str, Any] = self._t.call("kimetsu_benchmark_context", _clean({"query": query, **kw}))
91
+ return res
92
+
93
+ def record_outcome(self, task: str, **kw: Any) -> Dict[str, Any]:
94
+ res: Dict[str, Any] = self._t.call("kimetsu_benchmark_record_outcome", _clean({"task": task, **kw}))
95
+ return res
96
+
97
+
98
+ class KimetsuClient:
99
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
100
+ repo: Optional[str] = None, *, timeout: float = 30.0,
101
+ transport: Optional[Any] = None) -> None:
102
+ if transport is not None:
103
+ self._t = transport
104
+ else:
105
+ base_url = base_url or os.environ["KIMETSU_REMOTE_URL"]
106
+ token = token or os.environ["KIMETSU_REMOTE_TOKEN"]
107
+ repo = repo or os.environ["KIMETSU_REMOTE_REPO"]
108
+ self._t = SyncTransport(base_url, token, repo, timeout=timeout)
109
+ self.memory = _Memory(self._t)
110
+ self.config = _Config(self._t)
111
+ self.models = _Models(self._t)
112
+ self.benchmark = _Benchmark(self._t)
113
+
114
+ def context(self, query: str, *, tags: Optional[List[str]] = None, **kw: Any) -> Context:
115
+ return Context.model_validate(
116
+ self._t.call("kimetsu_brain_context", _clean({"query": query, "tags": tags, **kw}))
117
+ )
118
+
119
+ def record(self, lesson: str, *, tags: List[str],
120
+ kind: Optional[str] = None, **kw: Any) -> Dict[str, Any]:
121
+ res: Dict[str, Any] = self._t.call(
122
+ "kimetsu_brain_record", _clean({"lesson": lesson, "tags": tags, "kind": kind, **kw})
123
+ )
124
+ return res
125
+
126
+ def status(self) -> Status:
127
+ return Status.model_validate(self._t.call("kimetsu_brain_status", {}))
128
+
129
+ def insights(self) -> Dict[str, Any]:
130
+ res: Dict[str, Any] = self._t.call("kimetsu_brain_insights", {})
131
+ return res
132
+
133
+ def close(self) -> None:
134
+ if hasattr(self._t, "close"):
135
+ self._t.close()
136
+
137
+ def __enter__(self) -> "KimetsuClient":
138
+ return self
139
+
140
+ def __exit__(self, *exc: Any) -> None:
141
+ self.close()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+
4
+
5
+ class KimetsuError(Exception):
6
+ """Base class for all Kimetsu SDK errors."""
7
+
8
+
9
+ class KimetsuAuthError(KimetsuError):
10
+ """HTTP 401 (missing/invalid token) or 403 (wrong repo / scope)."""
11
+
12
+
13
+ class KimetsuRateLimitError(KimetsuError):
14
+ """HTTP 429."""
15
+
16
+ def __init__(self, message: str, *, retry_after: Optional[float] = None) -> None:
17
+ super().__init__(message)
18
+ self.retry_after = retry_after
19
+
20
+
21
+ class KimetsuToolError(KimetsuError):
22
+ """A JSON-RPC `error` object returned in the MCP envelope."""
23
+
24
+ def __init__(self, message: str, *, code: Optional[int] = None) -> None:
25
+ super().__init__(message)
26
+ self.code = code
27
+ self.message = message
28
+
29
+
30
+ class KimetsuProtocolError(KimetsuError):
31
+ """Handshake failure or malformed JSON-RPC envelope."""
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class _Base(BaseModel):
7
+ model_config = ConfigDict(extra="allow")
8
+
9
+
10
+ class Memory(_Base):
11
+ id: Optional[str] = None
12
+ text: Optional[str] = None
13
+ tags: Optional[List[str]] = None
14
+
15
+
16
+ class Context(_Base):
17
+ capsules: List[Dict[str, Any]] = []
18
+
19
+
20
+ class Status(_Base):
21
+ initialized: Optional[bool] = None
22
+ accepted: Optional[int] = None
File without changes
@@ -0,0 +1,35 @@
1
+ import json
2
+ import httpx
3
+ import pytest
4
+ import respx
5
+ from kimetsu import AsyncKimetsuClient
6
+
7
+
8
+ @respx.mock
9
+ @pytest.mark.asyncio
10
+ async def test_async_context_matches_sync_wire():
11
+ route = respx.post("https://brain.test/mcp/web").mock(side_effect=[
12
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05"}}),
13
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 2, "result": {"capsules": []}}),
14
+ ])
15
+ async with AsyncKimetsuClient("https://brain.test", "tok", "web") as c:
16
+ ctx = await c.context("q", tags=["a"])
17
+ assert ctx.capsules == []
18
+ sent = route.calls.last.request
19
+ assert sent.headers["authorization"] == "Bearer tok"
20
+ assert json.loads(sent.content)["params"] == {
21
+ "name": "kimetsu_brain_context",
22
+ "arguments": {"query": "q", "tags": ["a"]},
23
+ }
24
+
25
+
26
+ @respx.mock
27
+ @pytest.mark.asyncio
28
+ async def test_async_memory_add_maps_and_wraps():
29
+ respx.post("https://brain.test/mcp/web").mock(side_effect=[
30
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05"}}),
31
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 2, "result": {"id": "01ABC", "text": "m"}}),
32
+ ])
33
+ async with AsyncKimetsuClient("https://brain.test", "tok", "web") as c:
34
+ m = await c.memory.add("m", scope="project")
35
+ assert m.id == "01ABC"
@@ -0,0 +1,44 @@
1
+ import pytest
2
+
3
+ from kimetsu import KimetsuClient
4
+
5
+
6
+ class FakeTransport:
7
+ def __init__(self):
8
+ self.calls = []
9
+
10
+ def call(self, name, arguments):
11
+ self.calls.append((name, arguments))
12
+ return {"echo": name}
13
+
14
+
15
+ def test_core_methods_map_to_tools():
16
+ t = FakeTransport()
17
+ c = KimetsuClient(transport=t)
18
+ c.context("how to migrate", tags=["db"])
19
+ c.record("use just migrate", tags=["db"], kind="semantic_operator")
20
+ c.status()
21
+ c.insights()
22
+ names = [n for n, _ in t.calls]
23
+ assert names == ["kimetsu_brain_context", "kimetsu_brain_record",
24
+ "kimetsu_brain_status", "kimetsu_brain_insights"]
25
+ assert t.calls[0][1] == {"query": "how to migrate", "tags": ["db"]}
26
+
27
+
28
+ def test_env_fallback(monkeypatch):
29
+ monkeypatch.setenv("KIMETSU_REMOTE_URL", "https://b.test")
30
+ monkeypatch.setenv("KIMETSU_REMOTE_TOKEN", "tok")
31
+ monkeypatch.setenv("KIMETSU_REMOTE_REPO", "web")
32
+ c = KimetsuClient() # must not raise
33
+ assert c is not None
34
+
35
+
36
+ def test_required_args_enforced_by_signature():
37
+ t = FakeTransport()
38
+ c = KimetsuClient(transport=t)
39
+ with pytest.raises(TypeError):
40
+ c.record("no tags") # tags is required
41
+ with pytest.raises(TypeError):
42
+ c.memory.add("no scope") # scope is required
43
+ with pytest.raises(TypeError):
44
+ c.benchmark.record_outcome() # task is required
@@ -0,0 +1,16 @@
1
+ from kimetsu.errors import (
2
+ KimetsuError, KimetsuAuthError, KimetsuRateLimitError,
3
+ KimetsuToolError, KimetsuProtocolError,
4
+ )
5
+
6
+ def test_hierarchy():
7
+ for cls in (KimetsuAuthError, KimetsuRateLimitError, KimetsuToolError, KimetsuProtocolError):
8
+ assert issubclass(cls, KimetsuError)
9
+
10
+ def test_rate_limit_carries_retry_after():
11
+ e = KimetsuRateLimitError("slow down", retry_after=1.5)
12
+ assert e.retry_after == 1.5
13
+
14
+ def test_tool_error_carries_code():
15
+ e = KimetsuToolError("nope", code=-32601)
16
+ assert e.code == -32601 and str(e) == "nope"
@@ -0,0 +1,40 @@
1
+ from kimetsu import KimetsuClient
2
+
3
+
4
+ class FakeTransport:
5
+ def __init__(self):
6
+ self.calls = []
7
+
8
+ def call(self, name, arguments):
9
+ self.calls.append((name, arguments))
10
+ return {}
11
+
12
+
13
+ def test_memory_methods_map_to_tools():
14
+ t = FakeTransport()
15
+ c = KimetsuClient(transport=t)
16
+ c.memory.search("q")
17
+ c.memory.add("m", scope="project", kind="fact")
18
+ c.memory.list()
19
+ c.memory.top()
20
+ c.memory.accept("prop1")
21
+ c.memory.reject("prop2")
22
+ c.memory.invalidate("mem3")
23
+ c.memory.blame("run4")
24
+ c.memory.proposals()
25
+ c.memory.conflicts()
26
+ c.memory.conflict_resolve()
27
+ c.memory.prune()
28
+ assert [n for n, _ in t.calls] == [
29
+ "kimetsu_brain_memory_search", "kimetsu_brain_memory_add",
30
+ "kimetsu_brain_memory_list", "kimetsu_brain_memory_top",
31
+ "kimetsu_brain_memory_accept", "kimetsu_brain_memory_reject",
32
+ "kimetsu_brain_memory_invalidate", "kimetsu_brain_memory_blame",
33
+ "kimetsu_brain_memory_proposals", "kimetsu_brain_memory_conflicts",
34
+ "kimetsu_brain_conflict_resolve", "kimetsu_brain_prune",
35
+ ]
36
+ assert t.calls[1][1] == {"text": "m", "scope": "project", "kind": "fact"}
37
+ assert t.calls[4][1] == {"proposal_id": "prop1"} # accept
38
+ assert t.calls[5][1] == {"proposal_id": "prop2"} # reject
39
+ assert t.calls[6][1] == {"memory_id": "mem3"} # invalidate
40
+ assert t.calls[7][1] == {"run_id": "run4"} # blame
@@ -0,0 +1,10 @@
1
+ from kimetsu.models import Context, Status, Memory
2
+
3
+
4
+ def test_models_parse_and_tolerate_extra():
5
+ ctx = Context.model_validate({"capsules": [{"text": "x"}], "note": "unexpected"})
6
+ assert ctx.capsules[0]["text"] == "x"
7
+ st = Status.model_validate({"initialized": True, "accepted": 5})
8
+ assert st.initialized is True and st.accepted == 5
9
+ m = Memory.model_validate({"id": "01ABC", "text": "lesson"})
10
+ assert m.id == "01ABC"
@@ -0,0 +1,25 @@
1
+ from kimetsu import KimetsuClient
2
+
3
+
4
+ class FakeTransport:
5
+ def __init__(self):
6
+ self.calls = []
7
+
8
+ def call(self, name, arguments):
9
+ self.calls.append((name, arguments))
10
+ return {}
11
+
12
+
13
+ def test_config_models_benchmark():
14
+ t = FakeTransport()
15
+ c = KimetsuClient(transport=t)
16
+ c.config.show()
17
+ c.models.list()
18
+ c.benchmark.context("term-bench task")
19
+ c.benchmark.record_outcome(task="t1", passed=True)
20
+ assert [n for n, _ in t.calls] == [
21
+ "kimetsu_brain_config_show", "kimetsu_brain_model_list",
22
+ "kimetsu_benchmark_context", "kimetsu_benchmark_record_outcome",
23
+ ]
24
+ assert t.calls[2][1] == {"query": "term-bench task"}
25
+ assert t.calls[3][1] == {"task": "t1", "passed": True}
@@ -0,0 +1,4 @@
1
+ import kimetsu
2
+
3
+ def test_version_exposed():
4
+ assert isinstance(kimetsu.__version__, str)
@@ -0,0 +1,62 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from kimetsu._transport import build_call, parse_envelope, SyncTransport
6
+ from kimetsu.errors import KimetsuAuthError, KimetsuRateLimitError, KimetsuToolError
7
+
8
+
9
+ def test_build_call_shape():
10
+ body = build_call("kimetsu_brain_record", {"lesson": "x"}, request_id=7)
11
+ assert body == {"jsonrpc": "2.0", "id": 7, "method": "tools/call",
12
+ "params": {"name": "kimetsu_brain_record", "arguments": {"lesson": "x"}}}
13
+
14
+
15
+ def test_parse_result():
16
+ env = {"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}
17
+ assert parse_envelope(200, env, {}) == {"ok": True}
18
+
19
+
20
+ def test_parse_tool_error():
21
+ env = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "not available in remote mode"}}
22
+ with pytest.raises(KimetsuToolError) as ei:
23
+ parse_envelope(200, env, {})
24
+ assert ei.value.code == -32000
25
+
26
+
27
+ @pytest.mark.parametrize("code,exc", [(401, KimetsuAuthError), (403, KimetsuAuthError), (429, KimetsuRateLimitError)])
28
+ def test_parse_http_errors(code, exc):
29
+ with pytest.raises(exc):
30
+ parse_envelope(code, {}, {"retry-after": "2"} if code == 429 else {})
31
+
32
+
33
+ @respx.mock
34
+ def test_transport_call_hits_endpoint():
35
+ route = respx.post("https://brain.test/mcp/web").mock(side_effect=[
36
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {}}}), # initialize
37
+ httpx.Response(200, json={"jsonrpc": "2.0", "id": 2, "result": {"ok": True}}), # tools/call
38
+ ])
39
+ t = SyncTransport("https://brain.test", "tok_x", "web")
40
+ assert t.call("kimetsu_brain_status", {}) == {"ok": True}
41
+ assert route.calls.last.request.headers["authorization"] == "Bearer tok_x"
42
+
43
+
44
+ @respx.mock
45
+ def test_non_json_error_body_maps_to_typed_error():
46
+ # A 401 with an HTML gateway body must raise KimetsuAuthError, not a JSON decode error.
47
+ respx.post("https://brain.test/mcp/web").mock(
48
+ return_value=httpx.Response(401, text="<html>401 Unauthorized</html>")
49
+ )
50
+ t = SyncTransport("https://brain.test", "tok_x", "web")
51
+ with pytest.raises(KimetsuAuthError):
52
+ t.call("kimetsu_brain_status", {})
53
+
54
+
55
+ def test_retry_after_http_date_degrades_to_none():
56
+ from kimetsu.errors import KimetsuRateLimitError
57
+ try:
58
+ parse_envelope(429, {}, {"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"})
59
+ except KimetsuRateLimitError as e:
60
+ assert e.retry_after is None
61
+ else:
62
+ raise AssertionError("expected KimetsuRateLimitError")