@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.
- package/README.md +476 -0
- package/docs/mcp_tools.md +138 -0
- package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
- package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
- package/harness_adapters/openclaw/mcp.template.json +14 -0
- package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
- package/package.json +30 -0
- package/pyproject.toml +26 -0
- package/scripts/install_windows.ps1 +92 -0
- package/scripts/npm/people-memory.js +276 -0
- package/scripts/people_memory_bootstrap.py +247 -0
- package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
- package/scripts/run_tests_with_artifacts.ps1 +307 -0
- package/src/people_network_memory/__init__.py +6 -0
- package/src/people_network_memory/application/__init__.py +16 -0
- package/src/people_network_memory/application/normalization.py +1441 -0
- package/src/people_network_memory/application/services.py +921 -0
- package/src/people_network_memory/cli.py +1212 -0
- package/src/people_network_memory/config.py +268 -0
- package/src/people_network_memory/domain/__init__.py +55 -0
- package/src/people_network_memory/domain/identity.py +77 -0
- package/src/people_network_memory/domain/models.py +355 -0
- package/src/people_network_memory/fixtures/__init__.py +6 -0
- package/src/people_network_memory/fixtures/eval.py +398 -0
- package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
- package/src/people_network_memory/fixtures/generator.py +290 -0
- package/src/people_network_memory/fixtures/report.py +252 -0
- package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
- package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
- package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
- package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
- package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
- package/src/people_network_memory/harness_adapters/__init__.py +2 -0
- package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
- package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
- package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
- package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
- package/src/people_network_memory/infrastructure/__init__.py +2 -0
- package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
- package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
- package/src/people_network_memory/infrastructure/embeddings.py +155 -0
- package/src/people_network_memory/infrastructure/file_store.py +129 -0
- package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
- package/src/people_network_memory/infrastructure/id_generator.py +40 -0
- package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
- package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
- package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
- package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
- package/src/people_network_memory/infrastructure/redaction.py +21 -0
- package/src/people_network_memory/infrastructure/release_check.py +186 -0
- package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
- package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
- package/src/people_network_memory/mcp_server/__init__.py +2 -0
- package/src/people_network_memory/mcp_server/contracts.py +85 -0
- package/src/people_network_memory/mcp_server/runtime.py +133 -0
- package/src/people_network_memory/mcp_server/tools.py +588 -0
- package/src/people_network_memory/ports/__init__.py +2 -0
- package/src/people_network_memory/ports/errors.py +25 -0
- package/src/people_network_memory/ports/interfaces.py +103 -0
- package/src/people_network_memory/projection/__init__.py +6 -0
- 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,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
|