crewai-mimir 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.
- crewai_mimir-0.1.0/.github/workflows/publish.yml +45 -0
- crewai_mimir-0.1.0/.github/workflows/test.yml +28 -0
- crewai_mimir-0.1.0/LICENSE +21 -0
- crewai_mimir-0.1.0/PKG-INFO +150 -0
- crewai_mimir-0.1.0/README.md +122 -0
- crewai_mimir-0.1.0/crewai_mimir/__init__.py +35 -0
- crewai_mimir-0.1.0/crewai_mimir/_client.py +252 -0
- crewai_mimir-0.1.0/crewai_mimir/py.typed +0 -0
- crewai_mimir-0.1.0/crewai_mimir/tools.py +210 -0
- crewai_mimir-0.1.0/pyproject.toml +42 -0
- crewai_mimir-0.1.0/tests/__init__.py +0 -0
- crewai_mimir-0.1.0/tests/test_smoke_real_binary.py +67 -0
- crewai_mimir-0.1.0/tests/test_tools.py +255 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: '3.12'
|
|
18
|
+
|
|
19
|
+
- name: Install build
|
|
20
|
+
run: pip install build==1.2.2.post1
|
|
21
|
+
|
|
22
|
+
- name: Build
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- uses: actions/upload-artifact@v4
|
|
26
|
+
with:
|
|
27
|
+
name: dist
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/p/crewai-mimir
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
- name: Publish to PyPI
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
16
|
+
python-version: ['3.10', '3.12']
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install
|
|
25
|
+
run: pip install -e ".[test]"
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: python -m pytest tests/ -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perseus Computing LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crewai-mimir
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mimir long-term memory as CrewAI tools — local-first, encrypted, persistent memory the agent can explicitly call.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Perseus-Computing-LLC/crewai-mimir
|
|
6
|
+
Project-URL: Repository, https://github.com/Perseus-Computing-LLC/crewai-mimir
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Perseus-Computing-LLC/crewai-mimir/issues
|
|
8
|
+
Project-URL: Mimir, https://github.com/Perseus-Computing-LLC/mimir
|
|
9
|
+
Author-email: Perseus Computing LLC <hermes@perseus.observer>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,crewai,llm,mcp,memory,mimir,tools
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: crewai>=0.80.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# crewai-mimir
|
|
30
|
+
|
|
31
|
+
**Long-term, local-first, encrypted memory for [CrewAI](https://crewai.com) agents — as explicit, agent-callable tools.**
|
|
32
|
+
|
|
33
|
+
`crewai-mimir` wraps [Mimir](https://github.com/Perseus-Computing-LLC/mimir) (an open-source, MIT-licensed persistent memory engine with 40+ MCP tools, FTS5 + dense hybrid search, and optional AES-256-GCM encryption) as standard CrewAI `BaseTool`s. Your agents get two first-class actions they can deliberately call:
|
|
34
|
+
|
|
35
|
+
- **`mimir_remember`** — persist a fact, decision, insight, or note that survives across runs.
|
|
36
|
+
- **`mimir_recall`** — search what was stored earlier.
|
|
37
|
+
|
|
38
|
+
### Why tools (and not CrewAI's built-in memory)?
|
|
39
|
+
|
|
40
|
+
CrewAI ships *implicit* memory (auto-captured short/long-term memory) and a generic MCP adapter. `crewai-mimir` is deliberately different: it exposes **explicit, controllable memory** the agent chooses to invoke, with a typed `args_schema` so the LLM sees exactly what each call needs. Use it when you want the agent to reason about *what* to remember and *when* to recall — backed by a durable, encryptable store you own on disk.
|
|
41
|
+
|
|
42
|
+
## Prerequisite: the `mimir` binary
|
|
43
|
+
|
|
44
|
+
The tools talk to a local `mimir` process over JSON-RPC (MCP stdio). You need the `mimir` binary on your `PATH` (or pass an absolute path).
|
|
45
|
+
|
|
46
|
+
Install it from the [Mimir repository](https://github.com/Perseus-Computing-LLC/mimir) (build from source, or grab a release). Verify:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mimir --version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tools spawn `mimir serve --db <db_path>` for you — you do **not** start it manually.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install crewai-mimir
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
(or, from source: `pip install -e ".[test]"`)
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from crewai import Agent, Crew, Task
|
|
66
|
+
from crewai_mimir import build_mimir_tools
|
|
67
|
+
|
|
68
|
+
# One shared mimir process backs both tools.
|
|
69
|
+
memory_tools = build_mimir_tools(db_path="~/.mimir/data/crew.db")
|
|
70
|
+
|
|
71
|
+
researcher = Agent(
|
|
72
|
+
role="Research Analyst",
|
|
73
|
+
goal="Answer questions, remembering durable facts for next time.",
|
|
74
|
+
backstory="You persist key findings to long-term memory and check it before answering.",
|
|
75
|
+
tools=memory_tools,
|
|
76
|
+
verbose=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
remember_task = Task(
|
|
80
|
+
description="Remember that the project deadline is 2026-08-15. Store it under key 'project-deadline'.",
|
|
81
|
+
expected_output="Confirmation the deadline was stored.",
|
|
82
|
+
agent=researcher,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
recall_task = Task(
|
|
86
|
+
description="What is the project deadline? Check your long-term memory.",
|
|
87
|
+
expected_output="The project deadline date.",
|
|
88
|
+
agent=researcher,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
crew = Crew(agents=[researcher], tasks=[remember_task, recall_task])
|
|
92
|
+
result = crew.kickoff()
|
|
93
|
+
print(result)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Using the tool classes directly
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from crewai_mimir import MimirRememberTool, MimirRecallTool, MimirClient
|
|
100
|
+
|
|
101
|
+
client = MimirClient(db_path="~/.mimir/data/crew.db") # one shared process
|
|
102
|
+
remember = MimirRememberTool(client=client)
|
|
103
|
+
recall = MimirRecallTool(client=client)
|
|
104
|
+
|
|
105
|
+
agent = Agent(..., tools=[remember, recall])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If you omit `client`, each tool lazily starts its own `mimir serve` on first use
|
|
109
|
+
(configurable via `db_path` and `mimir_binary`).
|
|
110
|
+
|
|
111
|
+
### Encryption at rest
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
tools = build_mimir_tools(
|
|
115
|
+
db_path="~/.mimir/data/crew.db",
|
|
116
|
+
encryption_key="~/.mimir/key.b64", # base64-encoded 32-byte AES-256-GCM key
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Tool reference
|
|
121
|
+
|
|
122
|
+
| Tool | Required args | Optional args |
|
|
123
|
+
|------|---------------|---------------|
|
|
124
|
+
| `mimir_remember` | `content`, `key` | `category` (default `insight`), `tags`, `importance` (0.0–1.0) |
|
|
125
|
+
| `mimir_recall` | `query` | `limit` (default 5), `category` |
|
|
126
|
+
|
|
127
|
+
Both return a JSON string. `mimir_recall` returns `{"query": ..., "results": [...]}`.
|
|
128
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
`MimirClient` spawns `mimir serve --db <path>`, performs the MCP `initialize`
|
|
132
|
+
handshake, and issues id-correlated JSON-RPC requests with a per-call timeout
|
|
133
|
+
over stdin/stdout. The client core is adapted from the proven
|
|
134
|
+
[`adk-mimir-memory`](https://github.com/Perseus-Computing-LLC/adk-mimir-memory)
|
|
135
|
+
package.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install -e ".[test]"
|
|
141
|
+
pytest -q
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Unit tests mock the `mimir` subprocess, so they run with no binary installed.
|
|
145
|
+
`tests/test_smoke_real_binary.py` runs an end-to-end round-trip against a real
|
|
146
|
+
`mimir` binary when one is found on `PATH` (otherwise it is skipped).
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT © 2026 Perseus Computing LLC. Mimir is MIT-licensed by Perseus Computing LLC.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# crewai-mimir
|
|
2
|
+
|
|
3
|
+
**Long-term, local-first, encrypted memory for [CrewAI](https://crewai.com) agents — as explicit, agent-callable tools.**
|
|
4
|
+
|
|
5
|
+
`crewai-mimir` wraps [Mimir](https://github.com/Perseus-Computing-LLC/mimir) (an open-source, MIT-licensed persistent memory engine with 40+ MCP tools, FTS5 + dense hybrid search, and optional AES-256-GCM encryption) as standard CrewAI `BaseTool`s. Your agents get two first-class actions they can deliberately call:
|
|
6
|
+
|
|
7
|
+
- **`mimir_remember`** — persist a fact, decision, insight, or note that survives across runs.
|
|
8
|
+
- **`mimir_recall`** — search what was stored earlier.
|
|
9
|
+
|
|
10
|
+
### Why tools (and not CrewAI's built-in memory)?
|
|
11
|
+
|
|
12
|
+
CrewAI ships *implicit* memory (auto-captured short/long-term memory) and a generic MCP adapter. `crewai-mimir` is deliberately different: it exposes **explicit, controllable memory** the agent chooses to invoke, with a typed `args_schema` so the LLM sees exactly what each call needs. Use it when you want the agent to reason about *what* to remember and *when* to recall — backed by a durable, encryptable store you own on disk.
|
|
13
|
+
|
|
14
|
+
## Prerequisite: the `mimir` binary
|
|
15
|
+
|
|
16
|
+
The tools talk to a local `mimir` process over JSON-RPC (MCP stdio). You need the `mimir` binary on your `PATH` (or pass an absolute path).
|
|
17
|
+
|
|
18
|
+
Install it from the [Mimir repository](https://github.com/Perseus-Computing-LLC/mimir) (build from source, or grab a release). Verify:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
mimir --version
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The tools spawn `mimir serve --db <db_path>` for you — you do **not** start it manually.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install crewai-mimir
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(or, from source: `pip install -e ".[test]"`)
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from crewai import Agent, Crew, Task
|
|
38
|
+
from crewai_mimir import build_mimir_tools
|
|
39
|
+
|
|
40
|
+
# One shared mimir process backs both tools.
|
|
41
|
+
memory_tools = build_mimir_tools(db_path="~/.mimir/data/crew.db")
|
|
42
|
+
|
|
43
|
+
researcher = Agent(
|
|
44
|
+
role="Research Analyst",
|
|
45
|
+
goal="Answer questions, remembering durable facts for next time.",
|
|
46
|
+
backstory="You persist key findings to long-term memory and check it before answering.",
|
|
47
|
+
tools=memory_tools,
|
|
48
|
+
verbose=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
remember_task = Task(
|
|
52
|
+
description="Remember that the project deadline is 2026-08-15. Store it under key 'project-deadline'.",
|
|
53
|
+
expected_output="Confirmation the deadline was stored.",
|
|
54
|
+
agent=researcher,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
recall_task = Task(
|
|
58
|
+
description="What is the project deadline? Check your long-term memory.",
|
|
59
|
+
expected_output="The project deadline date.",
|
|
60
|
+
agent=researcher,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
crew = Crew(agents=[researcher], tasks=[remember_task, recall_task])
|
|
64
|
+
result = crew.kickoff()
|
|
65
|
+
print(result)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Using the tool classes directly
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from crewai_mimir import MimirRememberTool, MimirRecallTool, MimirClient
|
|
72
|
+
|
|
73
|
+
client = MimirClient(db_path="~/.mimir/data/crew.db") # one shared process
|
|
74
|
+
remember = MimirRememberTool(client=client)
|
|
75
|
+
recall = MimirRecallTool(client=client)
|
|
76
|
+
|
|
77
|
+
agent = Agent(..., tools=[remember, recall])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If you omit `client`, each tool lazily starts its own `mimir serve` on first use
|
|
81
|
+
(configurable via `db_path` and `mimir_binary`).
|
|
82
|
+
|
|
83
|
+
### Encryption at rest
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
tools = build_mimir_tools(
|
|
87
|
+
db_path="~/.mimir/data/crew.db",
|
|
88
|
+
encryption_key="~/.mimir/key.b64", # base64-encoded 32-byte AES-256-GCM key
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Tool reference
|
|
93
|
+
|
|
94
|
+
| Tool | Required args | Optional args |
|
|
95
|
+
|------|---------------|---------------|
|
|
96
|
+
| `mimir_remember` | `content`, `key` | `category` (default `insight`), `tags`, `importance` (0.0–1.0) |
|
|
97
|
+
| `mimir_recall` | `query` | `limit` (default 5), `category` |
|
|
98
|
+
|
|
99
|
+
Both return a JSON string. `mimir_recall` returns `{"query": ..., "results": [...]}`.
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
`MimirClient` spawns `mimir serve --db <path>`, performs the MCP `initialize`
|
|
104
|
+
handshake, and issues id-correlated JSON-RPC requests with a per-call timeout
|
|
105
|
+
over stdin/stdout. The client core is adapted from the proven
|
|
106
|
+
[`adk-mimir-memory`](https://github.com/Perseus-Computing-LLC/adk-mimir-memory)
|
|
107
|
+
package.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e ".[test]"
|
|
113
|
+
pytest -q
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Unit tests mock the `mimir` subprocess, so they run with no binary installed.
|
|
117
|
+
`tests/test_smoke_real_binary.py` runs an end-to-end round-trip against a real
|
|
118
|
+
`mimir` binary when one is found on `PATH` (otherwise it is skipped).
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT © 2026 Perseus Computing LLC. Mimir is MIT-licensed by Perseus Computing LLC.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""crewai-mimir: Mimir long-term memory as CrewAI tools.
|
|
2
|
+
|
|
3
|
+
Exposes explicit, agent-callable CrewAI tools that store and retrieve durable
|
|
4
|
+
memories in Mimir (github.com/Perseus-Computing-LLC/mimir), a local-first,
|
|
5
|
+
encrypted, persistent memory engine.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from crewai import Agent
|
|
10
|
+
from crewai_mimir import build_mimir_tools
|
|
11
|
+
|
|
12
|
+
agent = Agent(role="Researcher", goal="...", backstory="...",
|
|
13
|
+
tools=build_mimir_tools())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._client import MimirClient
|
|
17
|
+
from .tools import (
|
|
18
|
+
MimirRecallInput,
|
|
19
|
+
MimirRecallTool,
|
|
20
|
+
MimirRememberInput,
|
|
21
|
+
MimirRememberTool,
|
|
22
|
+
build_mimir_tools,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"MimirClient",
|
|
29
|
+
"MimirRememberTool",
|
|
30
|
+
"MimirRecallTool",
|
|
31
|
+
"MimirRememberInput",
|
|
32
|
+
"MimirRecallInput",
|
|
33
|
+
"build_mimir_tools",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Minimal Mimir MCP stdio client.
|
|
2
|
+
|
|
3
|
+
Mimir (github.com/Perseus-Computing-LLC/mimir) is an open-source (MIT)
|
|
4
|
+
local-first, encrypted, persistent memory engine exposing 40+ tools over the
|
|
5
|
+
Model Context Protocol. This module talks to the ``mimir`` binary via JSON-RPC
|
|
6
|
+
2.0 over stdin/stdout (the MCP stdio transport).
|
|
7
|
+
|
|
8
|
+
The client core (spawn subprocess, background stdout reader, id-correlated RPC
|
|
9
|
+
with timeout, MCP initialize handshake) is adapted from the proven
|
|
10
|
+
``Perseus-Computing-LLC/adk-mimir-memory`` package.
|
|
11
|
+
|
|
12
|
+
Requirements:
|
|
13
|
+
A ``mimir`` binary must be on ``$PATH`` or passed explicitly. Build from
|
|
14
|
+
source or install from https://github.com/Perseus-Computing-LLC/mimir.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
__all__ = ["MimirClient"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MimirClient:
|
|
32
|
+
"""Thread-safe JSON-RPC client for a ``mimir serve`` stdio subprocess.
|
|
33
|
+
|
|
34
|
+
The client spawns ``mimir serve --db <db_path>`` and performs the MCP
|
|
35
|
+
initialize handshake on construction. Call :meth:`call_tool` to invoke any
|
|
36
|
+
Mimir MCP tool by name.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
db_path: Filesystem path to the Mimir SQLite database.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
db_path: str = "~/.mimir/data/mimir.db",
|
|
45
|
+
mimir_binary: str = "mimir",
|
|
46
|
+
timeout_s: float = 30.0,
|
|
47
|
+
encryption_key: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initializes and starts the Mimir client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
db_path: Path to the Mimir SQLite database. Created if absent.
|
|
53
|
+
mimir_binary: Name or absolute path of the ``mimir`` executable.
|
|
54
|
+
timeout_s: Per-RPC response timeout, guarding against a hung server.
|
|
55
|
+
encryption_key: Optional path to an AES-256-GCM key file; enables
|
|
56
|
+
encryption at rest.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
RuntimeError: If the ``mimir`` binary cannot be found.
|
|
60
|
+
"""
|
|
61
|
+
self.db_path = os.path.expanduser(db_path)
|
|
62
|
+
self._timeout_s = timeout_s
|
|
63
|
+
|
|
64
|
+
if os.path.isabs(mimir_binary):
|
|
65
|
+
self._mimir_binary = mimir_binary
|
|
66
|
+
else:
|
|
67
|
+
resolved = shutil.which(mimir_binary)
|
|
68
|
+
if resolved is None:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"mimir binary not found on $PATH (looked for '{mimir_binary}'). "
|
|
71
|
+
"Install Mimir from https://github.com/Perseus-Computing-LLC/mimir "
|
|
72
|
+
"or pass the absolute path via mimir_binary=."
|
|
73
|
+
)
|
|
74
|
+
self._mimir_binary = resolved
|
|
75
|
+
|
|
76
|
+
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
|
|
77
|
+
|
|
78
|
+
cmd = [self._mimir_binary, "serve", "--db", self.db_path]
|
|
79
|
+
if encryption_key:
|
|
80
|
+
cmd += ["--encryption-key", os.path.expanduser(encryption_key)]
|
|
81
|
+
|
|
82
|
+
# stderr is discarded: nothing drains it, so a chatty server filling the
|
|
83
|
+
# OS pipe buffer would block on its stderr write while we wait on stdout
|
|
84
|
+
# (a two-pipe deadlock).
|
|
85
|
+
self._proc = subprocess.Popen(
|
|
86
|
+
cmd,
|
|
87
|
+
stdin=subprocess.PIPE,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.DEVNULL,
|
|
90
|
+
text=True,
|
|
91
|
+
)
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
self._request_id = 0
|
|
94
|
+
|
|
95
|
+
# Background reader: pump stdout lines into a queue so _rpc can wait with
|
|
96
|
+
# a timeout and correlate responses by id rather than block forever.
|
|
97
|
+
self._recv: queue.Queue = queue.Queue()
|
|
98
|
+
proc_stdout = self._proc.stdout
|
|
99
|
+
|
|
100
|
+
def _pump() -> None:
|
|
101
|
+
try:
|
|
102
|
+
for line in proc_stdout:
|
|
103
|
+
self._recv.put(line)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
finally:
|
|
107
|
+
self._recv.put(None) # EOF sentinel
|
|
108
|
+
|
|
109
|
+
self._reader = threading.Thread(target=_pump, daemon=True)
|
|
110
|
+
self._reader.start()
|
|
111
|
+
|
|
112
|
+
# MCP handshake: initialize, then the required initialized notification.
|
|
113
|
+
self._rpc(
|
|
114
|
+
"initialize",
|
|
115
|
+
{
|
|
116
|
+
"protocolVersion": "2024-11-05",
|
|
117
|
+
"capabilities": {},
|
|
118
|
+
"clientInfo": {"name": "crewai-mimir", "version": "0.1.0"},
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
self._notify("notifications/initialized", {})
|
|
122
|
+
|
|
123
|
+
atexit.register(self.close)
|
|
124
|
+
|
|
125
|
+
# -- lifecycle ----------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""Terminates the Mimir subprocess (idempotent)."""
|
|
129
|
+
proc = getattr(self, "_proc", None)
|
|
130
|
+
if proc is None:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
proc.terminate()
|
|
134
|
+
proc.wait(timeout=5)
|
|
135
|
+
except Exception:
|
|
136
|
+
try:
|
|
137
|
+
proc.kill()
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> "MimirClient":
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *exc) -> None:
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
# -- JSON-RPC core ------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _next_id(self) -> int:
|
|
150
|
+
self._request_id += 1
|
|
151
|
+
return self._request_id
|
|
152
|
+
|
|
153
|
+
def _rpc(self, method: str, params: object) -> dict:
|
|
154
|
+
"""Sends a JSON-RPC request and returns its ``result`` dict.
|
|
155
|
+
|
|
156
|
+
Holds the lock for the whole request/response exchange so pairs never
|
|
157
|
+
interleave, and honors ``timeout_s`` so a hung server cannot block the
|
|
158
|
+
caller forever.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
RuntimeError: On transport failure, RPC error, or timeout.
|
|
162
|
+
"""
|
|
163
|
+
with self._lock:
|
|
164
|
+
req_id = self._next_id()
|
|
165
|
+
req = {
|
|
166
|
+
"jsonrpc": "2.0",
|
|
167
|
+
"id": req_id,
|
|
168
|
+
"method": method,
|
|
169
|
+
"params": params,
|
|
170
|
+
}
|
|
171
|
+
payload = json.dumps(req, default=str)
|
|
172
|
+
try:
|
|
173
|
+
self._proc.stdin.write(payload + "\n")
|
|
174
|
+
self._proc.stdin.flush()
|
|
175
|
+
except (BrokenPipeError, OSError) as e:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"Mimir subprocess communication failed: {e}. "
|
|
178
|
+
"The mimir process may have crashed."
|
|
179
|
+
) from e
|
|
180
|
+
|
|
181
|
+
deadline = time.monotonic() + self._timeout_s
|
|
182
|
+
while True:
|
|
183
|
+
remaining = deadline - time.monotonic()
|
|
184
|
+
if remaining <= 0:
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"Mimir RPC '{method}' timed out after {self._timeout_s}s."
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
raw = self._recv.get(timeout=remaining)
|
|
190
|
+
except queue.Empty:
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"Mimir RPC '{method}' timed out after {self._timeout_s}s."
|
|
193
|
+
)
|
|
194
|
+
if raw is None:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
"Mimir subprocess closed its output (it may have crashed)."
|
|
197
|
+
)
|
|
198
|
+
raw = raw.strip()
|
|
199
|
+
if not raw:
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
resp = json.loads(raw)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
continue # non-JSON noise on stdout
|
|
205
|
+
if resp.get("id") != req_id:
|
|
206
|
+
continue # notification or a stale/other reply
|
|
207
|
+
if "error" in resp:
|
|
208
|
+
err = resp["error"]
|
|
209
|
+
raise RuntimeError(
|
|
210
|
+
f"Mimir RPC error [{err.get('code')}]: {err.get('message')}"
|
|
211
|
+
)
|
|
212
|
+
return resp.get("result", {})
|
|
213
|
+
|
|
214
|
+
def _notify(self, method: str, params: object) -> None:
|
|
215
|
+
"""Sends a JSON-RPC notification (no id, no response expected)."""
|
|
216
|
+
payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
|
|
217
|
+
with self._lock:
|
|
218
|
+
try:
|
|
219
|
+
self._proc.stdin.write(payload + "\n")
|
|
220
|
+
self._proc.stdin.flush()
|
|
221
|
+
except (BrokenPipeError, OSError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# -- public API ---------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def call_tool(self, name: str, arguments: dict) -> dict:
|
|
227
|
+
"""Calls a Mimir MCP tool and returns its structured result.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
name: The Mimir tool name, e.g. ``mimir_remember`` or
|
|
231
|
+
``mimir_recall``.
|
|
232
|
+
arguments: The tool arguments.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The tool's ``structuredContent`` if present, otherwise the parsed
|
|
236
|
+
text content, otherwise ``{}``.
|
|
237
|
+
"""
|
|
238
|
+
result = self._rpc("tools/call", {"name": name, "arguments": arguments})
|
|
239
|
+
sc = result.get("structuredContent")
|
|
240
|
+
if sc is not None:
|
|
241
|
+
return sc
|
|
242
|
+
content = result.get("content", [])
|
|
243
|
+
if content:
|
|
244
|
+
try:
|
|
245
|
+
return json.loads(content[0].get("text", "{}"))
|
|
246
|
+
except (json.JSONDecodeError, IndexError, KeyError, AttributeError):
|
|
247
|
+
# Surface raw text when it is not JSON.
|
|
248
|
+
try:
|
|
249
|
+
return {"text": content[0].get("text", "")}
|
|
250
|
+
except (IndexError, KeyError, AttributeError):
|
|
251
|
+
pass
|
|
252
|
+
return {}
|
|
File without changes
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""CrewAI tools that wrap the Mimir memory engine.
|
|
2
|
+
|
|
3
|
+
These are explicit, agent-callable tools (subclasses of ``crewai.tools.BaseTool``)
|
|
4
|
+
that let a CrewAI agent deliberately store and retrieve durable memories in
|
|
5
|
+
Mimir. Unlike CrewAI's built-in (implicit) memory or a generic MCP adapter,
|
|
6
|
+
these surface ``remember`` and ``recall`` as first-class actions the agent
|
|
7
|
+
chooses to invoke, with a typed ``args_schema`` so the LLM sees exactly what
|
|
8
|
+
each call needs.
|
|
9
|
+
|
|
10
|
+
Tools:
|
|
11
|
+
MimirRememberTool — store a fact/decision/note in Mimir.
|
|
12
|
+
MimirRecallTool — search Mimir for previously stored memories.
|
|
13
|
+
|
|
14
|
+
Both tools share a single :class:`~crewai_mimir._client.MimirClient` so one
|
|
15
|
+
``mimir serve`` subprocess backs the whole crew.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Optional, Type
|
|
22
|
+
|
|
23
|
+
from crewai.tools import BaseTool
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
from ._client import MimirClient
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"MimirRememberInput",
|
|
30
|
+
"MimirRecallInput",
|
|
31
|
+
"MimirRememberTool",
|
|
32
|
+
"MimirRecallTool",
|
|
33
|
+
"build_mimir_tools",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── args schemas ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MimirRememberInput(BaseModel):
|
|
41
|
+
"""Input schema for :class:`MimirRememberTool`."""
|
|
42
|
+
|
|
43
|
+
content: str = Field(
|
|
44
|
+
...,
|
|
45
|
+
description="The fact, decision, insight, or note to remember. Stored "
|
|
46
|
+
"verbatim and made searchable.",
|
|
47
|
+
)
|
|
48
|
+
key: str = Field(
|
|
49
|
+
...,
|
|
50
|
+
description="A short unique identifier for this memory within its "
|
|
51
|
+
"category, e.g. 'use-postgres-16'. Re-using a key updates that memory.",
|
|
52
|
+
)
|
|
53
|
+
category: str = Field(
|
|
54
|
+
default="insight",
|
|
55
|
+
description="Memory category: 'decision', 'architecture', 'convention', "
|
|
56
|
+
"'insight', or a custom label.",
|
|
57
|
+
)
|
|
58
|
+
tags: Optional[list[str]] = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Optional tags for cross-referencing this memory.",
|
|
61
|
+
)
|
|
62
|
+
importance: float = Field(
|
|
63
|
+
default=0.5,
|
|
64
|
+
ge=0.0,
|
|
65
|
+
le=1.0,
|
|
66
|
+
description="Initial importance 0.0-1.0; sets the starting decay score.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MimirRecallInput(BaseModel):
|
|
71
|
+
"""Input schema for :class:`MimirRecallTool`."""
|
|
72
|
+
|
|
73
|
+
query: str = Field(
|
|
74
|
+
...,
|
|
75
|
+
description="Search query. Keywords are OR'd together for broad recall.",
|
|
76
|
+
)
|
|
77
|
+
limit: int = Field(
|
|
78
|
+
default=5,
|
|
79
|
+
ge=1,
|
|
80
|
+
le=1000,
|
|
81
|
+
description="Maximum number of memories to return.",
|
|
82
|
+
)
|
|
83
|
+
category: Optional[str] = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
description="Optionally restrict the search to one category.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── tools ───────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MimirRememberTool(BaseTool):
|
|
93
|
+
"""Store a durable memory in Mimir.
|
|
94
|
+
|
|
95
|
+
Pass a shared :class:`MimirClient` (recommended, so all tools reuse one
|
|
96
|
+
``mimir serve`` process), or let the tool lazily start its own using
|
|
97
|
+
``db_path`` / ``mimir_binary``.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name: str = "mimir_remember"
|
|
101
|
+
description: str = (
|
|
102
|
+
"Persist a fact, decision, insight, or note to long-term memory (Mimir) "
|
|
103
|
+
"so it survives across sessions. Provide the content and a short unique "
|
|
104
|
+
"key. Use this whenever you learn something worth remembering later."
|
|
105
|
+
)
|
|
106
|
+
args_schema: Type[BaseModel] = MimirRememberInput
|
|
107
|
+
|
|
108
|
+
# Non-schema configuration (excluded from the LLM-facing args_schema).
|
|
109
|
+
client: Optional[MimirClient] = None
|
|
110
|
+
db_path: str = "~/.mimir/data/mimir.db"
|
|
111
|
+
mimir_binary: str = "mimir"
|
|
112
|
+
|
|
113
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
114
|
+
|
|
115
|
+
def _get_client(self) -> MimirClient:
|
|
116
|
+
if self.client is None:
|
|
117
|
+
self.client = MimirClient(
|
|
118
|
+
db_path=self.db_path, mimir_binary=self.mimir_binary
|
|
119
|
+
)
|
|
120
|
+
return self.client
|
|
121
|
+
|
|
122
|
+
def _run(
|
|
123
|
+
self,
|
|
124
|
+
content: str,
|
|
125
|
+
key: str,
|
|
126
|
+
category: str = "insight",
|
|
127
|
+
tags: Optional[list[str]] = None,
|
|
128
|
+
importance: float = 0.5,
|
|
129
|
+
) -> str:
|
|
130
|
+
client = self._get_client()
|
|
131
|
+
result = client.call_tool(
|
|
132
|
+
"mimir_remember",
|
|
133
|
+
{
|
|
134
|
+
"category": category,
|
|
135
|
+
"key": key,
|
|
136
|
+
"body_json": json.dumps({"content": content}),
|
|
137
|
+
"tags": tags or [],
|
|
138
|
+
"importance": importance,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
return json.dumps(
|
|
142
|
+
{"status": "remembered", "category": category, "key": key, "mimir": result}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MimirRecallTool(BaseTool):
|
|
147
|
+
"""Search Mimir for previously stored memories.
|
|
148
|
+
|
|
149
|
+
Pass a shared :class:`MimirClient` (recommended), or let the tool lazily
|
|
150
|
+
start its own using ``db_path`` / ``mimir_binary``.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
name: str = "mimir_recall"
|
|
154
|
+
description: str = (
|
|
155
|
+
"Search long-term memory (Mimir) for facts, decisions, or notes stored "
|
|
156
|
+
"earlier. Returns the best-matching memories. Use this before answering "
|
|
157
|
+
"to check what you already know."
|
|
158
|
+
)
|
|
159
|
+
args_schema: Type[BaseModel] = MimirRecallInput
|
|
160
|
+
|
|
161
|
+
client: Optional[MimirClient] = None
|
|
162
|
+
db_path: str = "~/.mimir/data/mimir.db"
|
|
163
|
+
mimir_binary: str = "mimir"
|
|
164
|
+
|
|
165
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
166
|
+
|
|
167
|
+
def _get_client(self) -> MimirClient:
|
|
168
|
+
if self.client is None:
|
|
169
|
+
self.client = MimirClient(
|
|
170
|
+
db_path=self.db_path, mimir_binary=self.mimir_binary
|
|
171
|
+
)
|
|
172
|
+
return self.client
|
|
173
|
+
|
|
174
|
+
def _run(
|
|
175
|
+
self,
|
|
176
|
+
query: str,
|
|
177
|
+
limit: int = 5,
|
|
178
|
+
category: Optional[str] = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
client = self._get_client()
|
|
181
|
+
arguments: dict = {"query": query, "limit": limit}
|
|
182
|
+
if category:
|
|
183
|
+
arguments["category"] = category
|
|
184
|
+
result = client.call_tool("mimir_recall", arguments)
|
|
185
|
+
items = result.get("items", result) if isinstance(result, dict) else result
|
|
186
|
+
return json.dumps({"query": query, "results": items})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_mimir_tools(
|
|
190
|
+
db_path: str = "~/.mimir/data/mimir.db",
|
|
191
|
+
mimir_binary: str = "mimir",
|
|
192
|
+
encryption_key: Optional[str] = None,
|
|
193
|
+
) -> list[BaseTool]:
|
|
194
|
+
"""Convenience: build remember+recall tools sharing one Mimir process.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
db_path: Path to the Mimir SQLite database.
|
|
198
|
+
mimir_binary: Name or absolute path of the ``mimir`` executable.
|
|
199
|
+
encryption_key: Optional path to an AES-256-GCM key file.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
``[MimirRememberTool, MimirRecallTool]`` backed by a single client.
|
|
203
|
+
"""
|
|
204
|
+
client = MimirClient(
|
|
205
|
+
db_path=db_path, mimir_binary=mimir_binary, encryption_key=encryption_key
|
|
206
|
+
)
|
|
207
|
+
return [
|
|
208
|
+
MimirRememberTool(client=client),
|
|
209
|
+
MimirRecallTool(client=client),
|
|
210
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crewai-mimir"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Perseus Computing LLC", email = "hermes@perseus.observer" },
|
|
10
|
+
]
|
|
11
|
+
description = "Mimir long-term memory as CrewAI tools — local-first, encrypted, persistent memory the agent can explicitly call."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = ["crewai", "mimir", "memory", "agents", "mcp", "tools", "llm"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"crewai>=0.80.0",
|
|
29
|
+
"pydantic>=2.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest>=7.0"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/Perseus-Computing-LLC/crewai-mimir"
|
|
37
|
+
Repository = "https://github.com/Perseus-Computing-LLC/crewai-mimir"
|
|
38
|
+
"Bug Tracker" = "https://github.com/Perseus-Computing-LLC/crewai-mimir/issues"
|
|
39
|
+
Mimir = "https://github.com/Perseus-Computing-LLC/mimir"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["crewai_mimir"]
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Optional smoke test against a REAL ``mimir`` binary.
|
|
2
|
+
|
|
3
|
+
Skipped automatically when no ``mimir`` binary is on $PATH. When present, it
|
|
4
|
+
starts a real ``mimir serve`` subprocess against a temp DB and round-trips a
|
|
5
|
+
remember -> recall through the actual MCP stdio transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from crewai_mimir import MimirClient, MimirRecallTool, MimirRememberTool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_mimir() -> str | None:
|
|
20
|
+
"""Locate a runnable mimir binary.
|
|
21
|
+
|
|
22
|
+
Prefers $MIMIR_BINARY, then PATH. On Windows the released binary may lack a
|
|
23
|
+
``.exe`` extension, so shutil.which() misses it; also probe ~/bin/mimir.
|
|
24
|
+
"""
|
|
25
|
+
env = os.environ.get("MIMIR_BINARY")
|
|
26
|
+
if env and os.path.exists(env):
|
|
27
|
+
return env
|
|
28
|
+
found = shutil.which("mimir")
|
|
29
|
+
if found:
|
|
30
|
+
return found
|
|
31
|
+
candidate = os.path.expanduser("~/bin/mimir")
|
|
32
|
+
if os.path.exists(candidate):
|
|
33
|
+
return candidate
|
|
34
|
+
candidate_exe = os.path.expanduser("~/bin/mimir.exe")
|
|
35
|
+
if os.path.exists(candidate_exe):
|
|
36
|
+
return candidate_exe
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_MIMIR = _find_mimir()
|
|
41
|
+
|
|
42
|
+
pytestmark = pytest.mark.skipif(
|
|
43
|
+
_MIMIR is None,
|
|
44
|
+
reason="real mimir binary not found (set MIMIR_BINARY or put mimir on PATH)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_real_remember_recall(tmp_path):
|
|
49
|
+
db = tmp_path / "mimir.db"
|
|
50
|
+
client = MimirClient(db_path=str(db), mimir_binary=_MIMIR)
|
|
51
|
+
try:
|
|
52
|
+
remember = MimirRememberTool(client=client)
|
|
53
|
+
recall = MimirRecallTool(client=client)
|
|
54
|
+
|
|
55
|
+
remember._run(
|
|
56
|
+
content="The capital of crewai-mimir testing is verification.",
|
|
57
|
+
key="smoke-fact",
|
|
58
|
+
category="insight",
|
|
59
|
+
tags=["smoke"],
|
|
60
|
+
)
|
|
61
|
+
out = json.loads(recall._run(query="verification capital", limit=5))
|
|
62
|
+
assert any(
|
|
63
|
+
"verification" in str(r.get("content", "")).lower()
|
|
64
|
+
for r in out["results"]
|
|
65
|
+
), f"expected memory not recalled: {out}"
|
|
66
|
+
finally:
|
|
67
|
+
client.close()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Tests for crewai-mimir tools against a fake Mimir MCP stdio server.
|
|
2
|
+
|
|
3
|
+
No real ``mimir`` binary is required: ``subprocess.Popen`` is monkeypatched to
|
|
4
|
+
return an in-process fake that speaks JSON-RPC 2.0 over fake stdin/stdout pipes,
|
|
5
|
+
so these exercise the real RPC, handshake, and tool ``_run`` code paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import queue
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
import crewai_mimir._client as client_mod
|
|
17
|
+
from crewai_mimir import (
|
|
18
|
+
MimirClient,
|
|
19
|
+
MimirRecallInput,
|
|
20
|
+
MimirRecallTool,
|
|
21
|
+
MimirRememberInput,
|
|
22
|
+
MimirRememberTool,
|
|
23
|
+
build_mimir_tools,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Fake Mimir MCP stdio server ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeStdin:
|
|
31
|
+
def __init__(self, on_line):
|
|
32
|
+
self._on_line = on_line
|
|
33
|
+
|
|
34
|
+
def write(self, s):
|
|
35
|
+
for line in s.splitlines():
|
|
36
|
+
if line.strip():
|
|
37
|
+
self._on_line(line)
|
|
38
|
+
|
|
39
|
+
def flush(self):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def close(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _FakeStdout:
|
|
47
|
+
"""Blocking, iterable line source fed by the fake server."""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self._q = queue.Queue()
|
|
51
|
+
|
|
52
|
+
def put(self, line):
|
|
53
|
+
self._q.put(line)
|
|
54
|
+
|
|
55
|
+
def __iter__(self):
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __next__(self):
|
|
59
|
+
item = self._q.get()
|
|
60
|
+
if item is None:
|
|
61
|
+
raise StopIteration
|
|
62
|
+
return item
|
|
63
|
+
|
|
64
|
+
def close(self):
|
|
65
|
+
self._q.put(None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FakeMimir:
|
|
69
|
+
"""Minimal Popen-compatible fake of the Mimir MCP stdio server."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, cmd=None, **kwargs):
|
|
72
|
+
self.cmd = cmd
|
|
73
|
+
self.store = {} # (category, key) -> body dict
|
|
74
|
+
self.stdout = _FakeStdout()
|
|
75
|
+
self.stdin = _FakeStdin(self._handle)
|
|
76
|
+
self._alive = True
|
|
77
|
+
|
|
78
|
+
# -- Popen surface used by MimirClient --
|
|
79
|
+
def terminate(self):
|
|
80
|
+
self._alive = False
|
|
81
|
+
self.stdout.close()
|
|
82
|
+
|
|
83
|
+
def wait(self, timeout=None):
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
def kill(self):
|
|
87
|
+
self._alive = False
|
|
88
|
+
|
|
89
|
+
# -- request dispatch --
|
|
90
|
+
def _reply(self, req_id, result):
|
|
91
|
+
self.stdout.put(json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}))
|
|
92
|
+
|
|
93
|
+
def _handle(self, line):
|
|
94
|
+
req = json.loads(line)
|
|
95
|
+
method = req.get("method")
|
|
96
|
+
req_id = req.get("id")
|
|
97
|
+
if req_id is None:
|
|
98
|
+
return # notification
|
|
99
|
+
if method == "initialize":
|
|
100
|
+
self._reply(req_id, {"protocolVersion": "2024-11-05"})
|
|
101
|
+
elif method == "tools/call":
|
|
102
|
+
params = req.get("params", {})
|
|
103
|
+
name = params.get("name")
|
|
104
|
+
args = params.get("arguments", {})
|
|
105
|
+
self._reply(req_id, self._call_tool(name, args))
|
|
106
|
+
else:
|
|
107
|
+
self._reply(req_id, {})
|
|
108
|
+
|
|
109
|
+
def _call_tool(self, name, args):
|
|
110
|
+
if name == "mimir_remember":
|
|
111
|
+
key = (args.get("category"), args.get("key"))
|
|
112
|
+
body = json.loads(args.get("body_json", "{}"))
|
|
113
|
+
self.store[key] = {**args, "body": body}
|
|
114
|
+
return {"structuredContent": {"stored": True, "key": args.get("key")}}
|
|
115
|
+
if name == "mimir_recall":
|
|
116
|
+
query = (args.get("query") or "").lower()
|
|
117
|
+
cat = args.get("category")
|
|
118
|
+
limit = args.get("limit", 10)
|
|
119
|
+
items = []
|
|
120
|
+
for (category, key), entry in self.store.items():
|
|
121
|
+
if cat and category != cat:
|
|
122
|
+
continue
|
|
123
|
+
content = str(entry["body"].get("content", "")).lower()
|
|
124
|
+
# OR semantics: any query token present in content.
|
|
125
|
+
if any(tok and tok in content for tok in query.split()):
|
|
126
|
+
items.append(
|
|
127
|
+
{"key": key, "category": category, **entry["body"]}
|
|
128
|
+
)
|
|
129
|
+
return {"structuredContent": {"items": items[:limit]}}
|
|
130
|
+
return {"structuredContent": {}}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.fixture
|
|
134
|
+
def fake_popen(monkeypatch):
|
|
135
|
+
"""Patch subprocess.Popen + shutil.which so MimirClient uses FakeMimir."""
|
|
136
|
+
created = {}
|
|
137
|
+
|
|
138
|
+
def _fake_popen(cmd, **kwargs):
|
|
139
|
+
fm = FakeMimir(cmd=cmd, **kwargs)
|
|
140
|
+
created["proc"] = fm
|
|
141
|
+
return fm
|
|
142
|
+
|
|
143
|
+
monkeypatch.setattr(client_mod.subprocess, "Popen", _fake_popen)
|
|
144
|
+
monkeypatch.setattr(client_mod.shutil, "which", lambda b: "/fake/mimir")
|
|
145
|
+
return created
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── args_schema validation (no client needed) ────────────────────────────────
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_remember_input_requires_content_and_key():
|
|
152
|
+
with pytest.raises(ValidationError):
|
|
153
|
+
MimirRememberInput(key="k") # missing content
|
|
154
|
+
with pytest.raises(ValidationError):
|
|
155
|
+
MimirRememberInput(content="c") # missing key
|
|
156
|
+
ok = MimirRememberInput(content="c", key="k")
|
|
157
|
+
assert ok.category == "insight"
|
|
158
|
+
assert ok.importance == 0.5
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_remember_input_importance_bounds():
|
|
162
|
+
with pytest.raises(ValidationError):
|
|
163
|
+
MimirRememberInput(content="c", key="k", importance=1.5)
|
|
164
|
+
with pytest.raises(ValidationError):
|
|
165
|
+
MimirRememberInput(content="c", key="k", importance=-0.1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_recall_input_requires_query_and_limit_bounds():
|
|
169
|
+
with pytest.raises(ValidationError):
|
|
170
|
+
MimirRecallInput() # missing query
|
|
171
|
+
with pytest.raises(ValidationError):
|
|
172
|
+
MimirRecallInput(query="q", limit=0) # below ge=1
|
|
173
|
+
ok = MimirRecallInput(query="q")
|
|
174
|
+
assert ok.limit == 5
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_tool_metadata():
|
|
178
|
+
t = MimirRememberTool.model_construct()
|
|
179
|
+
assert t.name == "mimir_remember"
|
|
180
|
+
assert t.args_schema is MimirRememberInput
|
|
181
|
+
r = MimirRecallTool.model_construct()
|
|
182
|
+
assert r.name == "mimir_recall"
|
|
183
|
+
assert r.args_schema is MimirRecallInput
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── _run round-trips against the fake server ──────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_remember_then_recall(fake_popen):
|
|
190
|
+
client = MimirClient(db_path="./_t/mimir.db")
|
|
191
|
+
remember = MimirRememberTool(client=client)
|
|
192
|
+
recall = MimirRecallTool(client=client)
|
|
193
|
+
|
|
194
|
+
out = remember._run(
|
|
195
|
+
content="Use PostgreSQL 16 for the main datastore.",
|
|
196
|
+
key="use-postgres-16",
|
|
197
|
+
category="decision",
|
|
198
|
+
tags=["db"],
|
|
199
|
+
)
|
|
200
|
+
parsed = json.loads(out)
|
|
201
|
+
assert parsed["status"] == "remembered"
|
|
202
|
+
assert parsed["key"] == "use-postgres-16"
|
|
203
|
+
|
|
204
|
+
out = recall._run(query="postgresql datastore", limit=5)
|
|
205
|
+
parsed = json.loads(out)
|
|
206
|
+
assert parsed["query"] == "postgresql datastore"
|
|
207
|
+
assert len(parsed["results"]) == 1
|
|
208
|
+
assert parsed["results"][0]["content"].startswith("Use PostgreSQL 16")
|
|
209
|
+
client.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_recall_category_filter(fake_popen):
|
|
213
|
+
client = MimirClient(db_path="./_t/mimir.db")
|
|
214
|
+
remember = MimirRememberTool(client=client)
|
|
215
|
+
recall = MimirRecallTool(client=client)
|
|
216
|
+
|
|
217
|
+
remember._run(content="alpha fact", key="a", category="insight")
|
|
218
|
+
remember._run(content="alpha decision", key="b", category="decision")
|
|
219
|
+
|
|
220
|
+
out = json.loads(recall._run(query="alpha", category="decision"))
|
|
221
|
+
assert len(out["results"]) == 1
|
|
222
|
+
assert out["results"][0]["content"] == "alpha decision"
|
|
223
|
+
client.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_run_through_crewai_tool_run_wrapper(fake_popen):
|
|
227
|
+
"""BaseTool.run(**kwargs) validates kwargs via args_schema then dispatches _run.
|
|
228
|
+
|
|
229
|
+
Note: in crewai 1.15.x, run() forwards keyword arguments to _run; passing a
|
|
230
|
+
single positional dict is NOT unpacked, so agents/tooling call with kwargs.
|
|
231
|
+
"""
|
|
232
|
+
client = MimirClient(db_path="./_t/mimir.db")
|
|
233
|
+
remember = MimirRememberTool(client=client)
|
|
234
|
+
result = remember.run(
|
|
235
|
+
content="wrapped via run()", key="w1", category="insight"
|
|
236
|
+
)
|
|
237
|
+
assert json.loads(result)["status"] == "remembered"
|
|
238
|
+
out = json.loads(MimirRecallTool(client=client).run(query="wrapped"))
|
|
239
|
+
assert any("wrapped" in r["content"] for r in out["results"])
|
|
240
|
+
client.close()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_build_mimir_tools_shares_client(fake_popen):
|
|
244
|
+
tools = build_mimir_tools(db_path="./_t/mimir.db")
|
|
245
|
+
assert len(tools) == 2
|
|
246
|
+
names = {t.name for t in tools}
|
|
247
|
+
assert names == {"mimir_remember", "mimir_recall"}
|
|
248
|
+
assert tools[0].client is tools[1].client
|
|
249
|
+
tools[0].client.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_missing_binary_raises(monkeypatch):
|
|
253
|
+
monkeypatch.setattr(client_mod.shutil, "which", lambda b: None)
|
|
254
|
+
with pytest.raises(RuntimeError, match="mimir binary not found"):
|
|
255
|
+
MimirClient(db_path="./_t/mimir.db", mimir_binary="mimir")
|