@reconcrap/people-network-memory 0.1.0

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 (61) hide show
  1. package/README.md +476 -0
  2. package/docs/mcp_tools.md +138 -0
  3. package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
  4. package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
  5. package/harness_adapters/openclaw/mcp.template.json +14 -0
  6. package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
  7. package/package.json +30 -0
  8. package/pyproject.toml +26 -0
  9. package/scripts/install_windows.ps1 +92 -0
  10. package/scripts/npm/people-memory.js +276 -0
  11. package/scripts/people_memory_bootstrap.py +247 -0
  12. package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
  13. package/scripts/run_tests_with_artifacts.ps1 +307 -0
  14. package/src/people_network_memory/__init__.py +6 -0
  15. package/src/people_network_memory/application/__init__.py +16 -0
  16. package/src/people_network_memory/application/normalization.py +1441 -0
  17. package/src/people_network_memory/application/services.py +921 -0
  18. package/src/people_network_memory/cli.py +1212 -0
  19. package/src/people_network_memory/config.py +268 -0
  20. package/src/people_network_memory/domain/__init__.py +55 -0
  21. package/src/people_network_memory/domain/identity.py +77 -0
  22. package/src/people_network_memory/domain/models.py +355 -0
  23. package/src/people_network_memory/fixtures/__init__.py +6 -0
  24. package/src/people_network_memory/fixtures/eval.py +398 -0
  25. package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
  26. package/src/people_network_memory/fixtures/generator.py +290 -0
  27. package/src/people_network_memory/fixtures/report.py +252 -0
  28. package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
  29. package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
  30. package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
  31. package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
  32. package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
  33. package/src/people_network_memory/harness_adapters/__init__.py +2 -0
  34. package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
  35. package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
  36. package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
  37. package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
  38. package/src/people_network_memory/infrastructure/__init__.py +2 -0
  39. package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
  40. package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
  41. package/src/people_network_memory/infrastructure/embeddings.py +155 -0
  42. package/src/people_network_memory/infrastructure/file_store.py +129 -0
  43. package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
  44. package/src/people_network_memory/infrastructure/id_generator.py +40 -0
  45. package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
  46. package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
  47. package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
  48. package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
  49. package/src/people_network_memory/infrastructure/redaction.py +21 -0
  50. package/src/people_network_memory/infrastructure/release_check.py +186 -0
  51. package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
  52. package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
  53. package/src/people_network_memory/mcp_server/__init__.py +2 -0
  54. package/src/people_network_memory/mcp_server/contracts.py +85 -0
  55. package/src/people_network_memory/mcp_server/runtime.py +133 -0
  56. package/src/people_network_memory/mcp_server/tools.py +588 -0
  57. package/src/people_network_memory/ports/__init__.py +2 -0
  58. package/src/people_network_memory/ports/errors.py +25 -0
  59. package/src/people_network_memory/ports/interfaces.py +103 -0
  60. package/src/people_network_memory/projection/__init__.py +6 -0
  61. package/src/people_network_memory/projection/builders.py +46 -0
