@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,577 @@
|
|
|
1
|
+
"""OpenClaw install helpers.
|
|
2
|
+
|
|
3
|
+
This module owns filesystem writes for the OpenClaw adapter. Product logic stays
|
|
4
|
+
outside this layer; the installer only materializes MCP config and skill text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from people_network_memory.ports.errors import HarnessInstallError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SERVER_ID = "people-network-memory"
|
|
20
|
+
SKILL_DIR_NAME = "ppl"
|
|
21
|
+
LEGACY_SKILL_DIR_NAMES = ("people-network-memory",)
|
|
22
|
+
WORKSPACE_GUIDANCE_START = "<!-- people-network-memory:start -->"
|
|
23
|
+
WORKSPACE_GUIDANCE_END = "<!-- people-network-memory:end -->"
|
|
24
|
+
|
|
25
|
+
OPENCLAW_WORKSPACE_GUIDANCE = f"""{WORKSPACE_GUIDANCE_START}
|
|
26
|
+
## People Network Memory MCP
|
|
27
|
+
|
|
28
|
+
For requests about remembering, recording, recalling, or showing information about people, social interactions, person cards, relationships, introductions, meetings, dinners, coffee chats, calls, follow-ups, or who knows whom, use the `people-network-memory` MCP server before OpenClaw file memory.
|
|
29
|
+
|
|
30
|
+
Explicit trigger:
|
|
31
|
+
- If the user starts a prompt with `/ppl`, `/people_network_memory`, or `/people-network-memory`, treat that as an explicit request to use this MCP server. Remove only that trigger prefix before saving `source_text`; preserve the rest of the user's wording exactly.
|
|
32
|
+
|
|
33
|
+
Priority rules:
|
|
34
|
+
- Use `people-network-memory__record_interaction` for capture prompts such as "remember", "met", "spoke with", "had coffee", "had dinner", "add this person", "记一下", "见了", "认识了", or any new social note.
|
|
35
|
+
- Use `people-network-memory__get_person` for person-card requests.
|
|
36
|
+
- Use `people-network-memory__retrieve_network_context` for vague recall, relationship questions, and pre-meeting briefs.
|
|
37
|
+
- Do not satisfy people/network memory requests by only reading or writing `MEMORY.md`, `memory/YYYY-MM-DD.md`, or other files.
|
|
38
|
+
- Do not use `read`, `write`, `edit`, or `exec` as a substitute for the people-network-memory MCP tools.
|
|
39
|
+
- If you also update OpenClaw memory files, do it only after the MCP tool succeeds and report that file update as a secondary reversible action.
|
|
40
|
+
- If the MCP tool is unavailable or fails, say that the people-network-memory MCP lookup/save failed and include the tool error instead of pretending the file memory is the source of truth.
|
|
41
|
+
{WORKSPACE_GUIDANCE_END}"""
|
|
42
|
+
|
|
43
|
+
SKILL_MARKDOWN = """---
|
|
44
|
+
name: "ppl"
|
|
45
|
+
description: "Use when users want to remember people, record social interactions, recall vague personal-network context, or prepare a pre-meeting brief."
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# People Network Memory
|
|
49
|
+
|
|
50
|
+
## Goal
|
|
51
|
+
|
|
52
|
+
Help the user record and recall social memory with low friction. Treat messy notes as capture tasks, not as forms.
|
|
53
|
+
|
|
54
|
+
## Tool Routing
|
|
55
|
+
|
|
56
|
+
- If the user starts a prompt with `/ppl`, `/people_network_memory`, or `/people-network-memory`, treat that as an explicit request to use this skill and its MCP tools. Remove only that trigger prefix before saving `source_text`; preserve the rest of the user's wording exactly.
|
|
57
|
+
- This skill's tools are MCP tools. In OpenClaw they may appear as `people-network-memory__record_interaction`, `people-network-memory__retrieve_network_context`, and `people-network-memory__get_person`. Do not call a tool named `ppl` or `people-network-memory`; `ppl` is the skill name and `people-network-memory` is the MCP server namespace, not a callable tool.
|
|
58
|
+
- The people/network graph is the source of truth for people, interactions, relationships, claims, follow-ups, and person cards. Do not satisfy a people-memory request by only reading or writing OpenClaw `MEMORY.md`, daily notes, or other files.
|
|
59
|
+
- Never use OpenClaw `read`, `write`, or `edit` as a substitute for the MCP tools. If you also update harness memory, do it only after the MCP tool succeeds, report it as a secondary reversible action, and keep MCP evidence as the source of truth.
|
|
60
|
+
- Use `record_interaction` first when the user says "remember", "met", "spoke with", "had coffee/dinner/call", "add this person", or otherwise describes a meeting, dinner, coffee, call, event, intro, conversation, or new social note.
|
|
61
|
+
- Do not call `get_person` first for a capture prompt just because a person's name appears. Capture the note with `record_interaction`, then call retrieval or `get_person` only if the user also asks for a card/brief.
|
|
62
|
+
- Use `retrieve_network_context` for vague recall, relationship questions, and pre-meeting briefs.
|
|
63
|
+
- Use `get_person` when the user asks for one person card by known ID, by visible name as a fallback, or after retrieval identifies a person.
|
|
64
|
+
- For every person-card request, call `get_person` again even if the same card was shown earlier in the chat. Do not answer "same as above" from conversation memory unless `get_person` fails and you clearly say the MCP lookup failed.
|
|
65
|
+
- If `get_person` returns `found=false` with `ambiguous=true` or a non-empty `candidates` list, do not choose one candidate or answer from only the first candidate. Present all candidates with their `person_id` and distinguishing details, then ask the user to clarify if needed.
|
|
66
|
+
- If `retrieve_network_context` returns `ambiguous=true`, its `answer_policy` and `final_answer_instruction` are mandatory. Start by listing every candidate; never start with "yes", "no", or a single candidate answer. Use `candidate_results` to state candidate-specific evidence only after the ambiguity is shown.
|
|
67
|
+
- For person-card requests, render the `get_person` JSON result directly inline in chat. Do not create a canvas/embed/browser page or ask the user to open a local URL unless the user explicitly asks for a visual UI.
|
|
68
|
+
- If a canvas, browser, gateway, or hosted embed returns `Unauthorized`, retry with `get_person` or `retrieve_network_context` and present the card inline instead of returning the raw auth error.
|
|
69
|
+
- Never call `record_interaction` to recreate or "fix" a memory just because retrieval looks incomplete. First retry retrieval with more specific constraints or call `get_person`; only record again when the user explicitly provides a new note to save or asks you to re-save it.
|
|
70
|
+
|
|
71
|
+
## Capture Rules
|
|
72
|
+
|
|
73
|
+
- Preserve the user's original wording in `source_text`.
|
|
74
|
+
- Separate participants from mentioned people.
|
|
75
|
+
- Store "A said B ..." as an attributed claim, not as a direct fact about B.
|
|
76
|
+
- Store "A knows B", "A works with B", and "A introduced me to B" as relationships.
|
|
77
|
+
- If a relationship names a new person who was not present, allow a provisional second-degree card and do not interrupt capture.
|
|
78
|
+
- Include dates, places, topics, commitments, follow-ups, contact info, interests, and relationship assertions when visible.
|
|
79
|
+
- Preserve Chinese names and aliases exactly. When the user writes an explicit alias such as `胡八一(胖子)`, `胡八一又叫胖子`, or `胡八一绰号胖子`, treat `胡八一` as the canonical display name and `胖子` as an alias unless the tool returns an identity review. Do not shorten names or remove visible prefixes such as `测试`.
|
|
80
|
+
- Do not rewrite hard-to-parse Chinese notes before capture. Preserve `source_text`; the MCP may use a tool-side LLM extractor before deterministic validation.
|
|
81
|
+
- For simple capture, the safest payload is only `{"source_text": "<the user's exact note>"}`. Add structured fields only when you are confident; the MCP can extract structure from `source_text`.
|
|
82
|
+
- Do not interrupt simple capture. Ask only for high-impact ambiguity such as duplicate identity, unclear person reference, or sensitive secondhand claims.
|
|
83
|
+
- Never invent missing personal facts.
|
|
84
|
+
|
|
85
|
+
## Post-Capture Autonomy Rules
|
|
86
|
+
|
|
87
|
+
- After every `record_interaction`, read the full response, especially `post_capture_opportunities`, `harness_context_requests`, `captured_follow_ups`, `needs_review`, and `evidence`.
|
|
88
|
+
- Always show the user a brief capture summary after saving. Prefer the tool's `capture_summary` if present: people created/updated, key details, follow-ups, review items, and the correction hint.
|
|
89
|
+
- The summary is a user-facing correction checkpoint, not a long report. Keep it short enough that the user can quickly spot wrong identities, wrong facts, or unwanted follow-ups.
|
|
90
|
+
- When an existing person was updated, say so plainly, such as "Updated existing card: Alice Zhang." Add a quiet correction path: "If this was a different Alice, say 'different person'."
|
|
91
|
+
- Use your own memory and available tools to proactively execute low-risk, private/local, easily reversible next steps when the response recommends `execute_if_supported`.
|
|
92
|
+
- Examples: create a private reminder/task for an open loop, search harness memory for related people/events, draft but do not send a follow-up message, or prepare a private shortlist/context note.
|
|
93
|
+
- For captured follow-ups, create a one-time reminder/task by default. Do not create a recurring reminder unless the user explicitly says the obligation repeats, such as weekly, every month, or every Monday.
|
|
94
|
+
- Do not ask the user to choose between reminder mechanisms for a normal private follow-up. Pick the lowest-friction reversible local option available: timed notification first, then one-time task/reminder, then private task-list item.
|
|
95
|
+
- Do not ask "should I set a reminder?" when `post_capture_opportunities` says `recommended_default=execute_if_supported`, `requires_confirmation=false`, and the action is private/reversible. Execute the reminder/task if a suitable tool exists, then report `Action`, `Why`, and `Undo`.
|
|
96
|
+
- If no reminder/task tool exists, say you could not create the reminder because no suitable tool is available. Do not ask for permission before discovering whether a suitable private reminder mechanism exists.
|
|
97
|
+
- If the harness only exposes cron-style reminders, still make one-off follow-ups self-expiring when the tool supports it; otherwise clearly say the reminder is recurring and ask before creating it.
|
|
98
|
+
- Report every autonomous action you took with `Action:`, `Why:`, and `Undo:`. If you cannot name the exact undo command, explain the safest practical undo path, such as deleting the reminder/task or asking you to remove it.
|
|
99
|
+
- Ask before actions that are externally visible, irreversible or hard to reverse, privacy-sensitive, costly, or destructive, such as sending messages, creating calendar invites with other people, merging ambiguous identities, deleting data, or publishing/sharing information.
|
|
100
|
+
- If opportunity time hints are vague, use a reasonable private reminder default when the action is reversible; ask only when the timing materially matters.
|
|
101
|
+
|
|
102
|
+
## Retrieval Rules
|
|
103
|
+
|
|
104
|
+
- Use retrieval before answering from memory.
|
|
105
|
+
- Return evidence: source note, date, speaker attribution, and why the result matched.
|
|
106
|
+
- Default private recall may include sensitive context because this is a single-user memory tool.
|
|
107
|
+
- Use `sensitivity_policy="task_aware"` and `output_context="shareable"` when drafting messages, intros, emails, or anything that may be shown to others.
|
|
108
|
+
- Use `sensitivity_policy="strict"` when the user asks for a privacy-safe summary.
|
|
109
|
+
- Treat `retrieve_network_context` results as an evidence pack, not as a finished answer.
|
|
110
|
+
- Prefer the tool's ranked order, because the tool may already have used deterministic rules plus an optional tool-side LLM judge to rerank candidates.
|
|
111
|
+
- Do not use the harness model's general memory to override source-backed tool results. If the tool evidence is weak or conflicting, say so.
|
|
112
|
+
- For "who mentioned X" queries, answer with the speaker or source person who mentioned X, not X unless the evidence says X mentioned themself.
|
|
113
|
+
- For follow-up or promise queries, prioritize results whose kind is `follow_up`; do not answer from a loosely related person fact.
|
|
114
|
+
- For profile-style queries with multiple constraints, prefer results that satisfy all constraints on the same person, such as company plus topic.
|
|
115
|
+
- When several people genuinely match, present a short ranked shortlist with reasons instead of pretending there is only one answer.
|
|
116
|
+
- For exact-name questions where multiple person cards share the same display name, preserve the ambiguity and list every matching card. A familiar alias from earlier chat or a richer profile match is not enough to collapse a full-name query to one person; do not say "you probably mean" one candidate.
|
|
117
|
+
- For ambiguous yes/no questions, do not answer "yes" or "no" globally. Say which candidate has the supporting evidence and which candidate has no matching evidence.
|
|
118
|
+
- If the result includes `needs_review`, `missing_info`, low confidence, or ambiguous identity, surface that uncertainty briefly.
|
|
119
|
+
- If expected people are missing from retrieval, do not reconstruct and re-record them from chat context or OpenClaw file memory. Say the MCP result looks incomplete, retry with a narrower query, and ask before saving any duplicate note.
|
|
120
|
+
|
|
121
|
+
## Harness Reasoning Rules
|
|
122
|
+
|
|
123
|
+
- Use the harness LLM after retrieval to synthesize, compare, and curate answers for the user's current task.
|
|
124
|
+
- Treat this MCP as the source of truth for the user's people/network memory: people, interactions, relationships, claims, follow-ups, preferences about other people, and evidence from social notes.
|
|
125
|
+
- Treat the harness's own memory as the source of truth for the user's stable preferences, working style, current projects, communication style, constraints, and prior instructions.
|
|
126
|
+
- Combine both memory sources deliberately: first retrieve people/network context from this MCP, then apply the harness's user-memory context to decide what is useful, tactful, timely, or aligned with the user's goals.
|
|
127
|
+
- If the harness memory and MCP evidence conflict about a person or interaction, prefer MCP evidence for social-memory facts and mention the conflict briefly if it matters.
|
|
128
|
+
- If the MCP has no evidence about a person but the harness remembers user-level context that helps the task, say the social graph has no matching evidence and clearly separate the harness-memory-based inference.
|
|
129
|
+
- For planning tasks such as dinner groups, intros, meeting prep, hiring help, or outreach, call `retrieve_network_context` with the user's full task constraints, then reason over the returned evidence.
|
|
130
|
+
- When choosing people for a task, explain each recommendation using source-backed factors such as relationship, location, interests, recent interactions, open follow-ups, and mutual connections.
|
|
131
|
+
- Call `get_person` for shortlisted people when the answer needs fuller card details, work history, education, contact info, preferences, or relationship context.
|
|
132
|
+
- Separate direct facts from attributed claims in the final answer. Phrase claims as "Alice said..." or "according to the note..." unless confirmed elsewhere.
|
|
133
|
+
- Keep private answers useful and direct. For shareable drafts, omit sensitive or secondhand context unless the user explicitly asks to include it.
|
|
134
|
+
- If retrieval returns no useful evidence, say that the memory graph does not have enough information and ask one concise follow-up question only when it would unblock the task.
|
|
135
|
+
|
|
136
|
+
## Examples
|
|
137
|
+
|
|
138
|
+
User: "Met Alice at Blue Bottle. We talked about robotics hiring. Alice said Bob may leave Tencent. I promised to send Alice two founder contacts next week."
|
|
139
|
+
|
|
140
|
+
First use `record_interaction` (`people-network-memory__record_interaction` if OpenClaw shows namespaced MCP tools) with Alice as participant, Bob as mentioned person, robotics hiring as topic, the Bob note as an attributed claim from Alice, and the founder-contact promise as a follow-up. Do not write this only to OpenClaw memory files. Then show a brief capture summary from `capture_summary`, including created/updated people and the correction hint. Inspect `post_capture_opportunities`: if a private reminder/task tool is available, create a reversible reminder for the founder-contact promise and report it with `Action:`, `Why:`, and `Undo:`; search harness memory for related founder contacts if useful; draft but do not send any message without confirmation. Only after that, use `retrieve_network_context` or `get_person` if the user asks for recall, a brief, or a card.
|
|
141
|
+
|
|
142
|
+
User: "Who was the robotics person I met at the coffee shop?"
|
|
143
|
+
|
|
144
|
+
Use `retrieve_network_context` with the original vague query.
|
|
145
|
+
|
|
146
|
+
User: "Brief me before I meet Alice."
|
|
147
|
+
|
|
148
|
+
Use `retrieve_network_context` with `mode="brief"` and include recent interactions, open follow-ups, claims, and relationship context.
|
|
149
|
+
|
|
150
|
+
User: "Show me Alice Zhang's person card."
|
|
151
|
+
|
|
152
|
+
Use `get_person` (`people-network-memory__get_person` if OpenClaw shows namespaced MCP tools) with Alice's name or stable `person_id`, then format the returned card inline in the chat. Do not use canvas, browser, hosted embeds, local URLs, or OpenClaw memory files for the default card view.
|
|
153
|
+
|
|
154
|
+
User: "Plan a dinner in Shanghai for five people who would enjoy robotics and founder intros."
|
|
155
|
+
|
|
156
|
+
Use `retrieve_network_context` with the full task as the query. Shortlist candidates from the ranked results, call `get_person` for any unclear top candidates, then combine the MCP evidence with the harness's memory of the user's preferences, budget, style, current goals, and constraints. Recommend the group with evidence-backed reasons and note any uncertainty.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class OpenClawInstallResult:
|
|
162
|
+
ok: bool
|
|
163
|
+
dry_run: bool
|
|
164
|
+
home: str
|
|
165
|
+
mcp_json: str
|
|
166
|
+
openclaw_config: str | None
|
|
167
|
+
skill_path: str
|
|
168
|
+
workspace_guidance_path: str
|
|
169
|
+
server_action: Literal["created", "updated", "unchanged"]
|
|
170
|
+
skill_action: Literal["created", "updated", "unchanged"]
|
|
171
|
+
workspace_guidance_action: Literal["created", "updated", "unchanged"]
|
|
172
|
+
mcp_entry: dict[str, Any]
|
|
173
|
+
|
|
174
|
+
def to_json(self) -> dict[str, Any]:
|
|
175
|
+
return {
|
|
176
|
+
"ok": self.ok,
|
|
177
|
+
"dry_run": self.dry_run,
|
|
178
|
+
"home": self.home,
|
|
179
|
+
"mcp_json": self.mcp_json,
|
|
180
|
+
"openclaw_config": self.openclaw_config,
|
|
181
|
+
"skill_path": self.skill_path,
|
|
182
|
+
"workspace_guidance_path": self.workspace_guidance_path,
|
|
183
|
+
"server_action": self.server_action,
|
|
184
|
+
"skill_action": self.skill_action,
|
|
185
|
+
"workspace_guidance_action": self.workspace_guidance_action,
|
|
186
|
+
"mcp_entry": self.mcp_entry,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class OpenClawCheck:
|
|
192
|
+
name: str
|
|
193
|
+
ok: bool
|
|
194
|
+
detail: str
|
|
195
|
+
remediation: str | None = None
|
|
196
|
+
|
|
197
|
+
def to_json(self) -> dict[str, object]:
|
|
198
|
+
payload: dict[str, object] = {
|
|
199
|
+
"name": self.name,
|
|
200
|
+
"ok": self.ok,
|
|
201
|
+
"detail": self.detail,
|
|
202
|
+
}
|
|
203
|
+
if self.remediation:
|
|
204
|
+
payload["remediation"] = self.remediation
|
|
205
|
+
return payload
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def install_openclaw_adapter(
|
|
209
|
+
*,
|
|
210
|
+
home: str | Path,
|
|
211
|
+
command: str | None = None,
|
|
212
|
+
command_args: list[str] | None = None,
|
|
213
|
+
backend: str = "local_json",
|
|
214
|
+
ingestion_extractor: str = "llm",
|
|
215
|
+
identity_advisor: str = "llm",
|
|
216
|
+
managed_bootstrap: bool = False,
|
|
217
|
+
auto_update: bool = False,
|
|
218
|
+
dry_run: bool = False,
|
|
219
|
+
) -> OpenClawInstallResult:
|
|
220
|
+
resolved_home = Path(home).expanduser()
|
|
221
|
+
mcp_path = resolved_home / "mcp.json"
|
|
222
|
+
openclaw_config_path = resolved_home / "openclaw.json"
|
|
223
|
+
skill_path = resolved_home / "skills" / SKILL_DIR_NAME / "SKILL.md"
|
|
224
|
+
workspace_guidance_path = resolved_home / "workspace" / "AGENTS.md"
|
|
225
|
+
mcp_data = _load_or_initialize_mcp_json(mcp_path)
|
|
226
|
+
mcp_servers = _mcp_servers(mcp_data, mcp_path)
|
|
227
|
+
openclaw_data = None
|
|
228
|
+
openclaw_servers = None
|
|
229
|
+
if openclaw_config_path.exists():
|
|
230
|
+
openclaw_data = _load_or_initialize_openclaw_json(openclaw_config_path)
|
|
231
|
+
openclaw_servers = _openclaw_mcp_servers(openclaw_data, openclaw_config_path)
|
|
232
|
+
|
|
233
|
+
entry = build_mcp_entry(
|
|
234
|
+
command=command,
|
|
235
|
+
command_args=command_args,
|
|
236
|
+
backend=backend,
|
|
237
|
+
ingestion_extractor=ingestion_extractor,
|
|
238
|
+
identity_advisor=identity_advisor,
|
|
239
|
+
managed_bootstrap=managed_bootstrap,
|
|
240
|
+
auto_update=auto_update,
|
|
241
|
+
)
|
|
242
|
+
existing_entry = mcp_servers.get(SERVER_ID)
|
|
243
|
+
merged_entry = _merge_entry(existing_entry, entry)
|
|
244
|
+
legacy_action = _entry_action(existing_entry, merged_entry)
|
|
245
|
+
mcp_servers[SERVER_ID] = merged_entry
|
|
246
|
+
server_action = legacy_action
|
|
247
|
+
if openclaw_servers is not None:
|
|
248
|
+
existing_openclaw_entry = openclaw_servers.get(SERVER_ID)
|
|
249
|
+
openclaw_entry = _merge_entry(existing_openclaw_entry, entry)
|
|
250
|
+
server_action = _entry_action(existing_openclaw_entry, openclaw_entry)
|
|
251
|
+
openclaw_servers[SERVER_ID] = openclaw_entry
|
|
252
|
+
merged_entry = openclaw_entry
|
|
253
|
+
|
|
254
|
+
existing_skill = skill_path.read_text(encoding="utf-8") if skill_path.exists() else None
|
|
255
|
+
if existing_skill is None:
|
|
256
|
+
skill_action: Literal["created", "updated", "unchanged"] = "created"
|
|
257
|
+
elif existing_skill == SKILL_MARKDOWN:
|
|
258
|
+
skill_action = "unchanged"
|
|
259
|
+
else:
|
|
260
|
+
skill_action = "updated"
|
|
261
|
+
existing_guidance = (
|
|
262
|
+
workspace_guidance_path.read_text(encoding="utf-8")
|
|
263
|
+
if workspace_guidance_path.exists()
|
|
264
|
+
else None
|
|
265
|
+
)
|
|
266
|
+
updated_guidance = _upsert_managed_workspace_guidance(existing_guidance)
|
|
267
|
+
if existing_guidance is None:
|
|
268
|
+
workspace_guidance_action: Literal["created", "updated", "unchanged"] = "created"
|
|
269
|
+
elif existing_guidance == updated_guidance:
|
|
270
|
+
workspace_guidance_action = "unchanged"
|
|
271
|
+
else:
|
|
272
|
+
workspace_guidance_action = "updated"
|
|
273
|
+
|
|
274
|
+
if not dry_run:
|
|
275
|
+
resolved_home.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
mcp_path.write_text(
|
|
277
|
+
json.dumps(mcp_data, ensure_ascii=False, indent=2, sort_keys=True),
|
|
278
|
+
encoding="utf-8",
|
|
279
|
+
)
|
|
280
|
+
if openclaw_data is not None:
|
|
281
|
+
openclaw_config_path.write_text(
|
|
282
|
+
json.dumps(openclaw_data, ensure_ascii=False, indent=2),
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
)
|
|
285
|
+
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
skill_path.write_text(SKILL_MARKDOWN, encoding="utf-8")
|
|
287
|
+
_remove_legacy_skill_dirs(resolved_home)
|
|
288
|
+
workspace_guidance_path.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
workspace_guidance_path.write_text(updated_guidance, encoding="utf-8")
|
|
290
|
+
|
|
291
|
+
return OpenClawInstallResult(
|
|
292
|
+
ok=True,
|
|
293
|
+
dry_run=dry_run,
|
|
294
|
+
home=str(resolved_home),
|
|
295
|
+
mcp_json=str(mcp_path),
|
|
296
|
+
openclaw_config=str(openclaw_config_path) if openclaw_config_path.exists() else None,
|
|
297
|
+
skill_path=str(skill_path),
|
|
298
|
+
workspace_guidance_path=str(workspace_guidance_path),
|
|
299
|
+
server_action=server_action,
|
|
300
|
+
skill_action=skill_action,
|
|
301
|
+
workspace_guidance_action=workspace_guidance_action,
|
|
302
|
+
mcp_entry=merged_entry,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def openclaw_checks(home: str | Path) -> list[OpenClawCheck]:
|
|
307
|
+
resolved_home = Path(home).expanduser()
|
|
308
|
+
mcp_path = resolved_home / "mcp.json"
|
|
309
|
+
openclaw_config_path = resolved_home / "openclaw.json"
|
|
310
|
+
skill_path = resolved_home / "skills" / SKILL_DIR_NAME / "SKILL.md"
|
|
311
|
+
workspace_guidance_path = resolved_home / "workspace" / "AGENTS.md"
|
|
312
|
+
checks = [
|
|
313
|
+
OpenClawCheck(
|
|
314
|
+
name="openclaw_home",
|
|
315
|
+
ok=resolved_home.exists(),
|
|
316
|
+
detail=str(resolved_home),
|
|
317
|
+
remediation="Run `people-memory install-openclaw` or create the OpenClaw home directory.",
|
|
318
|
+
)
|
|
319
|
+
]
|
|
320
|
+
try:
|
|
321
|
+
if openclaw_config_path.exists():
|
|
322
|
+
mcp_data = _load_or_initialize_openclaw_json(openclaw_config_path)
|
|
323
|
+
mcp_servers = _openclaw_mcp_servers(mcp_data, openclaw_config_path)
|
|
324
|
+
config_path = openclaw_config_path
|
|
325
|
+
else:
|
|
326
|
+
mcp_data = _load_or_initialize_mcp_json(mcp_path)
|
|
327
|
+
mcp_servers = _mcp_servers(mcp_data, mcp_path)
|
|
328
|
+
config_path = mcp_path
|
|
329
|
+
entry = mcp_servers.get(SERVER_ID)
|
|
330
|
+
checks.append(
|
|
331
|
+
OpenClawCheck(
|
|
332
|
+
name="openclaw_mcp_server",
|
|
333
|
+
ok=entry is not None,
|
|
334
|
+
detail=str(config_path) if entry is not None else f"{SERVER_ID} missing in {config_path}",
|
|
335
|
+
remediation="Run `people-memory install-openclaw`.",
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
except HarnessInstallError as exc:
|
|
339
|
+
checks.append(
|
|
340
|
+
OpenClawCheck(
|
|
341
|
+
name="openclaw_mcp_server",
|
|
342
|
+
ok=False,
|
|
343
|
+
detail=str(exc),
|
|
344
|
+
remediation="Fix or replace the OpenClaw mcp.json file.",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
checks.append(
|
|
348
|
+
OpenClawCheck(
|
|
349
|
+
name="openclaw_skill",
|
|
350
|
+
ok=skill_path.exists(),
|
|
351
|
+
detail=str(skill_path) if skill_path.exists() else f"missing {skill_path}",
|
|
352
|
+
remediation="Run `people-memory install-openclaw`.",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
guidance_ok = False
|
|
356
|
+
if workspace_guidance_path.exists():
|
|
357
|
+
guidance = workspace_guidance_path.read_text(encoding="utf-8")
|
|
358
|
+
guidance_ok = WORKSPACE_GUIDANCE_START in guidance and WORKSPACE_GUIDANCE_END in guidance
|
|
359
|
+
checks.append(
|
|
360
|
+
OpenClawCheck(
|
|
361
|
+
name="openclaw_workspace_guidance",
|
|
362
|
+
ok=guidance_ok,
|
|
363
|
+
detail=(
|
|
364
|
+
str(workspace_guidance_path)
|
|
365
|
+
if guidance_ok
|
|
366
|
+
else f"missing managed guidance in {workspace_guidance_path}"
|
|
367
|
+
),
|
|
368
|
+
remediation="Run `people-memory install-openclaw`.",
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
return checks
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def build_mcp_entry(
|
|
375
|
+
*,
|
|
376
|
+
command: str | None = None,
|
|
377
|
+
command_args: list[str] | None = None,
|
|
378
|
+
backend: str = "local_json",
|
|
379
|
+
ingestion_extractor: str = "llm",
|
|
380
|
+
identity_advisor: str = "llm",
|
|
381
|
+
managed_bootstrap: bool = False,
|
|
382
|
+
auto_update: bool = False,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
env = _base_env(
|
|
385
|
+
backend=backend,
|
|
386
|
+
ingestion_extractor=ingestion_extractor,
|
|
387
|
+
identity_advisor=identity_advisor,
|
|
388
|
+
)
|
|
389
|
+
if managed_bootstrap:
|
|
390
|
+
resolved_command, resolved_args = _managed_bootstrap_command(
|
|
391
|
+
backend=backend,
|
|
392
|
+
auto_update=auto_update,
|
|
393
|
+
command=command,
|
|
394
|
+
command_args=command_args,
|
|
395
|
+
)
|
|
396
|
+
return {
|
|
397
|
+
"command": resolved_command,
|
|
398
|
+
"args": resolved_args,
|
|
399
|
+
"env": env,
|
|
400
|
+
}
|
|
401
|
+
resolved_command = command or sys.executable
|
|
402
|
+
resolved_args = command_args if command_args is not None else _default_command_args(resolved_command)
|
|
403
|
+
return {
|
|
404
|
+
"command": resolved_command,
|
|
405
|
+
"args": resolved_args,
|
|
406
|
+
"env": env,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _base_env(
|
|
411
|
+
*, backend: str, ingestion_extractor: str, identity_advisor: str
|
|
412
|
+
) -> dict[str, str]:
|
|
413
|
+
env = {
|
|
414
|
+
"PEOPLE_MEMORY_BACKEND": backend,
|
|
415
|
+
"PEOPLE_MEMORY_INGESTION_EXTRACTOR": ingestion_extractor,
|
|
416
|
+
"PEOPLE_MEMORY_IDENTITY_ADVISOR": identity_advisor,
|
|
417
|
+
"PEOPLE_MEMORY_SENSITIVITY_POLICY": "personal",
|
|
418
|
+
"GRAPHITI_TELEMETRY_ENABLED": "false",
|
|
419
|
+
}
|
|
420
|
+
return env
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _load_or_initialize_mcp_json(path: Path) -> dict[str, Any]:
|
|
424
|
+
if not path.exists():
|
|
425
|
+
return {"mcpServers": {}}
|
|
426
|
+
try:
|
|
427
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
428
|
+
except json.JSONDecodeError as exc:
|
|
429
|
+
raise HarnessInstallError(f"Invalid JSON in {path}: {exc}") from exc
|
|
430
|
+
if not isinstance(payload, dict):
|
|
431
|
+
raise HarnessInstallError(f"{path} must contain a JSON object.")
|
|
432
|
+
payload.setdefault("mcpServers", {})
|
|
433
|
+
return payload
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _load_or_initialize_openclaw_json(path: Path) -> dict[str, Any]:
|
|
437
|
+
try:
|
|
438
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
439
|
+
except json.JSONDecodeError as exc:
|
|
440
|
+
raise HarnessInstallError(f"Invalid JSON in {path}: {exc}") from exc
|
|
441
|
+
if not isinstance(payload, dict):
|
|
442
|
+
raise HarnessInstallError(f"{path} must contain a JSON object.")
|
|
443
|
+
mcp = payload.setdefault("mcp", {})
|
|
444
|
+
if not isinstance(mcp, dict):
|
|
445
|
+
raise HarnessInstallError(f"{path} field `mcp` must be an object.")
|
|
446
|
+
mcp.setdefault("servers", {})
|
|
447
|
+
return payload
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _mcp_servers(payload: dict[str, Any], path: Path) -> dict[str, Any]:
|
|
451
|
+
servers = payload.get("mcpServers")
|
|
452
|
+
if not isinstance(servers, dict):
|
|
453
|
+
raise HarnessInstallError(f"{path} field `mcpServers` must be an object.")
|
|
454
|
+
return servers
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _openclaw_mcp_servers(payload: dict[str, Any], path: Path) -> dict[str, Any]:
|
|
458
|
+
mcp = payload.get("mcp")
|
|
459
|
+
if not isinstance(mcp, dict):
|
|
460
|
+
raise HarnessInstallError(f"{path} field `mcp` must be an object.")
|
|
461
|
+
servers = mcp.get("servers")
|
|
462
|
+
if not isinstance(servers, dict):
|
|
463
|
+
raise HarnessInstallError(f"{path} field `mcp.servers` must be an object.")
|
|
464
|
+
return servers
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _entry_action(existing: Any, merged: dict[str, Any]) -> Literal["created", "updated", "unchanged"]:
|
|
468
|
+
if existing is None:
|
|
469
|
+
return "created"
|
|
470
|
+
if existing == merged:
|
|
471
|
+
return "unchanged"
|
|
472
|
+
return "updated"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _merge_entry(existing: Any, desired: dict[str, Any]) -> dict[str, Any]:
|
|
476
|
+
if not isinstance(existing, dict):
|
|
477
|
+
return desired
|
|
478
|
+
merged = dict(existing)
|
|
479
|
+
existing_env = existing.get("env") if isinstance(existing.get("env"), dict) else {}
|
|
480
|
+
desired_env = desired.get("env", {})
|
|
481
|
+
merged.update({"command": desired["command"], "args": desired["args"]})
|
|
482
|
+
merged["env"] = {**existing_env, **desired_env}
|
|
483
|
+
return merged
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _upsert_managed_workspace_guidance(existing: str | None) -> str:
|
|
487
|
+
base = existing or "# AGENTS.md - Your Workspace\n"
|
|
488
|
+
block = OPENCLAW_WORKSPACE_GUIDANCE.strip()
|
|
489
|
+
start_index = base.find(WORKSPACE_GUIDANCE_START)
|
|
490
|
+
end_index = base.find(WORKSPACE_GUIDANCE_END)
|
|
491
|
+
if start_index != -1 and end_index != -1 and end_index > start_index:
|
|
492
|
+
end_index += len(WORKSPACE_GUIDANCE_END)
|
|
493
|
+
updated = base[:start_index].rstrip() + "\n\n" + block + "\n\n" + base[end_index:].lstrip()
|
|
494
|
+
return updated
|
|
495
|
+
lines = base.splitlines()
|
|
496
|
+
if lines and lines[0].lstrip().startswith("#"):
|
|
497
|
+
tail = lines[1:]
|
|
498
|
+
while tail and not tail[0].strip():
|
|
499
|
+
tail = tail[1:]
|
|
500
|
+
inserted = [lines[0], "", block, "", *tail]
|
|
501
|
+
return "\n".join(inserted).rstrip() + "\n"
|
|
502
|
+
return block + "\n\n" + base.rstrip() + "\n"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _remove_legacy_skill_dirs(home: Path) -> None:
|
|
506
|
+
skills_root = home / "skills"
|
|
507
|
+
for legacy_name in LEGACY_SKILL_DIR_NAMES:
|
|
508
|
+
if legacy_name == SKILL_DIR_NAME:
|
|
509
|
+
continue
|
|
510
|
+
legacy_dir = skills_root / legacy_name
|
|
511
|
+
legacy_skill = legacy_dir / "SKILL.md"
|
|
512
|
+
if not legacy_skill.exists():
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
text = legacy_skill.read_text(encoding="utf-8")
|
|
516
|
+
except OSError:
|
|
517
|
+
continue
|
|
518
|
+
if not _looks_like_managed_people_skill(text):
|
|
519
|
+
continue
|
|
520
|
+
legacy_skill.unlink()
|
|
521
|
+
try:
|
|
522
|
+
legacy_dir.rmdir()
|
|
523
|
+
except OSError:
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _looks_like_managed_people_skill(text: str) -> bool:
|
|
528
|
+
return (
|
|
529
|
+
'name: "people-network-memory"' in text
|
|
530
|
+
and "This skill's tools are MCP tools" in text
|
|
531
|
+
and "people-network-memory__record_interaction" in text
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _default_command_args(command: str) -> list[str]:
|
|
536
|
+
command_name = Path(command).name.lower()
|
|
537
|
+
if command_name in {"people-memory", "people-memory.exe"}:
|
|
538
|
+
return ["start"]
|
|
539
|
+
return ["-m", "people_network_memory.cli", "start"]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _managed_bootstrap_command(
|
|
543
|
+
*,
|
|
544
|
+
backend: str,
|
|
545
|
+
auto_update: bool,
|
|
546
|
+
command: str | None,
|
|
547
|
+
command_args: list[str] | None,
|
|
548
|
+
) -> tuple[str, list[str]]:
|
|
549
|
+
repo_root = Path(__file__).resolve().parents[4]
|
|
550
|
+
bootstrap_script = repo_root / "scripts" / "people_memory_bootstrap.py"
|
|
551
|
+
if command:
|
|
552
|
+
resolved_command = command
|
|
553
|
+
resolved_args = list(command_args or [])
|
|
554
|
+
elif platform.system().lower().startswith("win"):
|
|
555
|
+
resolved_command = "py"
|
|
556
|
+
resolved_args = ["-3.11"]
|
|
557
|
+
else:
|
|
558
|
+
resolved_command = "python3"
|
|
559
|
+
resolved_args = []
|
|
560
|
+
extra = "graphiti" if backend == "graphiti" else "base"
|
|
561
|
+
resolved_args.extend(
|
|
562
|
+
[
|
|
563
|
+
str(bootstrap_script),
|
|
564
|
+
"run",
|
|
565
|
+
"--repo",
|
|
566
|
+
str(repo_root),
|
|
567
|
+
"--venv",
|
|
568
|
+
str(repo_root / ".venv"),
|
|
569
|
+
"--backend",
|
|
570
|
+
backend,
|
|
571
|
+
"--extra",
|
|
572
|
+
extra,
|
|
573
|
+
]
|
|
574
|
+
)
|
|
575
|
+
if auto_update:
|
|
576
|
+
resolved_args.append("--git-pull")
|
|
577
|
+
return resolved_command, resolved_args
|