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.
@@ -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")