@@ -0,0 +1,292 @@
1
+ """OpenClaw adapter smoke checks.
2
+
3
+ These checks do not try to automate OpenClaw itself. They verify the adapter
4
+ surface that OpenClaw depends on: install materialization, prompt-routing
5
+ expectations, and the three public MCP tool workflows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from people_network_memory.config import PeopleMemoryConfig
16
+ from people_network_memory.harness_adapters.openclaw.installer import (
17
+ SKILL_MARKDOWN,
18
+ install_openclaw_adapter,
19
+ openclaw_checks,
20
+ )
21
+ from people_network_memory.mcp_server.runtime import build_runtime
22
+
23
+
24
+ ToolName = Literal["record_interaction", "retrieve_network_context", "get_person"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PromptRouteCase:
29
+ prompt: str
30
+ expected_tool: ToolName
31
+ expected_mode: Literal["recall", "brief"] | None = None
32
+
33
+ def to_json(self) -> dict[str, object]:
34
+ payload: dict[str, object] = {
35
+ "prompt": self.prompt,
36
+ "expected_tool": self.expected_tool,
37
+ }
38
+ if self.expected_mode:
39
+ payload["expected_mode"] = self.expected_mode
40
+ return payload
41
+
42
+
43
+ PROMPT_ROUTE_CASES = [
44
+ PromptRouteCase(
45
+ prompt="Remember Alice Zhang. Alice works at Tencent Robotics as product lead.",
46
+ expected_tool="record_interaction",
47
+ ),
48
+ PromptRouteCase(
49
+ prompt="Met Alice Zhang at Blue Bottle and discussed robotics hiring.",
50
+ expected_tool="record_interaction",
51
+ ),
52
+ PromptRouteCase(
53
+ prompt="今天在潘家园见了胡八一(胖子),聊了北京的项目。",
54
+ expected_tool="record_interaction",
55
+ ),
56
+ PromptRouteCase(
57
+ prompt="Who was the robotics person I met at the coffee shop?",
58
+ expected_tool="retrieve_network_context",
59
+ expected_mode="recall",
60
+ ),
61
+ PromptRouteCase(
62
+ prompt="Brief me before I meet Alice Zhang.",
63
+ expected_tool="retrieve_network_context",
64
+ expected_mode="brief",
65
+ ),
66
+ PromptRouteCase(
67
+ prompt="Who should I invite to dinner in Shanghai for robotics and founder intros?",
68
+ expected_tool="retrieve_network_context",
69
+ expected_mode="recall",
70
+ ),
71
+ PromptRouteCase(
72
+ prompt="Draft a warm intro message to Alice Zhang about the robotics founder dinner.",
73
+ expected_tool="retrieve_network_context",
74
+ expected_mode="recall",
75
+ ),
76
+ PromptRouteCase(
77
+ prompt="Open the person card for person_0001.",
78
+ expected_tool="get_person",
79
+ ),
80
+ PromptRouteCase(
81
+ prompt="Show me Alice Zhang's person card.",
82
+ expected_tool="get_person",
83
+ ),
84
+ PromptRouteCase(
85
+ prompt="Remember that I met Test Clara Wu today at % Arabica. She works on AI hardware hiring.",
86
+ expected_tool="record_interaction",
87
+ ),
88
+ PromptRouteCase(
89
+ prompt="/ppl Remember that I met Test Clara Wu today at % Arabica.",
90
+ expected_tool="record_interaction",
91
+ ),
92
+ PromptRouteCase(
93
+ prompt="/people_network_memory Remember that I met Test Clara Wu today at % Arabica.",
94
+ expected_tool="record_interaction",
95
+ ),
96
+ PromptRouteCase(
97
+ prompt="/people-network-memory Show me Test Clara Wu's person card.",
98
+ expected_tool="get_person",
99
+ ),
100
+ ]
101
+
102
+
103
+ def route_prompt_for_openclaw_skill(prompt: str) -> dict[str, object]:
104
+ """Deterministic guardrail for the skill examples and prompt tests."""
105
+
106
+ lowered = prompt.lower()
107
+ if "person card" in lowered or "get_person" in lowered:
108
+ return {"tool": "get_person"}
109
+ if "brief" in lowered or "before i meet" in lowered:
110
+ return {"tool": "retrieve_network_context", "mode": "brief"}
111
+ recall_markers = [
112
+ "who was",
113
+ "who should",
114
+ "find",
115
+ "recall",
116
+ "search",
117
+ "who did",
118
+ "which person",
119
+ "recommend",
120
+ "draft",
121
+ "intro message",
122
+ "invite",
123
+ ]
124
+ if any(marker in lowered for marker in recall_markers):
125
+ return {"tool": "retrieve_network_context", "mode": "recall"}
126
+ capture_markers = [
127
+ "remember",
128
+ "met ",
129
+ "spoke with",
130
+ "had coffee",
131
+ "had dinner",
132
+ "called",
133
+ "add ",
134
+ "save ",
135
+ "见了",
136
+ "记住",
137
+ "记录",
138
+ "保存",
139
+ "认识了",
140
+ "遇到",
141
+ "提到",
142
+ ]
143
+ if any(marker in lowered for marker in capture_markers):
144
+ return {"tool": "record_interaction"}
145
+ return {"tool": "retrieve_network_context", "mode": "recall"}
146
+
147
+
148
+ def run_openclaw_adapter_smoke(
149
+ *,
150
+ home: str | Path | None = None,
151
+ install: bool = True,
152
+ backend: str = "local_json",
153
+ ) -> dict[str, Any]:
154
+ temp_home: tempfile.TemporaryDirectory[str] | None = None
155
+ if home is None:
156
+ temp_home = tempfile.TemporaryDirectory()
157
+ home = temp_home.name
158
+ try:
159
+ install_result = (
160
+ install_openclaw_adapter(home=home, backend=backend) if install else None
161
+ )
162
+ checks = [check.to_json() for check in openclaw_checks(home)]
163
+ route_checks = _route_checks()
164
+ workflow = _workflow_smoke()
165
+ ok = all(check["ok"] for check in checks) and all(
166
+ case["ok"] for case in route_checks
167
+ ) and workflow["ok"]
168
+ payload: dict[str, Any] = {
169
+ "ok": ok,
170
+ "home": str(Path(home).expanduser()),
171
+ "installed": install_result.to_json() if install_result else None,
172
+ "checks": checks,
173
+ "route_checks": route_checks,
174
+ "workflow": workflow,
175
+ }
176
+ return payload
177
+ finally:
178
+ if temp_home is not None:
179
+ temp_home.cleanup()
180
+
181
+
182
+ def _route_checks() -> list[dict[str, object]]:
183
+ checks = []
184
+ for case in PROMPT_ROUTE_CASES:
185
+ actual = route_prompt_for_openclaw_skill(case.prompt)
186
+ expected = {"tool": case.expected_tool}
187
+ if case.expected_mode:
188
+ expected["mode"] = case.expected_mode
189
+ checks.append(
190
+ {
191
+ **case.to_json(),
192
+ "actual": actual,
193
+ "ok": all(actual.get(key) == value for key, value in expected.items()),
194
+ }
195
+ )
196
+ return checks
197
+
198
+
199
+ def _workflow_smoke() -> dict[str, Any]:
200
+ runtime = build_runtime(PeopleMemoryConfig(test_mode=True))
201
+ try:
202
+ record = runtime.tools.record_interaction(
203
+ {
204
+ "source_text": (
205
+ "Met Alice Zhang at Blue Bottle Coffee. We discussed robotics hiring. "
206
+ "Alice Zhang mentioned Bob Li may be useful for founder intros. "
207
+ "I promised to send Alice Zhang two founder contacts next week."
208
+ )
209
+ }
210
+ )
211
+ recall = runtime.tools.retrieve_network_context(
212
+ {"query": "robotics person from Blue Bottle", "limit": 5}
213
+ )
214
+ brief = runtime.tools.retrieve_network_context(
215
+ {"query": "Brief me before I meet Alice Zhang", "mode": "brief", "limit": 5}
216
+ )
217
+ person_id = record["person_ref_map"].get("Alice Zhang") or "person_0001"
218
+ card = runtime.tools.get_person({"person_id": person_id})
219
+ ok = (
220
+ bool(record["saved"])
221
+ and bool(record["post_capture_opportunities"])
222
+ and bool(record["harness_context_requests"])
223
+ and bool(recall["results"])
224
+ and bool(brief["results"])
225
+ and bool(card["found"])
226
+ and card["display_name"] == "Alice Zhang"
227
+ )
228
+ return {
229
+ "ok": ok,
230
+ "record_created_people": record["created_people"],
231
+ "post_capture_opportunity_count": len(record["post_capture_opportunities"]),
232
+ "harness_context_request_count": len(record["harness_context_requests"]),
233
+ "recall_result_count": len(recall["results"]),
234
+ "brief_result_count": len(brief["results"]),
235
+ "person_found": card["found"],
236
+ "person_display_name": card.get("display_name"),
237
+ }
238
+ finally:
239
+ runtime.close()
240
+
241
+
242
+ def skill_contains_required_routing_text(skill_text: str = SKILL_MARKDOWN) -> dict[str, object]:
243
+ required = [
244
+ "/ppl",
245
+ "/people_network_memory",
246
+ "/people-network-memory",
247
+ "Remove only that trigger prefix before saving `source_text`",
248
+ "record_interaction",
249
+ "retrieve_network_context",
250
+ "get_person",
251
+ "people-network-memory__record_interaction",
252
+ "people-network-memory__retrieve_network_context",
253
+ "people-network-memory__get_person",
254
+ "Do not call a tool named `ppl` or `people-network-memory`",
255
+ "Do not satisfy a people-memory request by only reading or writing OpenClaw `MEMORY.md`",
256
+ "Never use OpenClaw `read`, `write`, or `edit` as a substitute",
257
+ "call `get_person` again even if the same card was shown earlier",
258
+ "If `get_person` returns `found=false` with `ambiguous=true`",
259
+ "If `retrieve_network_context` returns `ambiguous=true`",
260
+ "final_answer_instruction",
261
+ "do not choose one candidate",
262
+ "render the `get_person` JSON result directly inline in chat",
263
+ "Unauthorized",
264
+ "present the card inline",
265
+ "Do not use canvas, browser, hosted embeds, local URLs, or OpenClaw memory files",
266
+ "Preserve the user's original wording",
267
+ "Separate participants from mentioned people",
268
+ "attributed claim",
269
+ "Preserve Chinese names and aliases exactly",
270
+ "胡八一(胖子)",
271
+ "tool-side LLM extractor",
272
+ "provisional second-degree card",
273
+ "post_capture_opportunities",
274
+ "harness_context_requests",
275
+ "capture_summary",
276
+ "brief capture summary",
277
+ "correction checkpoint",
278
+ "proactively execute low-risk",
279
+ "Action:",
280
+ "Why:",
281
+ "Undo:",
282
+ "source of truth for the user's people/network memory",
283
+ "source of truth for the user's stable preferences",
284
+ "Combine both memory sources deliberately",
285
+ "If the harness memory and MCP evidence conflict",
286
+ "For exact-name questions where multiple person cards share the same display name",
287
+ "do not say \"you probably mean\"",
288
+ "For ambiguous yes/no questions",
289
+ "mode=\"brief\"",
290
+ ]
291
+ missing = [item for item in required if item not in skill_text]
292
+ return {"ok": not missing, "missing": missing}
@@ -0,0 +1,2 @@
1
+ """Local infrastructure adapters."""
2
+
@@ -0,0 +1,171 @@
1
+ """Archive backup and restore helpers for local data backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import tempfile
8
+ import zipfile
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any
12
+
13
+ from people_network_memory.config import PeopleMemoryConfig
14
+ from people_network_memory.infrastructure.file_store import JsonPeopleStore, local_json_path
15
+ from people_network_memory.ports.errors import PersistenceError
16
+
17
+
18
+ MANIFEST_NAME = "manifest.json"
19
+ PROJECTION_NAME = "projection.json"
20
+ KUZU_PREFIX = "graphiti_kuzu"
21
+
22
+
23
+ def create_archive_backup(config: PeopleMemoryConfig, output: str | Path) -> dict[str, Any]:
24
+ output_path = Path(output).expanduser()
25
+ output_path.parent.mkdir(parents=True, exist_ok=True)
26
+ projection = _read_projection_payload(config)
27
+ manifest: dict[str, Any] = {
28
+ "schema_version": 1,
29
+ "created_at": datetime.now(timezone.utc).isoformat(),
30
+ "backend": config.backend,
31
+ "data_path": str(Path(config.data_path).expanduser()),
32
+ "graph_backend_kind": config.graph_backend_kind,
33
+ "graphiti_kuzu_path": str(Path(config.graphiti_kuzu_path).expanduser()),
34
+ "includes": [PROJECTION_NAME],
35
+ }
36
+
37
+ kuzu_path = Path(config.graphiti_kuzu_path).expanduser()
38
+ if config.backend == "graphiti" and config.graph_backend_kind == "kuzu" and kuzu_path.exists():
39
+ manifest["includes"].append(KUZU_PREFIX)
40
+ manifest["kuzu_kind"] = "directory" if kuzu_path.is_dir() else "file"
41
+
42
+ with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
43
+ archive.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
44
+ archive.writestr(PROJECTION_NAME, json.dumps(projection, ensure_ascii=False, indent=2))
45
+ if config.backend == "graphiti" and kuzu_path.exists():
46
+ _write_path_to_archive(archive, kuzu_path, KUZU_PREFIX)
47
+
48
+ return {
49
+ "ok": True,
50
+ "output": str(output_path),
51
+ "backend": config.backend,
52
+ "people": len(projection.get("people", [])),
53
+ "includes": manifest["includes"],
54
+ }
55
+
56
+
57
+ def restore_archive_backup(
58
+ config: PeopleMemoryConfig, archive_path: str | Path, *, confirm: str
59
+ ) -> dict[str, Any]:
60
+ if confirm != "OVERWRITE":
61
+ raise PersistenceError("restore-archive requires --confirm OVERWRITE")
62
+ source = Path(archive_path).expanduser()
63
+ if not source.exists():
64
+ raise PersistenceError(f"Archive does not exist: {source}")
65
+
66
+ with zipfile.ZipFile(source, "r") as archive:
67
+ _validate_archive_members(archive)
68
+ manifest = json.loads(archive.read(MANIFEST_NAME).decode("utf-8"))
69
+ projection = json.loads(archive.read(PROJECTION_NAME).decode("utf-8"))
70
+ JsonPeopleStore.validate_export_payload(projection)
71
+
72
+ if config.backend == "graphiti":
73
+ restored_to = _restore_graphiti_archive(config, archive, projection, manifest)
74
+ else:
75
+ target = local_json_path(config)
76
+ JsonPeopleStore.restore_to_path(target, projection)
77
+ restored_to = [str(target)]
78
+
79
+ return {
80
+ "ok": True,
81
+ "input": str(source),
82
+ "backend": config.backend,
83
+ "archive_backend": manifest.get("backend"),
84
+ "restored_to": restored_to,
85
+ "people": len(projection.get("people", [])),
86
+ }
87
+
88
+
89
+ def _read_projection_payload(config: PeopleMemoryConfig) -> dict[str, Any]:
90
+ if config.backend == "graphiti":
91
+ path = Path(config.data_path).expanduser() / "people-memory.graphiti-cache.json"
92
+ else:
93
+ path = local_json_path(config)
94
+ if not path.exists():
95
+ return {"people": [], "review_items": [], "interactions": []}
96
+ payload = json.loads(path.read_text(encoding="utf-8"))
97
+ return JsonPeopleStore.validate_export_payload(payload)
98
+
99
+
100
+ def _write_path_to_archive(archive: zipfile.ZipFile, path: Path, prefix: str) -> None:
101
+ if path.is_file():
102
+ archive.write(path, f"{prefix}/{path.name}")
103
+ return
104
+ for child in path.rglob("*"):
105
+ if child.is_file():
106
+ archive.write(child, f"{prefix}/{child.relative_to(path).as_posix()}")
107
+
108
+
109
+ def _restore_graphiti_archive(
110
+ config: PeopleMemoryConfig,
111
+ archive: zipfile.ZipFile,
112
+ projection: dict[str, Any],
113
+ manifest: dict[str, Any],
114
+ ) -> list[str]:
115
+ restored: list[str] = []
116
+ cache_path = Path(config.data_path).expanduser() / "people-memory.graphiti-cache.json"
117
+ JsonPeopleStore.restore_to_path(cache_path, projection)
118
+ restored.append(str(cache_path))
119
+
120
+ kuzu_members = [
121
+ name
122
+ for name in archive.namelist()
123
+ if name.startswith(f"{KUZU_PREFIX}/") and not name.endswith("/")
124
+ ]
125
+ if kuzu_members:
126
+ target = Path(config.graphiti_kuzu_path).expanduser()
127
+ _assert_safe_graphiti_target(target)
128
+ with tempfile.TemporaryDirectory() as tmp:
129
+ temp_root = Path(tmp) / KUZU_PREFIX
130
+ for name in kuzu_members:
131
+ relative = PurePosixPath(name).relative_to(KUZU_PREFIX)
132
+ destination = temp_root.joinpath(*relative.parts)
133
+ destination.parent.mkdir(parents=True, exist_ok=True)
134
+ destination.write_bytes(archive.read(name))
135
+ if manifest.get("kuzu_kind") == "file":
136
+ source_file = next(path for path in temp_root.rglob("*") if path.is_file())
137
+ target.parent.mkdir(parents=True, exist_ok=True)
138
+ if target.exists():
139
+ target.unlink()
140
+ shutil.copy2(source_file, target)
141
+ else:
142
+ target.parent.mkdir(parents=True, exist_ok=True)
143
+ if target.exists():
144
+ shutil.rmtree(target)
145
+ shutil.copytree(temp_root, target)
146
+ restored.append(str(target))
147
+ return restored
148
+
149
+
150
+ def _validate_archive_members(archive: zipfile.ZipFile) -> None:
151
+ names = archive.namelist()
152
+ if MANIFEST_NAME not in names or PROJECTION_NAME not in names:
153
+ raise PersistenceError("Archive must contain manifest.json and projection.json")
154
+ for name in names:
155
+ path = PurePosixPath(name)
156
+ if path.is_absolute() or ".." in path.parts:
157
+ raise PersistenceError(f"Unsafe archive member path: {name}")
158
+
159
+
160
+ def _assert_safe_graphiti_target(path: Path) -> None:
161
+ resolved = path.resolve()
162
+ if resolved.parent == resolved:
163
+ raise PersistenceError(f"Refusing to overwrite filesystem root: {resolved}")
164
+ if resolved == Path.home().resolve():
165
+ raise PersistenceError(f"Refusing to overwrite home directory: {resolved}")
166
+ lowered = resolved.name.lower()
167
+ if "graphiti" not in lowered and not lowered.endswith(".kuzu"):
168
+ raise PersistenceError(
169
+ "Refusing to overwrite graph backend path without graphiti/.kuzu in the name: "
170
+ f"{resolved}"
171
+ )
@@ -0,0 +1,171 @@
1
+ """Environment diagnostics for local setup and spike gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from dataclasses import dataclass
8
+
9
+ from people_network_memory.config import PeopleMemoryConfig
10
+ from people_network_memory.infrastructure.embeddings import EmbeddingSettings
11
+ from people_network_memory.ports.errors import PeopleMemoryError
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CheckResult:
16
+ name: str
17
+ ok: bool
18
+ detail: str | None = None
19
+ remediation: str | None = None
20
+
21
+ def to_json(self) -> dict[str, object]:
22
+ payload: dict[str, object] = {"name": self.name, "ok": self.ok}
23
+ if self.detail:
24
+ payload["detail"] = self.detail
25
+ if self.remediation:
26
+ payload["remediation"] = self.remediation
27
+ return payload
28
+
29
+
30
+ def graphiti_spike_checks(config: PeopleMemoryConfig) -> list[CheckResult]:
31
+ graphiti_package = "graphiti" + "_core"
32
+ graphiti_installed = importlib.util.find_spec(graphiti_package) is not None
33
+ kuzu_installed = importlib.util.find_spec("kuzu") is not None
34
+ checks = [
35
+ CheckResult(
36
+ name="graphiti_core_package",
37
+ ok=graphiti_installed,
38
+ detail=f"{graphiti_package} importable"
39
+ if graphiti_installed
40
+ else f"{graphiti_package} is not installed",
41
+ remediation="Install graphiti-core in an environment with compatible numpy wheels.",
42
+ ),
43
+ _graph_backend_check(config, kuzu_installed),
44
+ _embedding_settings_check(config),
45
+ _llm_settings_check(config),
46
+ _graphiti_projection_path_check(config),
47
+ CheckResult(
48
+ name="telemetry_disabled",
49
+ ok=not config.telemetry_enabled,
50
+ detail=(
51
+ "GRAPHITI_TELEMETRY_ENABLED is false"
52
+ if not config.telemetry_enabled
53
+ else "GRAPHITI_TELEMETRY_ENABLED is true"
54
+ ),
55
+ remediation="Set GRAPHITI_TELEMETRY_ENABLED=false for local personal data.",
56
+ ),
57
+ ]
58
+ return checks
59
+
60
+
61
+ def _graph_backend_check(config: PeopleMemoryConfig, kuzu_installed: bool) -> CheckResult:
62
+ if config.graph_backend_kind == "kuzu":
63
+ path = Path(config.graphiti_kuzu_path).expanduser()
64
+ nearest = path.parent
65
+ while not nearest.exists() and nearest.parent != nearest:
66
+ nearest = nearest.parent
67
+ ok = kuzu_installed and nearest.exists() and _path_is_writable(nearest)
68
+ detail = (
69
+ f"kuzu importable; database path={path}; nearest existing parent={nearest}"
70
+ if kuzu_installed
71
+ else "kuzu package is not installed"
72
+ )
73
+ return CheckResult(
74
+ name="graph_backend_kuzu",
75
+ ok=ok,
76
+ detail=detail,
77
+ remediation="Install graphiti-core[kuzu] in the Python 3.11 venv."
78
+ if not kuzu_installed
79
+ else "Create or choose a writable parent directory for PEOPLE_MEMORY_GRAPHITI_KUZU_PATH.",
80
+ )
81
+ ok = bool(config.graph_backend_url)
82
+ return CheckResult(
83
+ name=f"graph_backend_{config.graph_backend_kind}",
84
+ ok=ok,
85
+ detail=config.graph_backend_url or "PEOPLE_MEMORY_GRAPH_BACKEND_URL is not set",
86
+ remediation="Set PEOPLE_MEMORY_GRAPH_BACKEND_URL for the chosen graph backend.",
87
+ )
88
+
89
+
90
+ def _embedding_settings_check(config: PeopleMemoryConfig) -> CheckResult:
91
+ try:
92
+ settings = EmbeddingSettings.from_config(config)
93
+ except PeopleMemoryError as exc:
94
+ return CheckResult(
95
+ name="embedding_settings",
96
+ ok=False,
97
+ detail=str(exc),
98
+ remediation=(
99
+ "Configure PEOPLE_MEMORY_EMBEDDING_PROVIDER plus model/base URL/key. "
100
+ "Use provider=ollama for local embeddings or provider=volcengine/openai_compatible "
101
+ "for an OpenAI-compatible cloud endpoint."
102
+ ),
103
+ )
104
+ return CheckResult(
105
+ name="embedding_settings",
106
+ ok=True,
107
+ detail=(
108
+ f"provider={settings.provider}; model={settings.model}; "
109
+ f"base_url={settings.base_url}; dimension={settings.dimension or 'provider_default'}"
110
+ ),
111
+ )
112
+
113
+
114
+ def _llm_settings_check(config: PeopleMemoryConfig) -> CheckResult:
115
+ missing: list[str] = []
116
+ if not config.llm_provider:
117
+ missing.append("PEOPLE_MEMORY_LLM_PROVIDER")
118
+ if not config.llm_model:
119
+ missing.append("PEOPLE_MEMORY_LLM_MODEL")
120
+ if not config.llm_api_key:
121
+ missing.append("PEOPLE_MEMORY_LLM_API_KEY")
122
+ if config.llm_provider and config.llm_provider.lower() in {"openai_compatible", "openai"}:
123
+ if not config.llm_base_url:
124
+ missing.append("PEOPLE_MEMORY_LLM_BASE_URL")
125
+ if missing:
126
+ return CheckResult(
127
+ name="llm_settings",
128
+ ok=False,
129
+ detail="Missing " + ", ".join(sorted(set(missing))),
130
+ remediation=(
131
+ "Graphiti needs an LLM for episode extraction. Configure provider, model, "
132
+ "credential, and base URL when using an OpenAI-compatible gateway."
133
+ ),
134
+ )
135
+ return CheckResult(
136
+ name="llm_settings",
137
+ ok=True,
138
+ detail=(
139
+ f"provider={config.llm_provider}; model={config.llm_model}; "
140
+ f"base_url={config.llm_base_url or 'provider_default'}; "
141
+ f"response_format={config.llm_response_format}"
142
+ ),
143
+ )
144
+
145
+
146
+ def _graphiti_projection_path_check(config: PeopleMemoryConfig) -> CheckResult:
147
+ cache_path = Path(config.data_path).expanduser() / "people-memory.graphiti-cache.json"
148
+ nearest = cache_path.parent
149
+ while not nearest.exists() and nearest.parent != nearest:
150
+ nearest = nearest.parent
151
+ ok = nearest.exists() and _path_is_writable(nearest)
152
+ return CheckResult(
153
+ name="graphiti_projection_cache",
154
+ ok=ok,
155
+ detail=f"cache={cache_path}; nearest existing parent={nearest}",
156
+ remediation="Create a writable PEOPLE_MEMORY_DATA_PATH for the Graphiti projection cache."
157
+ if not ok
158
+ else None,
159
+ )
160
+
161
+
162
+ def _path_is_writable(path: Path) -> bool:
163
+ if not path.exists():
164
+ return False
165
+ probe = path / ".people-memory-write-check"
166
+ try:
167
+ probe.write_text("ok", encoding="utf-8")
168
+ probe.unlink()
169
+ return True
170
+ except OSError:
171
+ return False