@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,268 @@
1
+ """Runtime configuration and validation.
2
+
3
+ This module intentionally lives outside the domain/application layers because it
4
+ reads environment variables and filesystem-like paths.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from people_network_memory.ports.errors import ConfigError
14
+
15
+
16
+ BackendKind = Literal["inmemory", "local_json", "graphiti"]
17
+ SensitivityPolicy = Literal["personal", "strict", "task_aware"]
18
+ RetrievalJudgeKind = Literal["off", "llm"]
19
+ IngestionExtractorKind = Literal["off", "llm"]
20
+ IdentityAdvisorKind = Literal["off", "llm"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class PeopleMemoryConfig:
25
+ backend: BackendKind = "inmemory"
26
+ graph_backend_kind: Literal["kuzu", "neo4j", "falkordb"] = "kuzu"
27
+ graph_backend_url: str | None = None
28
+ graphiti_kuzu_path: str = "~/.people-network-memory/graphiti.kuzu"
29
+ embedding_provider: str | None = None
30
+ embedding_base_url: str | None = None
31
+ embedding_model: str | None = None
32
+ embedding_api_key: str | None = None
33
+ embedding_dim: int | None = None
34
+ llm_provider: str | None = None
35
+ llm_base_url: str | None = None
36
+ llm_model: str | None = None
37
+ llm_api_key: str | None = None
38
+ llm_response_format: Literal["none", "json_object", "json_schema"] = "none"
39
+ ingestion_extractor: IngestionExtractorKind = "llm"
40
+ ingestion_extractor_timeout_seconds: float = 30.0
41
+ identity_advisor: IdentityAdvisorKind = "off"
42
+ identity_advisor_timeout_seconds: float = 30.0
43
+ retrieval_judge: RetrievalJudgeKind = "off"
44
+ retrieval_judge_timeout_seconds: float = 30.0
45
+ sensitivity_policy: SensitivityPolicy = "personal"
46
+ data_path: str = "~/.people-network-memory"
47
+ telemetry_enabled: bool = False
48
+ graphiti_add_timeout_seconds: float = 240.0
49
+ graphiti_search_timeout_seconds: float = 120.0
50
+ graphiti_retry_attempts: int = 3
51
+ test_mode: bool = False
52
+
53
+ @classmethod
54
+ def from_env(cls, *, test_mode: bool = False) -> "PeopleMemoryConfig":
55
+ local_env = _load_local_env()
56
+ backend = _env(
57
+ "PEOPLE_MEMORY_BACKEND",
58
+ local_env,
59
+ "inmemory" if test_mode else "local_json",
60
+ ).strip().lower()
61
+ if backend not in {"inmemory", "local_json", "graphiti"}:
62
+ raise ConfigError(f"Unsupported PEOPLE_MEMORY_BACKEND: {backend}")
63
+ llm_response_format = _env(
64
+ "PEOPLE_MEMORY_LLM_RESPONSE_FORMAT",
65
+ local_env,
66
+ "none",
67
+ ).strip().lower()
68
+ if llm_response_format not in {"none", "json_object", "json_schema"}:
69
+ raise ConfigError(
70
+ "Unsupported PEOPLE_MEMORY_LLM_RESPONSE_FORMAT: "
71
+ f"{llm_response_format}"
72
+ )
73
+ sensitivity_policy = _env(
74
+ "PEOPLE_MEMORY_SENSITIVITY_POLICY",
75
+ local_env,
76
+ "personal",
77
+ ).strip().lower()
78
+ if sensitivity_policy not in {"personal", "strict", "task_aware"}:
79
+ raise ConfigError(
80
+ "Unsupported PEOPLE_MEMORY_SENSITIVITY_POLICY: "
81
+ f"{sensitivity_policy}"
82
+ )
83
+ retrieval_judge = _env(
84
+ "PEOPLE_MEMORY_RETRIEVAL_JUDGE",
85
+ local_env,
86
+ "off",
87
+ ).strip().lower()
88
+ if retrieval_judge not in {"off", "llm"}:
89
+ raise ConfigError(
90
+ "Unsupported PEOPLE_MEMORY_RETRIEVAL_JUDGE: "
91
+ f"{retrieval_judge}"
92
+ )
93
+ ingestion_extractor = _env(
94
+ "PEOPLE_MEMORY_INGESTION_EXTRACTOR",
95
+ local_env,
96
+ "llm",
97
+ ).strip().lower()
98
+ if ingestion_extractor not in {"off", "llm"}:
99
+ raise ConfigError(
100
+ "Unsupported PEOPLE_MEMORY_INGESTION_EXTRACTOR: "
101
+ f"{ingestion_extractor}"
102
+ )
103
+ identity_advisor = _env(
104
+ "PEOPLE_MEMORY_IDENTITY_ADVISOR",
105
+ local_env,
106
+ "off",
107
+ ).strip().lower()
108
+ if identity_advisor not in {"off", "llm"}:
109
+ raise ConfigError(
110
+ "Unsupported PEOPLE_MEMORY_IDENTITY_ADVISOR: "
111
+ f"{identity_advisor}"
112
+ )
113
+ return cls(
114
+ backend=backend, # type: ignore[arg-type]
115
+ graph_backend_kind=_env("PEOPLE_MEMORY_GRAPH_BACKEND", local_env, "kuzu").strip().lower(), # type: ignore[arg-type]
116
+ graph_backend_url=_env("PEOPLE_MEMORY_GRAPH_BACKEND_URL", local_env),
117
+ graphiti_kuzu_path=_env(
118
+ "PEOPLE_MEMORY_GRAPHITI_KUZU_PATH",
119
+ local_env,
120
+ "~/.people-network-memory/graphiti.kuzu",
121
+ ),
122
+ embedding_provider=_env("PEOPLE_MEMORY_EMBEDDING_PROVIDER", local_env),
123
+ embedding_base_url=_env("PEOPLE_MEMORY_EMBEDDING_BASE_URL", local_env),
124
+ embedding_model=_env("PEOPLE_MEMORY_EMBEDDING_MODEL", local_env),
125
+ embedding_api_key=_env("PEOPLE_MEMORY_EMBEDDING_API_KEY", local_env),
126
+ embedding_dim=_optional_int(_env("PEOPLE_MEMORY_EMBEDDING_DIM", local_env)),
127
+ llm_provider=_env("PEOPLE_MEMORY_LLM_PROVIDER", local_env),
128
+ llm_base_url=_env("PEOPLE_MEMORY_LLM_BASE_URL", local_env),
129
+ llm_model=_env("PEOPLE_MEMORY_LLM_MODEL", local_env),
130
+ llm_api_key=_env("PEOPLE_MEMORY_LLM_API_KEY", local_env),
131
+ llm_response_format=llm_response_format, # type: ignore[arg-type]
132
+ ingestion_extractor=ingestion_extractor, # type: ignore[arg-type]
133
+ ingestion_extractor_timeout_seconds=_optional_float(
134
+ _env("PEOPLE_MEMORY_INGESTION_EXTRACTOR_TIMEOUT_SECONDS", local_env)
135
+ )
136
+ or 30.0,
137
+ identity_advisor=identity_advisor, # type: ignore[arg-type]
138
+ identity_advisor_timeout_seconds=_optional_float(
139
+ _env("PEOPLE_MEMORY_IDENTITY_ADVISOR_TIMEOUT_SECONDS", local_env)
140
+ )
141
+ or 30.0,
142
+ retrieval_judge=retrieval_judge, # type: ignore[arg-type]
143
+ retrieval_judge_timeout_seconds=_optional_float(
144
+ _env("PEOPLE_MEMORY_RETRIEVAL_JUDGE_TIMEOUT_SECONDS", local_env)
145
+ )
146
+ or 30.0,
147
+ sensitivity_policy=sensitivity_policy, # type: ignore[arg-type]
148
+ data_path=_env("PEOPLE_MEMORY_DATA_PATH", local_env, "~/.people-network-memory"),
149
+ telemetry_enabled=_env("GRAPHITI_TELEMETRY_ENABLED", local_env, "false").lower()
150
+ in {"1", "true", "yes"},
151
+ graphiti_add_timeout_seconds=_optional_float(
152
+ _env("PEOPLE_MEMORY_GRAPHITI_ADD_TIMEOUT_SECONDS", local_env)
153
+ )
154
+ or 240.0,
155
+ graphiti_search_timeout_seconds=_optional_float(
156
+ _env("PEOPLE_MEMORY_GRAPHITI_SEARCH_TIMEOUT_SECONDS", local_env)
157
+ )
158
+ or 120.0,
159
+ graphiti_retry_attempts=_optional_int(
160
+ _env("PEOPLE_MEMORY_GRAPHITI_RETRY_ATTEMPTS", local_env)
161
+ )
162
+ or 3,
163
+ test_mode=test_mode,
164
+ )
165
+
166
+ def validate_runtime(self) -> None:
167
+ if self.backend in {"inmemory", "local_json"}:
168
+ self._validate_retrieval_judge()
169
+ self.validate_ingestion_extractor()
170
+ self.validate_identity_advisor()
171
+ return
172
+ missing: list[str] = []
173
+ if self.graph_backend_kind not in {"kuzu", "neo4j", "falkordb"}:
174
+ missing.append("PEOPLE_MEMORY_GRAPH_BACKEND")
175
+ if self.graph_backend_kind != "kuzu" and not self.graph_backend_url:
176
+ missing.append("PEOPLE_MEMORY_GRAPH_BACKEND_URL")
177
+ if not self.embedding_provider:
178
+ missing.append("PEOPLE_MEMORY_EMBEDDING_PROVIDER")
179
+ if missing:
180
+ raise ConfigError(
181
+ "Graphiti backend requires: " + ", ".join(sorted(missing))
182
+ )
183
+ self._validate_retrieval_judge()
184
+ self.validate_ingestion_extractor()
185
+ self.validate_identity_advisor()
186
+
187
+ def _validate_retrieval_judge(self) -> None:
188
+ if self.retrieval_judge != "llm":
189
+ return
190
+ self._validate_llm_settings("LLM retrieval judge")
191
+
192
+ def validate_ingestion_extractor(self) -> None:
193
+ if self.ingestion_extractor != "llm":
194
+ return
195
+ # Capture extraction is intentionally best-effort: V1 defaults it to
196
+ # on, but a fresh install without LLM credentials must still boot and
197
+ # fall back to deterministic normalization.
198
+ return
199
+
200
+ def validate_identity_advisor(self) -> None:
201
+ if self.identity_advisor != "llm":
202
+ return
203
+ # Identity advice is a best-effort brake. Without LLM credentials the
204
+ # runtime falls back to deterministic identity policy.
205
+ return
206
+
207
+ def has_llm_settings(self) -> bool:
208
+ return bool(self.llm_base_url and self.llm_model and self.llm_api_key)
209
+
210
+ def _validate_llm_settings(self, feature_name: str) -> None:
211
+ missing: list[str] = []
212
+ if not self.llm_base_url:
213
+ missing.append("PEOPLE_MEMORY_LLM_BASE_URL")
214
+ if not self.llm_model:
215
+ missing.append("PEOPLE_MEMORY_LLM_MODEL")
216
+ if not self.llm_api_key:
217
+ missing.append("PEOPLE_MEMORY_LLM_API_KEY")
218
+ if missing:
219
+ raise ConfigError(
220
+ f"{feature_name} requires: " + ", ".join(sorted(missing))
221
+ )
222
+
223
+
224
+ def _optional_int(value: str | None) -> int | None:
225
+ if not value:
226
+ return None
227
+ return int(value)
228
+
229
+
230
+ def _optional_float(value: str | None) -> float | None:
231
+ if not value:
232
+ return None
233
+ return float(value)
234
+
235
+
236
+ def _env(name: str, local_env: dict[str, str], default: str | None = None) -> str | None:
237
+ return os.getenv(name) or local_env.get(name) or default
238
+
239
+
240
+ def _load_local_env() -> dict[str, str]:
241
+ if os.getenv("PEOPLE_MEMORY_DISABLE_LOCAL_ENV", "").lower() in {"1", "true", "yes"}:
242
+ return {}
243
+ path = os.getenv("PEOPLE_MEMORY_LOCAL_ENV_FILE")
244
+ candidates = [Path(path).expanduser()] if path else []
245
+ candidates.append(Path.cwd() / ".people-network-memory" / "local.env.json")
246
+ for candidate in candidates:
247
+ if not candidate.exists():
248
+ continue
249
+ try:
250
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
251
+ except json.JSONDecodeError as exc:
252
+ raise ConfigError(f"Invalid local env JSON at {candidate}: {exc}") from exc
253
+ if not isinstance(payload, dict):
254
+ raise ConfigError(f"Local env JSON must be an object: {candidate}")
255
+ return {
256
+ str(key): str(value)
257
+ for key, value in payload.items()
258
+ if _allowed_local_env_name(str(key)) and isinstance(value, str) and value
259
+ }
260
+ return {}
261
+
262
+
263
+ def _allowed_local_env_name(name: str) -> bool:
264
+ return (
265
+ name.startswith("PEOPLE_MEMORY_")
266
+ or name == "GRAPHITI_TELEMETRY_ENABLED"
267
+ or name == "VOLCENGINE_" + "API" + "_KEY"
268
+ )
@@ -0,0 +1,55 @@
1
+ """Pure domain models and policies."""
2
+
3
+ from people_network_memory.domain.models import (
4
+ AttributedClaim,
5
+ CaptureSummary,
6
+ CapturedPersonSummary,
7
+ ContactMethod,
8
+ DirectFact,
9
+ EducationRecord,
10
+ Evidence,
11
+ FollowUpTask,
12
+ HarnessContextRequest,
13
+ ImportantDate,
14
+ MentionedPerson,
15
+ Participant,
16
+ PersonCard,
17
+ PersonMemoryRecord,
18
+ PersonProfileUpdate,
19
+ PersonRef,
20
+ PostCaptureOpportunity,
21
+ RelationshipAssertion,
22
+ RetrievalItem,
23
+ ReviewItem,
24
+ SensitivityLabel,
25
+ SocialInteraction,
26
+ OpportunityTimeHint,
27
+ WorkHistoryRecord,
28
+ )
29
+
30
+ __all__ = [
31
+ "AttributedClaim",
32
+ "CaptureSummary",
33
+ "CapturedPersonSummary",
34
+ "ContactMethod",
35
+ "DirectFact",
36
+ "EducationRecord",
37
+ "Evidence",
38
+ "FollowUpTask",
39
+ "HarnessContextRequest",
40
+ "ImportantDate",
41
+ "MentionedPerson",
42
+ "Participant",
43
+ "PersonCard",
44
+ "PersonMemoryRecord",
45
+ "PersonProfileUpdate",
46
+ "PersonRef",
47
+ "PostCaptureOpportunity",
48
+ "RelationshipAssertion",
49
+ "RetrievalItem",
50
+ "ReviewItem",
51
+ "SensitivityLabel",
52
+ "SocialInteraction",
53
+ "OpportunityTimeHint",
54
+ "WorkHistoryRecord",
55
+ ]
@@ -0,0 +1,77 @@
1
+ """Conservative identity policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from people_network_memory.domain.models import IdentityCandidate, PersonRef
9
+
10
+
11
+ IdentityDecisionKind = Literal["link", "review", "provisional"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class IdentityDecision:
16
+ kind: IdentityDecisionKind
17
+ person_id: str | None = None
18
+ reason: str = ""
19
+ candidates: tuple[IdentityCandidate, ...] = ()
20
+
21
+
22
+ def choose_identity(ref: PersonRef, candidates: list[IdentityCandidate]) -> IdentityDecision:
23
+ """Apply the V1 conservative identity policy.
24
+
25
+ Exact user-provided IDs link directly. Exact email/phone matches can link.
26
+ Ambiguous name/company matches go to review. Unknown people become provisional.
27
+ """
28
+
29
+ if ref.person_id:
30
+ return IdentityDecision(kind="link", person_id=ref.person_id, reason="explicit person_id")
31
+ exact = [candidate for candidate in candidates if candidate.exact_identifier_match]
32
+ if len(exact) == 1:
33
+ return IdentityDecision(
34
+ kind="link",
35
+ person_id=exact[0].person_id,
36
+ reason="single exact email/phone match",
37
+ candidates=tuple(exact),
38
+ )
39
+ exact_name = [
40
+ candidate
41
+ for candidate in candidates
42
+ if candidate.exact_name_match
43
+ or _normal_label(candidate.display_name) == _normal_label(ref.label)
44
+ ]
45
+ if len(exact_name) == 1:
46
+ return IdentityDecision(
47
+ kind="link",
48
+ person_id=exact_name[0].person_id,
49
+ reason="single exact name or alias match",
50
+ candidates=tuple(exact_name),
51
+ )
52
+ if len(exact_name) > 1:
53
+ return IdentityDecision(
54
+ kind="review",
55
+ reason="multiple exact name or alias matches",
56
+ candidates=tuple(exact_name),
57
+ )
58
+ strong = [candidate for candidate in candidates if candidate.score >= 0.82]
59
+ if len(strong) == 1 and (ref.email or ref.phone):
60
+ return IdentityDecision(
61
+ kind="link",
62
+ person_id=strong[0].person_id,
63
+ reason="strong match with identifier hint",
64
+ candidates=tuple(strong),
65
+ )
66
+ reviewable = [candidate for candidate in candidates if candidate.score >= 0.75]
67
+ if reviewable:
68
+ return IdentityDecision(
69
+ kind="review",
70
+ reason="ambiguous high-confidence person reference",
71
+ candidates=tuple(reviewable),
72
+ )
73
+ return IdentityDecision(kind="provisional", reason="no existing candidate")
74
+
75
+
76
+ def _normal_label(value: str) -> str:
77
+ return " ".join(value.casefold().split())