@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,262 @@
1
+ """Local semantic sidecar for projection-cache recall.
2
+
3
+ The graph backend owns graph extraction and graph search. This sidecar keeps
4
+ small, evidence-backed interaction chunks searchable by embedding so vague
5
+ event-level queries do not depend only on Graphiti edge granularity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from people_network_memory.domain.models import (
19
+ Evidence,
20
+ RetrievalItem,
21
+ SensitivityLabel,
22
+ SocialInteraction,
23
+ )
24
+
25
+
26
+ EmbedTexts = Callable[[list[str]], list[list[float]]]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SemanticIndexEntry:
31
+ entry_id: str
32
+ text: str
33
+ embedding: list[float]
34
+ source_text: str
35
+ recorded_at: datetime
36
+ person_ids: list[str]
37
+ sensitivity: list[SensitivityLabel]
38
+
39
+
40
+ class SemanticProjectionIndex:
41
+ def __init__(self, path: Path) -> None:
42
+ self._path = path
43
+ self._entries: dict[str, SemanticIndexEntry] = {}
44
+ self._load()
45
+
46
+ @property
47
+ def path(self) -> Path:
48
+ return self._path
49
+
50
+ @property
51
+ def entries(self) -> list[SemanticIndexEntry]:
52
+ return list(self._entries.values())
53
+
54
+ def build_from_interactions(
55
+ self,
56
+ interactions: list[SocialInteraction],
57
+ *,
58
+ embed_texts: EmbedTexts,
59
+ batch_size: int = 16,
60
+ reset: bool = False,
61
+ ) -> dict[str, object]:
62
+ if reset:
63
+ self._entries = {}
64
+ chunks = [
65
+ _interaction_chunk(interaction)
66
+ for interaction in interactions
67
+ if _interaction_chunk(interaction)["entry_id"] not in self._entries
68
+ ]
69
+ embedded = 0
70
+ for start in range(0, len(chunks), batch_size):
71
+ batch = chunks[start : start + batch_size]
72
+ vectors = embed_texts([str(chunk["text"]) for chunk in batch])
73
+ if len(vectors) != len(batch):
74
+ raise ValueError("embedding provider returned the wrong number of vectors")
75
+ for chunk, vector in zip(batch, vectors):
76
+ labels = [
77
+ SensitivityLabel(label)
78
+ for label in chunk["sensitivity"]
79
+ if label in {item.value for item in SensitivityLabel}
80
+ ]
81
+ self._entries[str(chunk["entry_id"])] = SemanticIndexEntry(
82
+ entry_id=str(chunk["entry_id"]),
83
+ text=str(chunk["text"]),
84
+ embedding=[float(value) for value in vector],
85
+ source_text=str(chunk["source_text"]),
86
+ recorded_at=chunk["recorded_at"], # type: ignore[arg-type]
87
+ person_ids=list(chunk["person_ids"]), # type: ignore[arg-type]
88
+ sensitivity=labels,
89
+ )
90
+ embedded += 1
91
+ self._flush()
92
+ self._flush()
93
+ return {
94
+ "ok": True,
95
+ "target_index": str(self._path),
96
+ "attempted": len(interactions),
97
+ "embedded": embedded,
98
+ "skipped_existing": len(interactions) - len(chunks),
99
+ "entries": len(self._entries),
100
+ }
101
+
102
+ def search(
103
+ self,
104
+ query: str,
105
+ *,
106
+ embed_texts: EmbedTexts,
107
+ limit: int,
108
+ include_sensitive: bool = False,
109
+ ) -> list[RetrievalItem]:
110
+ if not self._entries:
111
+ return []
112
+ query_vectors = embed_texts([query])
113
+ if not query_vectors:
114
+ return []
115
+ query_vector = query_vectors[0]
116
+ scored: list[tuple[float, SemanticIndexEntry]] = []
117
+ for entry in self._entries.values():
118
+ if _blocked(entry.sensitivity, include_sensitive):
119
+ continue
120
+ score = _cosine(query_vector, entry.embedding)
121
+ if score <= 0:
122
+ continue
123
+ scored.append((score, entry))
124
+ scored.sort(key=lambda item: item[0], reverse=True)
125
+ return [
126
+ RetrievalItem(
127
+ item_id=f"semantic:{entry.entry_id}",
128
+ kind="interaction",
129
+ title="Semantic interaction match",
130
+ matched_text=entry.text,
131
+ score=max(score, 0.0) * 2.0,
132
+ why_matched="Matched by local semantic projection index.",
133
+ person_ids=entry.person_ids,
134
+ sensitivity=entry.sensitivity,
135
+ evidence=[
136
+ Evidence(
137
+ evidence_id=entry.entry_id,
138
+ source_text=entry.source_text,
139
+ recorded_at=entry.recorded_at,
140
+ )
141
+ ],
142
+ is_secondhand=SensitivityLabel.SECONDHAND in entry.sensitivity,
143
+ )
144
+ for score, entry in scored[:limit]
145
+ ]
146
+
147
+ def _load(self) -> None:
148
+ if not self._path.exists():
149
+ return
150
+ payload = json.loads(self._path.read_text(encoding="utf-8"))
151
+ for item in payload.get("entries", []):
152
+ labels = [
153
+ SensitivityLabel(label)
154
+ for label in item.get("sensitivity", [])
155
+ if label in {entry.value for entry in SensitivityLabel}
156
+ ]
157
+ self._entries[item["entry_id"]] = SemanticIndexEntry(
158
+ entry_id=item["entry_id"],
159
+ text=item["text"],
160
+ embedding=[float(value) for value in item["embedding"]],
161
+ source_text=item["source_text"],
162
+ recorded_at=datetime.fromisoformat(item["recorded_at"]),
163
+ person_ids=list(item.get("person_ids", [])),
164
+ sensitivity=labels,
165
+ )
166
+
167
+ def _flush(self) -> None:
168
+ self._path.parent.mkdir(parents=True, exist_ok=True)
169
+ payload = {
170
+ "schema_version": 1,
171
+ "entries": [
172
+ {
173
+ "entry_id": entry.entry_id,
174
+ "text": entry.text,
175
+ "embedding": entry.embedding,
176
+ "source_text": entry.source_text,
177
+ "recorded_at": entry.recorded_at.isoformat(),
178
+ "person_ids": entry.person_ids,
179
+ "sensitivity": [label.value for label in entry.sensitivity],
180
+ }
181
+ for entry in self._entries.values()
182
+ ],
183
+ }
184
+ self._path.write_text(
185
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
186
+ encoding="utf-8",
187
+ )
188
+
189
+
190
+ def _interaction_chunk(interaction: SocialInteraction) -> dict[str, object]:
191
+ participants = [participant.person.label for participant in interaction.participants]
192
+ mentioned = [
193
+ f"{mentioned.mentioned_by.label} mentioned {mentioned.person.label}"
194
+ if mentioned.mentioned_by
195
+ else mentioned.person.label
196
+ for mentioned in interaction.mentioned_people
197
+ ]
198
+ direct_facts = [
199
+ f"{fact.subject.label} {fact.predicate} {fact.value}"
200
+ for fact in interaction.direct_facts
201
+ ]
202
+ follow_ups = [follow_up.description for follow_up in interaction.follow_ups]
203
+ person_ids = sorted(
204
+ {
205
+ ref.person_id
206
+ for ref in [
207
+ *[participant.person for participant in interaction.participants],
208
+ *[mentioned.person for mentioned in interaction.mentioned_people],
209
+ ]
210
+ if ref.person_id
211
+ }
212
+ )
213
+ sensitivity = set(interaction.sensitivity)
214
+ for claim in interaction.attributed_claims:
215
+ sensitivity.update(claim.sensitivity)
216
+ for fact in interaction.direct_facts:
217
+ sensitivity.update(fact.sensitivity)
218
+ parts = [
219
+ interaction.source_text,
220
+ f"Place: {interaction.place}" if interaction.place else "",
221
+ "Participants: " + ", ".join(participants) if participants else "",
222
+ "Mentioned: " + ", ".join(mentioned) if mentioned else "",
223
+ "Topics: " + ", ".join(interaction.topics) if interaction.topics else "",
224
+ "Direct facts: " + "; ".join(direct_facts) if direct_facts else "",
225
+ "Follow-ups: " + "; ".join(follow_ups) if follow_ups else "",
226
+ ]
227
+ text = "\n".join(part for part in parts if part)
228
+ recorded_at = interaction.occurred_at or datetime.now(timezone.utc)
229
+ return {
230
+ "entry_id": hashlib.sha256(
231
+ interaction.model_dump_json().encode("utf-8")
232
+ ).hexdigest(),
233
+ "text": text,
234
+ "source_text": interaction.source_text,
235
+ "recorded_at": recorded_at,
236
+ "person_ids": person_ids,
237
+ "sensitivity": [label.value for label in sorted(sensitivity, key=lambda item: item.value)],
238
+ }
239
+
240
+
241
+ def _cosine(left: list[float], right: list[float]) -> float:
242
+ if not left or not right or len(left) != len(right):
243
+ return 0.0
244
+ dot = sum(a * b for a, b in zip(left, right))
245
+ left_norm = math.sqrt(sum(value * value for value in left))
246
+ right_norm = math.sqrt(sum(value * value for value in right))
247
+ if left_norm == 0 or right_norm == 0:
248
+ return 0.0
249
+ return dot / (left_norm * right_norm)
250
+
251
+
252
+ def _blocked(labels: list[SensitivityLabel], include_sensitive: bool) -> bool:
253
+ if include_sensitive:
254
+ return False
255
+ return any(
256
+ label
257
+ in {
258
+ SensitivityLabel.SENSITIVE,
259
+ SensitivityLabel.DO_NOT_SURFACE_UNPROMPTED,
260
+ }
261
+ for label in labels
262
+ )
@@ -0,0 +1,2 @@
1
+ """MCP transport layer."""
2
+
@@ -0,0 +1,85 @@
1
+ """Public V1 MCP tool contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from people_network_memory.domain.models import (
10
+ PersonCard,
11
+ RecordInteractionResult,
12
+ RetrievalResponse,
13
+ SocialInteraction,
14
+ StrictModel,
15
+ )
16
+
17
+
18
+ CONTRACT_VERSION = "v1"
19
+
20
+
21
+ class RetrieveNetworkContextInput(StrictModel):
22
+ query: str = Field(min_length=1)
23
+ limit: int = Field(default=10, ge=1, le=50)
24
+ include_sensitive: bool | None = None
25
+ sensitivity_policy: Literal["personal", "strict", "task_aware"] | None = None
26
+ output_context: Literal["private", "shareable"] = "private"
27
+ mode: Literal["recall", "brief"] = "recall"
28
+
29
+
30
+ class GetPersonInput(StrictModel):
31
+ person_id: str | None = Field(default=None, min_length=1)
32
+ name: str | None = Field(default=None, min_length=1)
33
+
34
+ @model_validator(mode="after")
35
+ def require_identifier(self) -> "GetPersonInput":
36
+ if not (self.person_id or self.name):
37
+ raise ValueError("get_person requires either person_id or name")
38
+ return self
39
+
40
+
41
+ class PersonCardFoundResponse(PersonCard):
42
+ found: bool = True
43
+
44
+
45
+ class PersonCardMissingResponse(StrictModel):
46
+ person_id: str | None = None
47
+ name: str | None = None
48
+ found: bool = False
49
+ missing_info: list[str] = Field(default_factory=list)
50
+ candidates: list[dict[str, Any]] = Field(default_factory=list)
51
+
52
+
53
+ def public_tool_names() -> list[str]:
54
+ return ["record_interaction", "retrieve_network_context", "get_person"]
55
+
56
+
57
+ def public_tool_contracts() -> list[dict[str, Any]]:
58
+ return [
59
+ {
60
+ "name": "record_interaction",
61
+ "version": CONTRACT_VERSION,
62
+ "description": "Capture a social interaction or memory note.",
63
+ "input_schema": SocialInteraction.model_json_schema(),
64
+ "output_schema": RecordInteractionResult.model_json_schema(),
65
+ },
66
+ {
67
+ "name": "retrieve_network_context",
68
+ "version": CONTRACT_VERSION,
69
+ "description": "Retrieve relevant social-memory context from a vague query.",
70
+ "input_schema": RetrieveNetworkContextInput.model_json_schema(),
71
+ "output_schema": RetrievalResponse.model_json_schema(),
72
+ },
73
+ {
74
+ "name": "get_person",
75
+ "version": CONTRACT_VERSION,
76
+ "description": "Return one projected person card by stable person_id, or by name as a best-effort harness fallback.",
77
+ "input_schema": GetPersonInput.model_json_schema(),
78
+ "output_schema": {
79
+ "oneOf": [
80
+ PersonCardFoundResponse.model_json_schema(),
81
+ PersonCardMissingResponse.model_json_schema(),
82
+ ]
83
+ },
84
+ },
85
+ ]
@@ -0,0 +1,133 @@
1
+ """Runtime wiring for CLI and MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from people_network_memory.application.services import (
9
+ BuildPersonCardService,
10
+ RecordInteractionService,
11
+ RetrieveContextService,
12
+ ReviewWorkflowService,
13
+ )
14
+ from people_network_memory.config import PeopleMemoryConfig
15
+ from people_network_memory.graphiti_adapter.graphiti_store import GraphitiGraphStore
16
+ from people_network_memory.infrastructure.file_store import JsonPeopleStore
17
+ from people_network_memory.infrastructure.id_generator import SequentialIdGenerator
18
+ from people_network_memory.infrastructure.in_memory_store import InMemoryPeopleStore
19
+ from people_network_memory.mcp_server.tools import PeopleMemoryTools
20
+ from people_network_memory.projection.builders import DefaultPersonProjector
21
+
22
+
23
+ @dataclass
24
+ class PeopleMemoryRuntime:
25
+ tools: PeopleMemoryTools
26
+ store: Any
27
+ record_service: RecordInteractionService
28
+ retrieve_service: RetrieveContextService
29
+ review_workflow: ReviewWorkflowService
30
+
31
+ def close(self) -> None:
32
+ close_store = getattr(self.store, "close", None)
33
+ if callable(close_store):
34
+ close_store()
35
+
36
+ def run_stdio(self) -> None:
37
+ from mcp.server.fastmcp import FastMCP
38
+
39
+ mcp = FastMCP("people-network-memory")
40
+
41
+ @mcp.tool()
42
+ def record_interaction(payload: dict[str, Any]) -> dict[str, Any]:
43
+ """Capture a messy social interaction note."""
44
+
45
+ return self.tools.record_interaction(payload)
46
+
47
+ @mcp.tool()
48
+ def retrieve_network_context(payload: dict[str, Any]) -> dict[str, Any]:
49
+ """Retrieve relevant personal network context from a vague query."""
50
+
51
+ return self.tools.retrieve_network_context(payload)
52
+
53
+ @mcp.tool()
54
+ def get_person(payload: dict[str, Any]) -> dict[str, Any]:
55
+ """Return a projected person card."""
56
+
57
+ return self.tools.get_person(payload)
58
+
59
+ mcp.run()
60
+
61
+
62
+ def build_runtime(config: PeopleMemoryConfig) -> PeopleMemoryRuntime:
63
+ config.validate_runtime()
64
+ store = _build_store(config)
65
+ ids = getattr(store, "id_generator", SequentialIdGenerator())
66
+ projector = DefaultPersonProjector()
67
+ record_service = RecordInteractionService(
68
+ memory_store=store,
69
+ identity_index=store,
70
+ review_queue=store,
71
+ id_generator=ids,
72
+ interaction_extractor=_build_interaction_extractor(config),
73
+ identity_advisor=_build_identity_advisor(config),
74
+ )
75
+ retrieve_service = RetrieveContextService(
76
+ graph_search=store,
77
+ review_queue=store,
78
+ default_sensitivity_policy=config.sensitivity_policy,
79
+ retrieval_judge=_build_retrieval_judge(config),
80
+ )
81
+ card_service = BuildPersonCardService(memory_store=store, projector=projector)
82
+ review_workflow = ReviewWorkflowService(memory_store=store, review_queue=store)
83
+ return PeopleMemoryRuntime(
84
+ tools=PeopleMemoryTools(
85
+ record_interaction_service=record_service,
86
+ retrieve_context_service=retrieve_service,
87
+ build_person_card_service=card_service,
88
+ ),
89
+ store=store,
90
+ record_service=record_service,
91
+ retrieve_service=retrieve_service,
92
+ review_workflow=review_workflow,
93
+ )
94
+
95
+
96
+ def _build_store(config: PeopleMemoryConfig) -> InMemoryPeopleStore | JsonPeopleStore | GraphitiGraphStore:
97
+ if config.backend == "graphiti":
98
+ return GraphitiGraphStore.from_config(config)
99
+ if config.backend == "local_json":
100
+ return JsonPeopleStore.from_config(config)
101
+ return InMemoryPeopleStore()
102
+
103
+
104
+ def _build_retrieval_judge(config: PeopleMemoryConfig) -> Any | None:
105
+ if config.retrieval_judge != "llm":
106
+ return None
107
+ from people_network_memory.infrastructure.llm_judge import OpenAICompatibleRetrievalJudge
108
+
109
+ return OpenAICompatibleRetrievalJudge.from_config(config)
110
+
111
+
112
+ def _build_interaction_extractor(config: PeopleMemoryConfig) -> Any | None:
113
+ if config.ingestion_extractor != "llm":
114
+ return None
115
+ if not config.has_llm_settings():
116
+ return None
117
+ from people_network_memory.infrastructure.llm_extractor import (
118
+ OpenAICompatibleInteractionExtractor,
119
+ )
120
+
121
+ return OpenAICompatibleInteractionExtractor.from_config(config)
122
+
123
+
124
+ def _build_identity_advisor(config: PeopleMemoryConfig) -> Any | None:
125
+ if config.identity_advisor != "llm":
126
+ return None
127
+ if not config.has_llm_settings():
128
+ return None
129
+ from people_network_memory.infrastructure.llm_identity_advisor import (
130
+ OpenAICompatibleIdentityAdvisor,
131
+ )
132
+
133
+ return OpenAICompatibleIdentityAdvisor.from_config(config)