memmachine-client 0.3.7__tar.gz → 0.3.9__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.
Files changed (27) hide show
  1. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/PKG-INFO +15 -11
  2. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/README.md +14 -10
  3. memmachine_client-0.3.9/client_tests/test_cli.py +228 -0
  4. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_client.py +91 -0
  5. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_config.py +28 -0
  6. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_format.py +79 -0
  7. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_langgraph.py +41 -5
  8. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_memory.py +48 -0
  9. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/pyproject.toml +4 -0
  10. memmachine_client-0.3.9/src/memmachine_client/cli.py +478 -0
  11. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/client.py +86 -12
  12. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/config.py +21 -0
  13. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/format.py +3 -3
  14. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/langgraph.py +21 -2
  15. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/memory.py +31 -8
  16. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/project.py +11 -1
  17. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/PKG-INFO +15 -11
  18. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/SOURCES.txt +3 -0
  19. memmachine_client-0.3.9/src/memmachine_client.egg-info/entry_points.txt +3 -0
  20. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/LICENSE +0 -0
  21. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/__init__.py +0 -0
  22. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_integration_complete.py +0 -0
  23. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/setup.cfg +0 -0
  24. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/__init__.py +0 -0
  25. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/dependency_links.txt +0 -0
  26. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/requires.txt +0 -0
  27. {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memmachine-client
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: MemMachine REST Client - A lightweight Python client library for MemMachine memory system
5
5
  Author-email: MemMachine <noreply@memmachine.ai>
6
6
  License-Expression: Apache-2.0
@@ -39,11 +39,15 @@ A Python client library for the MemMachine memory system.
39
39
  ## Installation
40
40
 
41
41
  ```bash
42
- # Install from source (for development)
43
- pip install -e .
42
+ pip install memmachine-client
43
+ ```
44
+
45
+ ### Development installation
46
+
47
+ If you are working on the package locally inside the MemMachine monorepo, install it from source instead:
44
48
 
45
- # Or install dependencies
46
- pip install requests urllib3
49
+ ```bash
50
+ pip install -e packages/client
47
51
  ```
48
52
 
49
53
  ## Quick Start
@@ -130,14 +134,14 @@ memory = client.memory(
130
134
  memory.add("I like pizza", metadata={"type": "preference", "category": "food"})
131
135
  memory.add("I work as a software engineer", metadata={"type": "fact", "category": "work"})
132
136
 
133
- # Search memories
137
+ # Search memories — Memory.search() returns a SearchResult Pydantic model
134
138
  results = memory.search("What do I like to eat?")
135
- print(f"Episodic memory: {results.get('episodic_memory', [])}")
136
- print(f"Profile memory: {results.get('profile_memory', [])}")
139
+ print(f"Episodic memory: {results.content.episodic_memory}")
140
+ print(f"Semantic memory: {results.content.semantic_memory}")
137
141
 
138
- # Search with filters
139
- work_results = memory.search("Tell me about work", filter_dict={"category": "work"})
140
- print(f"Work results: {work_results}")
142
+ # Search with filters (user metadata fields require the `m.` / `metadata.` prefix)
143
+ work_results = memory.search("Tell me about work", filter_dict={"m.category": "work"})
144
+ print(f"Work results: {work_results.model_dump()}")
141
145
  ```
142
146
 
143
147
  ### Multiple Users
@@ -13,11 +13,15 @@ A Python client library for the MemMachine memory system.
13
13
  ## Installation
14
14
 
15
15
  ```bash
16
- # Install from source (for development)
17
- pip install -e .
16
+ pip install memmachine-client
17
+ ```
18
+
19
+ ### Development installation
20
+
21
+ If you are working on the package locally inside the MemMachine monorepo, install it from source instead:
18
22
 
19
- # Or install dependencies
20
- pip install requests urllib3
23
+ ```bash
24
+ pip install -e packages/client
21
25
  ```
22
26
 
23
27
  ## Quick Start
@@ -104,14 +108,14 @@ memory = client.memory(
104
108
  memory.add("I like pizza", metadata={"type": "preference", "category": "food"})
105
109
  memory.add("I work as a software engineer", metadata={"type": "fact", "category": "work"})
106
110
 
107
- # Search memories
111
+ # Search memories — Memory.search() returns a SearchResult Pydantic model
108
112
  results = memory.search("What do I like to eat?")
109
- print(f"Episodic memory: {results.get('episodic_memory', [])}")
110
- print(f"Profile memory: {results.get('profile_memory', [])}")
113
+ print(f"Episodic memory: {results.content.episodic_memory}")
114
+ print(f"Semantic memory: {results.content.semantic_memory}")
111
115
 
112
- # Search with filters
113
- work_results = memory.search("Tell me about work", filter_dict={"category": "work"})
114
- print(f"Work results: {work_results}")
116
+ # Search with filters (user metadata fields require the `m.` / `metadata.` prefix)
117
+ work_results = memory.search("Tell me about work", filter_dict={"m.category": "work"})
118
+ print(f"Work results: {work_results.model_dump()}")
115
119
  ```
116
120
 
117
121
  ### Multiple Users
@@ -0,0 +1,228 @@
1
+ """Tests for the MemMachine command line client."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import Mock, patch
6
+
7
+ import pytest
8
+ from pydantic import BaseModel
9
+
10
+ if sys.version_info >= (3, 11):
11
+ import tomllib
12
+ else:
13
+ import tomli as tomllib
14
+
15
+ from memmachine_client import cli
16
+
17
+
18
+ class CliResult(BaseModel):
19
+ """Small Pydantic test result."""
20
+
21
+ uid: str
22
+
23
+
24
+ def test_pyproject_installs_command_name():
25
+ """Editable installs should expose the documented mem-cli command."""
26
+ pyproject = tomllib.loads(
27
+ (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()
28
+ )
29
+
30
+ assert pyproject["project"]["scripts"]["mem-cli"] == ("memmachine_client.cli:main")
31
+
32
+
33
+ def test_build_client_uses_args_over_environment(monkeypatch):
34
+ """Command arguments should override environment variables."""
35
+ monkeypatch.setenv("MEMORY_BACKEND_URL", "http://env:8080")
36
+ monkeypatch.setenv("MEMMACHINE_API_KEY", "env-key")
37
+ monkeypatch.setenv("MEMMACHINE_TIMEOUT", "10")
38
+ monkeypatch.setenv("MEMMACHINE_MAX_RETRIES", "2")
39
+
40
+ args = cli.build_parser().parse_args(
41
+ [
42
+ "--base-url",
43
+ "http://arg:8080",
44
+ "--api-key",
45
+ "arg-key",
46
+ "--timeout",
47
+ "20",
48
+ "--max-retries",
49
+ "4",
50
+ "health",
51
+ ]
52
+ )
53
+
54
+ client = cli.build_client(args)
55
+
56
+ assert client.base_url == "http://arg:8080"
57
+ assert client.api_key == "arg-key"
58
+ assert client.timeout == 20
59
+ assert client.max_retries == 4
60
+
61
+
62
+ def test_build_client_requires_base_url(monkeypatch):
63
+ """The CLI should fail clearly when base_url is unavailable."""
64
+ monkeypatch.delenv("MEMORY_BACKEND_URL", raising=False)
65
+
66
+ args = cli.build_parser().parse_args(["health"])
67
+
68
+ with pytest.raises(SystemExit) as exc:
69
+ cli.build_client(args)
70
+
71
+ assert exc.value.code == 2
72
+
73
+
74
+ def test_parser_help_uses_installed_command_name(capsys):
75
+ """Help output should match the documented console script."""
76
+ with pytest.raises(SystemExit) as exc:
77
+ cli.build_parser().parse_args(["--help"])
78
+
79
+ assert exc.value.code == 0
80
+ assert capsys.readouterr().out.startswith("usage: mem-cli ")
81
+
82
+
83
+ def test_custom_errors_use_parser_program_name(monkeypatch, capsys):
84
+ """Custom errors should use the same program name as argparse."""
85
+ monkeypatch.delenv("MEMORY_BACKEND_URL", raising=False)
86
+
87
+ args = cli.build_parser().parse_args(["health"])
88
+
89
+ with pytest.raises(SystemExit) as exc:
90
+ cli.build_client(args)
91
+
92
+ assert exc.value.code == 2
93
+ assert capsys.readouterr().err == (
94
+ "mem-cli: error: --base-url or MEMORY_BACKEND_URL is required\n"
95
+ )
96
+
97
+
98
+ def test_health_command_prints_json(capsys):
99
+ """The health command should call the underlying client."""
100
+ client = Mock()
101
+ client.health_check.return_value = {"status": "healthy"}
102
+
103
+ result = cli.run_command(client, cli.build_parser().parse_args(["health"]))
104
+
105
+ assert result == 0
106
+ client.health_check.assert_called_once_with(timeout=None)
107
+ assert capsys.readouterr().out == '{\n "status": "healthy"\n}\n'
108
+
109
+
110
+ def test_memory_add_uses_env_project_context(monkeypatch, capsys):
111
+ """Memory commands should accept project context from the environment."""
112
+ monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
113
+ monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
114
+ client = Mock()
115
+ project = Mock()
116
+ memory = Mock()
117
+ memory.add.return_value = [CliResult(uid="mem-1")]
118
+ project.memory.return_value = memory
119
+ client.get_project.return_value = project
120
+
121
+ args = cli.build_parser().parse_args(
122
+ ["memory", "add", "hello", "--metadata", "user_id=user-1"]
123
+ )
124
+
125
+ exit_code = cli.run_command(client, args)
126
+
127
+ assert exit_code == 0
128
+ client.get_project.assert_called_once_with(
129
+ org_id="env-org",
130
+ project_id="env-project",
131
+ timeout=None,
132
+ )
133
+ project.memory.assert_called_once_with(metadata={"user_id": "user-1"})
134
+ memory.add.assert_called_once_with(
135
+ "hello",
136
+ role="",
137
+ producer=None,
138
+ produced_for=None,
139
+ metadata=None,
140
+ timeout=None,
141
+ )
142
+ assert capsys.readouterr().out == '[\n {\n "uid": "mem-1"\n }\n]\n'
143
+
144
+
145
+ @pytest.mark.parametrize(
146
+ ("argv", "memory_method"),
147
+ [
148
+ (["memory", "search", "hello"], "search"),
149
+ (["memory", "list"], "list"),
150
+ (["memory", "delete-episodic", "--id", "episode-1"], "delete_episodic"),
151
+ (["memory", "delete-semantic", "--id", "semantic-1"], "delete_semantic"),
152
+ ],
153
+ )
154
+ def test_memory_subcommands_use_env_project_context(
155
+ argv, memory_method, monkeypatch, capsys
156
+ ):
157
+ """Memory subcommands should resolve project context through the shared helper."""
158
+ monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
159
+ monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
160
+ client = Mock()
161
+ project = Mock()
162
+ memory = Mock()
163
+ getattr(memory, memory_method).return_value = (
164
+ True if memory_method.startswith("delete_") else []
165
+ )
166
+ project.memory.return_value = memory
167
+ client.get_project.return_value = project
168
+
169
+ args = cli.build_parser().parse_args(argv)
170
+
171
+ assert cli.run_command(client, args) == 0
172
+ client.get_project.assert_called_once_with(
173
+ org_id="env-org",
174
+ project_id="env-project",
175
+ timeout=None,
176
+ )
177
+ project.memory.assert_called_once_with(metadata={})
178
+ assert capsys.readouterr().out
179
+
180
+
181
+ def test_projects_create_uses_env_project_context(monkeypatch, capsys):
182
+ """Project create should accept project context from the environment."""
183
+ monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
184
+ monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
185
+ client = Mock()
186
+ project = Mock(
187
+ org_id="env-org",
188
+ project_id="env-project",
189
+ description="",
190
+ config={},
191
+ )
192
+ client.create_project.return_value = project
193
+
194
+ args = cli.build_parser().parse_args(["projects", "create"])
195
+
196
+ exit_code = cli.run_command(client, args)
197
+
198
+ assert exit_code == 0
199
+ client.create_project.assert_called_once_with(
200
+ org_id="env-org",
201
+ project_id="env-project",
202
+ description="",
203
+ embedder="",
204
+ reranker="",
205
+ timeout=None,
206
+ )
207
+ assert capsys.readouterr().out == (
208
+ "{\n"
209
+ ' "config": {},\n'
210
+ ' "description": "",\n'
211
+ ' "org_id": "env-org",\n'
212
+ ' "project_id": "env-project"\n'
213
+ "}\n"
214
+ )
215
+
216
+
217
+ @patch("memmachine_client.cli.MemMachineClient")
218
+ def test_main_closes_client(mock_client_class, monkeypatch):
219
+ """The top-level entry point should close the client after command execution."""
220
+ monkeypatch.setenv("MEMORY_BACKEND_URL", "http://localhost:8080")
221
+ client = Mock()
222
+ client.health_check.return_value = {"status": "healthy"}
223
+ mock_client_class.return_value = client
224
+
225
+ exit_code = cli.main(["health"])
226
+
227
+ assert exit_code == 0
228
+ client.close.assert_called_once_with()
@@ -206,6 +206,97 @@ class TestMemMachineClient:
206
206
  project_id="test_project",
207
207
  )
208
208
 
209
+ def test_create_project_event_backend_fields_reach_wire(self):
210
+ """Event-backend fields must reach the wire — without this the SDK
211
+ has no supported path to event-backed projects."""
212
+ client = MemMachineClient(base_url="http://localhost:8080")
213
+ mock_response = Mock()
214
+ mock_response.status_code = 200
215
+ mock_response.raise_for_status = Mock()
216
+ mock_response.json.return_value = {
217
+ "org_id": "test_org",
218
+ "project_id": "test_project",
219
+ "description": "",
220
+ "config": {
221
+ "backend": "event",
222
+ "embedder": "default",
223
+ "reranker": "default",
224
+ "vector_store": "qdrant_vs",
225
+ "segment_store": "sqlite_db",
226
+ "properties_schema": {"customer_tier": "str"},
227
+ },
228
+ }
229
+ with patch.object(
230
+ client._session, "post", return_value=mock_response
231
+ ) as mock_post:
232
+ client.create_project(
233
+ org_id="test_org",
234
+ project_id="test_project",
235
+ backend="event",
236
+ vector_store="qdrant_vs",
237
+ segment_store="sqlite_db",
238
+ properties_schema={"customer_tier": "str"},
239
+ )
240
+ cfg = mock_post.call_args[1]["json"]["config"]
241
+ assert cfg["backend"] == "event"
242
+ assert cfg["vector_store"] == "qdrant_vs"
243
+ assert cfg["segment_store"] == "sqlite_db"
244
+ assert cfg["properties_schema"] == {"customer_tier": "str"}
245
+
246
+ def test_get_project_404_raises_http_error(self):
247
+ """Test missing project preserves HTTPError semantics."""
248
+ client = MemMachineClient(base_url="http://localhost:8080")
249
+ mock_response = Mock()
250
+ mock_response.status_code = 404
251
+ http_error = requests.HTTPError(response=mock_response)
252
+ mock_response.raise_for_status.side_effect = http_error
253
+
254
+ with (
255
+ patch.object(client, "request", return_value=mock_response),
256
+ pytest.raises(requests.HTTPError) as exc_info,
257
+ ):
258
+ client.get_project(org_id="test_org", project_id="missing_project")
259
+
260
+ assert exc_info.value is http_error
261
+ assert exc_info.value.response.status_code == 404
262
+
263
+ def test_get_or_create_project_creates_after_get_404(self):
264
+ """Test missing project is created by get_or_create_project."""
265
+ client = MemMachineClient(base_url="http://localhost:8080")
266
+ get_response = Mock()
267
+ get_response.status_code = 404
268
+ get_response.raise_for_status.side_effect = requests.HTTPError(
269
+ response=get_response
270
+ )
271
+
272
+ create_response = Mock()
273
+ create_response.status_code = 200
274
+ create_response.raise_for_status = Mock()
275
+ create_response.json.return_value = {
276
+ "org_id": "test_org",
277
+ "project_id": "new_project",
278
+ "description": "Created project",
279
+ "config": {"embedder": "", "reranker": ""},
280
+ }
281
+
282
+ with (
283
+ patch.object(client, "request", return_value=get_response) as mock_request,
284
+ patch.object(
285
+ client._session, "post", return_value=create_response
286
+ ) as mock_post,
287
+ ):
288
+ project = client.get_or_create_project(
289
+ org_id="test_org",
290
+ project_id="new_project",
291
+ description="Created project",
292
+ )
293
+
294
+ assert project.org_id == "test_org"
295
+ assert project.project_id == "new_project"
296
+ assert project.description == "Created project"
297
+ mock_request.assert_called_once()
298
+ mock_post.assert_called_once()
299
+
209
300
  @patch("requests.Session.get")
210
301
  def test_health_check_success(self, mock_get):
211
302
  """Test successful health check."""
@@ -368,6 +368,30 @@ class TestConfig:
368
368
  with pytest.raises(RuntimeError, match="client has been closed"):
369
369
  config.update_long_term_memory_config(embedder="x")
370
370
 
371
+ def test_update_long_term_memory_config_event_backend(self, config, mock_client):
372
+ """Event-backend fields (backend / vector_store / segment_store /
373
+ properties_schema) must reach the wire — without these the SDK has no
374
+ supported path to event-backed projects."""
375
+ mock_client.request.return_value = _mock_response(
376
+ {"success": True, "message": "Long-term memory configuration updated"}
377
+ )
378
+ result = config.update_long_term_memory_config(
379
+ backend="event",
380
+ embedder="new-embedder",
381
+ vector_store="qdrant_vs",
382
+ segment_store="sqlite_db",
383
+ properties_schema={"customer_tier": "str"},
384
+ )
385
+ assert isinstance(result, UpdateMemoryConfigResponse)
386
+ body = mock_client.request.call_args[1]["json"]
387
+ assert body["backend"] == "event"
388
+ assert body["vector_store"] == "qdrant_vs"
389
+ assert body["segment_store"] == "sqlite_db"
390
+ assert body["properties_schema"] == {"customer_tier": "str"}
391
+ assert body["embedder"] == "new-embedder"
392
+ # Untouched declarative-only field must NOT leak into the payload.
393
+ assert "vector_graph_store" not in body
394
+
371
395
  def test_update_short_term_memory_config(self, config, mock_client):
372
396
  mock_client.request.return_value = _mock_response(
373
397
  {"success": True, "message": "Short-term memory configuration updated"}
@@ -533,9 +557,13 @@ class TestConfig:
533
557
  {"success": True, "message": "Episodic memory configuration updated"}
534
558
  )
535
559
  ltm_spec = UpdateLongTermMemorySpec(
560
+ backend=None,
536
561
  embedder="new-embedder",
537
562
  reranker="new-reranker",
538
563
  vector_graph_store="new-store",
564
+ vector_store=None,
565
+ segment_store=None,
566
+ properties_schema=None,
539
567
  )
540
568
  stm_spec = UpdateShortTermMemorySpec(
541
569
  llm_model="new-llm",
@@ -99,6 +99,59 @@ class TestFormatEpisodes:
99
99
  result = format_episodes([ep])
100
100
  assert json.dumps('She said "hello"') in result
101
101
 
102
+ def test_non_ascii_content_preserved_literally(self):
103
+ """Non-ASCII characters must appear literally in the LLM context, not
104
+ as ``\\uXXXX`` escapes — escaping bloats token counts and degrades
105
+ recall on multilingual content."""
106
+ ep = EpisodeResponse(
107
+ uid="1",
108
+ content="寿司 café 🍕 naïve résumé Привет",
109
+ producer_id="user_1",
110
+ producer_role="user",
111
+ created_at=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
112
+ )
113
+ result = format_episodes([ep])
114
+ assert "寿司" in result
115
+ assert "café" in result
116
+ assert "🍕" in result
117
+ assert "naïve" in result
118
+ assert "résumé" in result
119
+ assert "Привет" in result
120
+ # Sanity: no escaped CJK / cyrillic / accented sequences.
121
+ assert "\\u" not in result
122
+
123
+ def test_non_ascii_content_lossless_roundtrip(self):
124
+ """The JSON-encoded portion must round-trip back to the original
125
+ string so downstream LLM consumers (and any client-side
126
+ post-processing) still see correct text."""
127
+ original = '日本語 — "quoted" + emoji 🎉'
128
+ ep = EpisodeResponse(
129
+ uid="1",
130
+ content=original,
131
+ producer_id="user_1",
132
+ producer_role="user",
133
+ created_at=None,
134
+ )
135
+ result = format_episodes([ep])
136
+ json_part = result.removeprefix("user_1: ").rstrip("\n")
137
+ assert json.loads(json_part) == original
138
+
139
+ def test_output_is_utf8_encodable(self):
140
+ """The LLM-visible string must be safe to send over UTF-8 transports
141
+ (HTTP body, logging sinks). ``ensure_ascii=False`` produces
142
+ unescaped surrogates only for malformed inputs; clean Unicode must
143
+ encode without error."""
144
+ ep = EpisodeResponse(
145
+ uid="1",
146
+ content="Mixed: ASCII + 中文 + 🚀",
147
+ producer_id="user_1",
148
+ producer_role="user",
149
+ created_at=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
150
+ )
151
+ result = format_episodes([ep])
152
+ encoded = result.encode("utf-8")
153
+ assert encoded.decode("utf-8") == result
154
+
102
155
 
103
156
  class TestFormatSemanticMemories:
104
157
  """Tests for format_semantic_memories."""
@@ -146,6 +199,32 @@ class TestFormatSemanticMemories:
146
199
  "background": {"role": "engineer"},
147
200
  }
148
201
 
202
+ def test_non_ascii_value_preserved_literally(self):
203
+ feature = SemanticFeature(
204
+ category="profile",
205
+ tag="prefs",
206
+ feature_name="favorite_food",
207
+ value="寿司 🍣",
208
+ )
209
+ result = format_semantic_memories([feature])
210
+ assert "寿司" in result
211
+ assert "🍣" in result
212
+ assert "\\u" not in result
213
+ # And the JSON is still valid.
214
+ assert json.loads(result) == {"prefs": {"favorite_food": "寿司 🍣"}}
215
+
216
+ def test_non_ascii_tag_and_feature_name_preserved(self):
217
+ feature = SemanticFeature(
218
+ category="profile",
219
+ tag="préférences",
220
+ feature_name="種類",
221
+ value="ramen",
222
+ )
223
+ result = format_semantic_memories([feature])
224
+ assert "préférences" in result
225
+ assert "種類" in result
226
+ assert json.loads(result) == {"préférences": {"種類": "ramen"}}
227
+
149
228
  def test_metadata_excluded(self):
150
229
  feature = SemanticFeature(
151
230
  set_id="set_1",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from unittest.mock import Mock, patch
4
4
 
5
+ from memmachine_common.api import EpisodeType
6
+
5
7
  from memmachine_client.langgraph import (
6
8
  MemMachineTools,
7
9
  create_add_memory_tool,
@@ -145,9 +147,43 @@ class TestAddMemory:
145
147
  assert result["uids"] == ["uid-123"]
146
148
  assert result["content"] == "Hello world"
147
149
  mock_memory.add.assert_called_once_with(
148
- content="Hello world", role="user", metadata={}
150
+ content="Hello world", role="user", metadata={}, episode_type=None
151
+ )
152
+
153
+ def test_raw_filter_passthrough(self):
154
+ tools, mock_memory = self._make_tools()
155
+
156
+ mock_content = Mock()
157
+ mock_content.episodic_memory = None
158
+ mock_content.semantic_memory = []
159
+
160
+ mock_search_result = Mock()
161
+ mock_search_result.content = mock_content
162
+ mock_memory.search.return_value = mock_search_result
163
+
164
+ result = tools.search_memory(query="food", filter='metadata.category = "work"')
165
+
166
+ assert result["status"] == "success"
167
+ mock_memory.search.assert_called_once_with(
168
+ query="food",
169
+ limit=20,
170
+ score_threshold=None,
171
+ filter_dict=None,
172
+ filter='metadata.category = "work"',
149
173
  )
150
174
 
175
+ def test_episode_type_string_is_normalized(self):
176
+ tools, mock_memory = self._make_tools()
177
+ mock_result = Mock()
178
+ mock_result.uid = "uid-ep"
179
+ mock_memory.add.return_value = [mock_result]
180
+
181
+ result = tools.add_memory(content="Hello world", episode_type="message")
182
+
183
+ assert result["status"] == "success"
184
+ mock_memory.add.assert_called_once()
185
+ assert mock_memory.add.call_args.kwargs["episode_type"] == EpisodeType.MESSAGE
186
+
151
187
  def test_error_path(self):
152
188
  tools, mock_memory = self._make_tools()
153
189
  mock_memory.add.side_effect = RuntimeError("boom")
@@ -203,7 +239,7 @@ class TestSearchMemory:
203
239
  assert result["results"]["episodic_memory"] == {"episodes": [{"content": "hi"}]}
204
240
  assert result["results"]["semantic_memory"] == [{"name": "likes pizza"}]
205
241
  mock_memory.search.assert_called_once_with(
206
- query="food", limit=20, score_threshold=None, filter_dict=None
242
+ query="food", limit=20, score_threshold=None, filter_dict=None, filter=None
207
243
  )
208
244
 
209
245
  def test_error_path(self):
@@ -323,7 +359,7 @@ class TestFactoryFunctions:
323
359
  mock_memory.add.return_value = [mock_result]
324
360
 
325
361
  add_fn = create_add_memory_tool(tools)
326
- result = add_fn("test content", None, None)
362
+ result = add_fn("test content", None, None, None)
327
363
 
328
364
  assert result["status"] == "success"
329
365
  assert result["uids"] == ["uid-1"]
@@ -335,7 +371,7 @@ class TestFactoryFunctions:
335
371
  mock_memory.add.return_value = [mock_result]
336
372
 
337
373
  add_fn = create_add_memory_tool(tools)
338
- result = add_fn("content", "u1", None)
374
+ result = add_fn("content", "u1", None, "message")
339
375
 
340
376
  assert result["status"] == "success"
341
377
 
@@ -368,5 +404,5 @@ class TestFactoryFunctions:
368
404
  search_fn("hello", None, 10)
369
405
 
370
406
  mock_memory.search.assert_called_once_with(
371
- query="hello", limit=10, score_threshold=None, filter_dict=None
407
+ query="hello", limit=10, score_threshold=None, filter_dict=None, filter=None
372
408
  )