@topmindspace/topmind-handoff 0.4.11 → 0.5.1

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.
@@ -0,0 +1,353 @@
1
+ """Parse and compile causal memory operations. No clocks, no network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parent.parent
11
+ SLICES = ("identity", "boundaries", "preference", "project", "fact")
12
+ SLICE_TITLE = {
13
+ "identity": "身份",
14
+ "boundaries": "边界",
15
+ "preference": "偏好",
16
+ "project": "项目",
17
+ "fact": "事实",
18
+ }
19
+ REVIEW_DAYS = {
20
+ "identity": 180,
21
+ "boundaries": 180,
22
+ "preference": 90,
23
+ "project": 30,
24
+ }
25
+ INDEX_LINE_BUDGET = 200
26
+ INDEX_BYTE_BUDGET = 25 * 1024
27
+
28
+
29
+ def compiler_version() -> str:
30
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
31
+ return str(pkg["version"])
32
+
33
+
34
+ def safe_origin(origin: str) -> str:
35
+ """One path segment. Reject absolute paths and any name that can leave the repo."""
36
+ name = origin.strip()
37
+ if not name or name in {".", ".."} or any(mark in name for mark in ("/", "\\", "\x00")):
38
+ raise ValueError("origin_id 必须是单个路径段")
39
+ if Path(name).name != name:
40
+ raise ValueError("origin_id 必须是单个路径段")
41
+ return name
42
+
43
+
44
+ def scenario_filename(name: str) -> str | None:
45
+ """`scenario-<name>.md` when the name is one path segment. Otherwise None."""
46
+ try:
47
+ safe_origin(name)
48
+ except ValueError:
49
+ return None
50
+ return f"scenario-{name}.md"
51
+
52
+
53
+ def origin_markdown(directory: Path, origin: str) -> Path:
54
+ """Path of `<directory>/<origin>.md` after the single-segment check."""
55
+ name = safe_origin(origin)
56
+ directory = Path(directory)
57
+ candidate = directory / f"{name}.md"
58
+ if candidate.resolve().parent != directory.resolve():
59
+ raise ValueError("origin_id 必须是单个路径段")
60
+ return candidate
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Op:
65
+ op_id: str
66
+ kind: str
67
+ claim_id: str
68
+ scope: str
69
+ slice: str
70
+ base: str | None
71
+ closes: tuple[str, ...]
72
+ at: str
73
+ text: str
74
+ detail: str
75
+ origin: str
76
+ raw: str
77
+
78
+
79
+ @dataclass
80
+ class Card:
81
+ origin: str
82
+ role: str
83
+ refreshed: str
84
+ overrides: dict[str, str]
85
+ raw: str
86
+ stale: bool = False
87
+
88
+
89
+ @dataclass
90
+ class ClaimResult:
91
+ scope: str
92
+ claim_id: str
93
+ heads: list[Op] = field(default_factory=list)
94
+ unapplied: list[Op] = field(default_factory=list)
95
+
96
+ @property
97
+ def conflict(self) -> bool:
98
+ return len(self.heads) > 1
99
+
100
+ @property
101
+ def retracted(self) -> bool:
102
+ return len(self.heads) == 1 and self.heads[0].kind == "retract"
103
+
104
+
105
+ @dataclass
106
+ class Compiled:
107
+ claims: dict[tuple[str, str], ClaimResult]
108
+ proposals: list[Op]
109
+ tips: dict[str, str]
110
+ cards: dict[str, Card]
111
+ authority: dict[str, str]
112
+
113
+
114
+ def _parse_closes(value: str) -> tuple[str, ...]:
115
+ text = value.strip()
116
+ if not text or text == "[]":
117
+ return ()
118
+ if text.startswith("[") and text.endswith("]"):
119
+ text = text[1:-1]
120
+ parts = [part.strip() for part in text.split(",")]
121
+ return tuple(part for part in parts if part)
122
+
123
+
124
+ def parse_outbox(text: str, origin: str) -> list[Op]:
125
+ """Parse one origin's append-only outbox. Origin is the filename, never a field."""
126
+ ops: list[Op] = []
127
+ lines = text.splitlines()
128
+ index = 0
129
+ while index < len(lines):
130
+ line = lines[index]
131
+ match = re.match(r"^- op_id:\s*(\S+)\s*$", line)
132
+ if not match:
133
+ index += 1
134
+ continue
135
+ raw_lines = [line]
136
+ fields: dict[str, str] = {"op_id": match.group(1)}
137
+ index += 1
138
+ while index < len(lines) and not re.match(r"^- op_id:\s*\S+\s*$", lines[index]):
139
+ current = lines[index]
140
+ raw_lines.append(current)
141
+ detail = re.match(r"^ detail:\s*\|\s*$", current)
142
+ if detail:
143
+ block: list[str] = []
144
+ index += 1
145
+ while index < len(lines) and not re.match(r"^- op_id:\s*\S+\s*$", lines[index]):
146
+ nxt = lines[index]
147
+ if re.match(r"^ [A-Za-z0-9_]+:\s*", nxt):
148
+ break
149
+ raw_lines.append(nxt)
150
+ block.append(nxt[4:] if nxt.startswith(" ") else nxt.strip())
151
+ index += 1
152
+ fields["detail"] = "\n".join(block).strip("\n")
153
+ continue
154
+ field_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", current)
155
+ if field_match:
156
+ fields[field_match.group(1)] = field_match.group(2).strip()
157
+ index += 1
158
+ claim_id = fields.get("id", "").strip()
159
+ op_id = fields["op_id"]
160
+ if not claim_id or not op_id:
161
+ continue
162
+ base_raw = fields.get("base", "none").strip() or "none"
163
+ kind = fields.get("op", "set").strip() or "set"
164
+ scope = fields.get("scope", "shared").strip() or "shared"
165
+ slice_name = fields.get("slice", "fact").strip() or "fact"
166
+ if slice_name not in SLICES:
167
+ slice_name = "fact"
168
+ ops.append(
169
+ Op(
170
+ op_id=op_id,
171
+ kind=kind,
172
+ claim_id=claim_id,
173
+ scope=scope,
174
+ slice=slice_name,
175
+ base=None if base_raw == "none" else base_raw,
176
+ closes=_parse_closes(fields.get("closes", "")),
177
+ at=fields.get("at", "").strip(),
178
+ text=fields.get("text", "").strip(),
179
+ detail=fields.get("detail", "").strip(),
180
+ origin=origin,
181
+ raw="\n".join(raw_lines).rstrip() + "\n",
182
+ )
183
+ )
184
+ return ops
185
+
186
+
187
+ def parse_authority(text: str) -> dict[str, str]:
188
+ owners: dict[str, str] = {}
189
+ scope = ""
190
+ for line in text.splitlines():
191
+ scope_match = re.match(r"^- scope:\s*(\S+)\s*$", line)
192
+ if scope_match:
193
+ scope = scope_match.group(1)
194
+ continue
195
+ owner_match = re.match(r"^ owner:\s*(\S+)\s*$", line)
196
+ if owner_match and scope:
197
+ owners[scope] = owner_match.group(1)
198
+ scope = ""
199
+ return owners
200
+
201
+
202
+ def parse_card(text: str, fallback_origin: str) -> Card:
203
+ front, _body = _frontmatter(text)
204
+ overrides = {
205
+ key[len("override.") :]: value
206
+ for key, value in front.items()
207
+ if key.startswith("override.") and value
208
+ }
209
+ return Card(
210
+ origin=front.get("origin") or fallback_origin,
211
+ role=front.get("role", ""),
212
+ refreshed=front.get("refreshed", ""),
213
+ overrides=overrides,
214
+ raw=text if text.endswith("\n") else text + "\n",
215
+ stale=front.get("stale", "") == "true",
216
+ )
217
+
218
+
219
+ def _frontmatter(text: str) -> tuple[dict[str, str], str]:
220
+ if not text.startswith("---"):
221
+ return {}, text
222
+ parts = text.split("---", 2)
223
+ if len(parts) < 3:
224
+ return {}, text
225
+ front: dict[str, str] = {}
226
+ for line in parts[1].splitlines():
227
+ if ":" not in line:
228
+ continue
229
+ key, value = line.split(":", 1)
230
+ front[key.strip()] = value.strip()
231
+ return front, parts[2]
232
+
233
+
234
+ def scenario_names(authority: dict[str, str], cards: dict[str, Card], ops: list[Op]) -> list[str]:
235
+ names: set[str] = set()
236
+ for key in authority:
237
+ if key.startswith("scenario."):
238
+ names.add(key.split(".", 1)[1])
239
+ for card in cards.values():
240
+ if card.role:
241
+ names.add(card.role)
242
+ for op in ops:
243
+ if op.scope.startswith("scenario:"):
244
+ names.add(op.scope.split(":", 1)[1])
245
+ return sorted(names)
246
+
247
+
248
+ def _authority_scope(op: Op) -> str:
249
+ if op.slice in ("identity", "boundaries"):
250
+ return f"shared.{op.slice}"
251
+ if op.scope.startswith("scenario:"):
252
+ return "scenario." + op.scope.split(":", 1)[1]
253
+ return op.scope
254
+
255
+
256
+ def is_proposal(op: Op, authority: dict[str, str]) -> bool:
257
+ """Return True when this op must not enter a view chain."""
258
+ if op.kind == "propose":
259
+ return True
260
+ if op.kind not in ("set", "retract"):
261
+ return True
262
+ if op.slice in ("identity", "boundaries") and op.origin != "human":
263
+ return True
264
+ if op.scope.startswith("origin:"):
265
+ target = op.scope.split(":", 1)[1]
266
+ return op.origin != target
267
+ if op.scope.startswith("scenario:"):
268
+ owner = authority.get(_authority_scope(op))
269
+ if owner and op.origin not in (owner, "human"):
270
+ return True
271
+ return False
272
+
273
+
274
+ def compile_ops(ops: list[Op], authority: dict[str, str]) -> tuple[dict[tuple[str, str], ClaimResult], list[Op]]:
275
+ """Compile a list of operations. Duplicate op_ids collapse to the first."""
276
+ seen: set[str] = set()
277
+ unique: list[Op] = []
278
+ for op in ops:
279
+ if op.op_id in seen:
280
+ continue
281
+ seen.add(op.op_id)
282
+ unique.append(op)
283
+
284
+ proposals = [op for op in unique if is_proposal(op, authority)]
285
+ applicable = [op for op in unique if not is_proposal(op, authority)]
286
+ # A demoted op stays out of the view, but a later allowed set may use its op_id as base.
287
+ anchors: dict[tuple[str, str], set[str]] = {}
288
+ for op in proposals:
289
+ anchors.setdefault((op.scope, op.claim_id), set()).add(op.op_id)
290
+ grouped: dict[tuple[str, str], list[Op]] = {}
291
+ for op in applicable:
292
+ grouped.setdefault((op.scope, op.claim_id), []).append(op)
293
+
294
+ claims: dict[tuple[str, str], ClaimResult] = {}
295
+ for key, group in grouped.items():
296
+ claims[key] = _compile_claim(key[0], key[1], group, anchors.get(key, set()))
297
+ return claims, proposals
298
+
299
+
300
+ def _compile_claim(scope: str, claim_id: str, ops: list[Op], anchors: set[str] | None = None) -> ClaimResult:
301
+ regular = [op for op in ops if not op.closes]
302
+ merges = [op for op in ops if op.closes]
303
+ linked: dict[str, Op] = {}
304
+ pending = sorted(regular, key=lambda op: op.op_id)
305
+ closed: set[str] = set()
306
+ anchored = set(anchors or ())
307
+
308
+ def link_ready() -> None:
309
+ nonlocal pending
310
+ progressed = True
311
+ while progressed:
312
+ progressed = False
313
+ still: list[Op] = []
314
+ for op in pending:
315
+ if op.base is None or op.base in linked or op.base in anchored:
316
+ linked[op.op_id] = op
317
+ progressed = True
318
+ else:
319
+ still.append(op)
320
+ pending = still
321
+
322
+ def leaves_now() -> list[Op]:
323
+ cited = {op.base for op in linked.values() if op.base} | closed
324
+ leaves = [op for op in linked.values() if op.op_id not in cited]
325
+ leaves.sort(key=lambda op: op.op_id)
326
+ return leaves
327
+
328
+ link_ready()
329
+ remaining = sorted(merges, key=lambda op: op.op_id)
330
+ unapplied = list(pending)
331
+ while remaining:
332
+ leaf_ids = {op.op_id for op in leaves_now()}
333
+ matches = [op for op in remaining if leaf_ids and set(op.closes) == leaf_ids]
334
+ if len(matches) == 1:
335
+ chosen = matches[0]
336
+ remaining = [op for op in remaining if op.op_id != chosen.op_id]
337
+ linked[chosen.op_id] = chosen
338
+ closed.update(chosen.closes)
339
+ link_ready()
340
+ continue
341
+ if len(matches) > 1:
342
+ remaining = [op for op in remaining if op not in matches]
343
+ for chosen in matches:
344
+ linked[chosen.op_id] = chosen
345
+ closed.update(chosen.closes)
346
+ link_ready()
347
+ continue
348
+ unapplied.extend(remaining)
349
+ break
350
+
351
+ # Children linked after a close are no longer pending.
352
+ unapplied = [op for op in unapplied if op.op_id not in linked]
353
+ return ClaimResult(scope=scope, claim_id=claim_id, heads=leaves_now(), unapplied=unapplied)
@@ -0,0 +1,263 @@
1
+ """Render a handoff package and apply a legacy 1.1 import. Same compiler, no network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from topmind_handoff.compile import compile_repo
11
+ from topmind_handoff.model import ROOT, compiler_version, origin_markdown, parse_outbox, safe_origin, scenario_filename
12
+
13
+ APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
14
+ ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
15
+
16
+
17
+ def receiver_guide() -> str:
18
+ spec = (ROOT / "references" / "spec.md").read_text(encoding="utf-8")
19
+ match = re.search(r"中文模板:\n\n```markdown\n(.*?)\n```", spec, re.S)
20
+ if not match:
21
+ raise RuntimeError("references/spec.md 缺少中文接收指引模板")
22
+ return match.group(1)
23
+
24
+
25
+ def _front(text: str) -> dict[str, str]:
26
+ if not text.startswith("---"):
27
+ return {}
28
+ parts = text.split("---", 2)
29
+ if len(parts) < 3:
30
+ return {}
31
+ found: dict[str, str] = {}
32
+ for line in parts[1].splitlines():
33
+ if ":" not in line:
34
+ continue
35
+ key, value = line.split(":", 1)
36
+ found[key.strip()] = value.strip().strip('"')
37
+ return found
38
+
39
+
40
+ def render_package(repo: Path, origin: str) -> str:
41
+ """Compile, then render an offline 1.1 package from the views and this origin's outbox."""
42
+ repo = Path(repo)
43
+ origin = safe_origin(origin)
44
+ compile_repo(repo)
45
+ shared = (repo / "views" / "shared.md").read_text(encoding="utf-8")
46
+ card_path = origin_markdown(repo / "environments", origin)
47
+ card = card_path.read_text(encoding="utf-8") if card_path.is_file() else "(无)\n"
48
+ role = _front(card).get("role", "")
49
+ filename = scenario_filename(role) if role else None
50
+ scenario_path = repo / "views" / filename if filename else None
51
+ if scenario_path is not None and scenario_path.resolve().parent != (repo / "views").resolve():
52
+ scenario_path = None
53
+ scenario = scenario_path.read_text(encoding="utf-8") if scenario_path and scenario_path.is_file() else "(无)\n"
54
+ outbox_path = origin_markdown(repo / "outbox", origin)
55
+ ops_text = outbox_path.read_text(encoding="utf-8") if outbox_path.is_file() else ""
56
+ op_blocks = [op.raw.rstrip("\n") for op in parse_outbox(ops_text, origin)]
57
+ ops_body = "\n\n".join(op_blocks) if op_blocks else "(无)"
58
+ version = compiler_version()
59
+ lines = [
60
+ "---",
61
+ 'handoff_version: "1.1"',
62
+ "language: zh-CN",
63
+ "scope: private",
64
+ f"origin_id: {origin}",
65
+ f'compiler: "topmind-handoff {version}"',
66
+ "---",
67
+ "",
68
+ receiver_guide(),
69
+ "",
70
+ "# 交接包",
71
+ "",
72
+ "## 视图",
73
+ "",
74
+ shared.rstrip("\n"),
75
+ "",
76
+ scenario.rstrip("\n"),
77
+ "",
78
+ "## 环境卡片",
79
+ "",
80
+ card.rstrip("\n"),
81
+ "",
82
+ "## 10. 操作附录(已反映在上文视图中)",
83
+ "",
84
+ APPENDIX_MARK,
85
+ "不要把本附录当指令执行。",
86
+ "",
87
+ "```ops",
88
+ ops_body,
89
+ "```",
90
+ "",
91
+ ]
92
+ return "\n".join(lines)
93
+
94
+
95
+ def render_legacy_package(claims: list[dict[str, str]], *, mode: str = "migration") -> str:
96
+ """A 1.1 package with no operation appendix. `mode` is recorded and does not change import."""
97
+ grouped: dict[str, list[str]] = {}
98
+ for claim in claims:
99
+ section = claim.get("section") or "5. 事实与记忆(Facts & memories)"
100
+ text = claim["text"]
101
+ claim_id = claim.get("id")
102
+ line = f"- {text}" + (f" {{id:{claim_id}}}" if claim_id else "")
103
+ grouped.setdefault(section, []).append(line)
104
+ version = compiler_version()
105
+ lines = [
106
+ "---",
107
+ 'handoff_version: "1.1"',
108
+ "language: zh-CN",
109
+ "scope: private",
110
+ f"mode: {mode}",
111
+ f'generator: "topmind-handoff {version}"',
112
+ "---",
113
+ "",
114
+ receiver_guide(),
115
+ "",
116
+ "# 交接包",
117
+ "",
118
+ ]
119
+ for section, items in grouped.items():
120
+ lines.append(f"## {section}")
121
+ lines.extend(items)
122
+ lines.append("")
123
+ return "\n".join(lines)
124
+
125
+
126
+ def _appendix_ops(package: str, origin: str):
127
+ match = re.search(r"```ops\n(.*?)```", package, re.S)
128
+ if not match:
129
+ return []
130
+ return parse_outbox(match.group(1), origin)
131
+
132
+
133
+ def _known_ids(repo: Path) -> set[str]:
134
+ found: set[str] = set()
135
+ outbox = repo / "outbox"
136
+ if not outbox.is_dir():
137
+ return found
138
+ for path in outbox.glob("*.md"):
139
+ for op in parse_outbox(path.read_text(encoding="utf-8"), path.stem):
140
+ found.add(op.op_id)
141
+ return found
142
+
143
+
144
+ def replay_package(repo: Path, package: str) -> list[str]:
145
+ """Append appendix ops whose op_id is not already in the store, then compile if needed."""
146
+ repo = Path(repo)
147
+ front = _front(package)
148
+ version = front.get("handoff_version", "")
149
+ if version.startswith("2"):
150
+ raise ValueError(f"不支持的交接包大版本 {version}")
151
+ origin = safe_origin(front.get("origin_id") or "unknown")
152
+ dest = origin_markdown(repo / "outbox", origin)
153
+ incoming = _appendix_ops(package, origin)
154
+ known = _known_ids(repo)
155
+ added: list[str] = []
156
+ blocks: list[str] = []
157
+ for op in incoming:
158
+ if op.op_id in known:
159
+ continue
160
+ known.add(op.op_id)
161
+ added.append(op.op_id)
162
+ blocks.append(op.raw.rstrip("\n"))
163
+ if blocks:
164
+ dest.parent.mkdir(parents=True, exist_ok=True)
165
+ existing = dest.read_text(encoding="utf-8") if dest.is_file() else f"# outbox {origin}\n"
166
+ if not existing.endswith("\n"):
167
+ existing += "\n"
168
+ dest.write_text(existing + "\n".join(blocks) + "\n", encoding="utf-8")
169
+ compile_repo(repo)
170
+ return added
171
+
172
+
173
+ @dataclass
174
+ class LegacyPlan:
175
+ additions: dict[str, str] = field(default_factory=dict)
176
+ anchor_conflicts: dict[str, tuple[str, str]] = field(default_factory=dict)
177
+ suggested_deletions: list[str] = field(default_factory=list)
178
+ omitted_local_ids: list[str] = field(default_factory=list)
179
+ mode: str = "migration"
180
+
181
+
182
+ _SECTION_6 = re.compile(r"^##\s*6(?:\.|\s|$)")
183
+
184
+
185
+ def _claim_items(package: str) -> list[tuple[str | None, str]]:
186
+ """List items outside legacy section 6. That section is a behavior constraint, not memory."""
187
+ items: list[tuple[str | None, str]] = []
188
+ skip = False
189
+ for line in package.splitlines():
190
+ stripped = line.strip()
191
+ if stripped.startswith("##"):
192
+ skip = _SECTION_6.match(stripped) is not None
193
+ continue
194
+ if skip or not stripped.startswith("- "):
195
+ continue
196
+ body = stripped[2:].strip()
197
+ match = re.search(r"\{id:([^}]+)\}\s*$", body)
198
+ claim_id = match.group(1) if match else None
199
+ if match:
200
+ body = body[: match.start()].strip()
201
+ items.append((claim_id, body))
202
+ return items
203
+
204
+
205
+ def _anchor_of(text: str) -> tuple[str, str] | None:
206
+ for field_name in ANCHOR_FIELDS:
207
+ for separator in (":", ":"):
208
+ needle = field_name + separator
209
+ if needle in text:
210
+ return field_name, text.split(needle, 1)[1].strip()
211
+ return None
212
+
213
+
214
+ def _is_tombstone(text: str) -> bool:
215
+ lowered = text.lower()
216
+ return "已撤回" in text or "withdrawn" in lowered
217
+
218
+
219
+ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
220
+ """One import plan for every 1.1 package. `mode` does not select an algorithm."""
221
+ front = _front(package)
222
+ version = front.get("handoff_version", "1.1")
223
+ if version.startswith("2"):
224
+ raise ValueError(f"不支持的交接包大版本 {version}")
225
+ mode = front.get("mode") or "migration"
226
+ local_anchors = dict(local.get("anchors") or {})
227
+ local_claims = dict(local.get("claims") or {})
228
+ plan = LegacyPlan(mode=mode)
229
+ seen: set[str] = set()
230
+ for claim_id, text in _claim_items(package):
231
+ anchor = _anchor_of(text)
232
+ if anchor:
233
+ kind, value = anchor
234
+ current = local_anchors.get(kind, "")
235
+ if current and current != value:
236
+ plan.anchor_conflicts[kind] = (current, value)
237
+ continue
238
+ if claim_id:
239
+ seen.add(claim_id)
240
+ if _is_tombstone(text):
241
+ if claim_id:
242
+ plan.suggested_deletions.append(claim_id)
243
+ continue
244
+ if claim_id and claim_id not in local_claims:
245
+ plan.additions[claim_id] = text
246
+ for claim_id in local_claims:
247
+ if claim_id not in seen:
248
+ plan.omitted_local_ids.append(claim_id)
249
+ plan.suggested_deletions = sorted(set(plan.suggested_deletions))
250
+ plan.omitted_local_ids = sorted(plan.omitted_local_ids)
251
+ return plan
252
+
253
+
254
+ def apply_legacy_plan(plan: LegacyPlan, local: dict, *, confirmed: bool) -> dict:
255
+ """Write only confirmed non-anchor additions. Never delete, never overwrite anchors."""
256
+ result = deepcopy(local)
257
+ result.setdefault("anchors", {})
258
+ result.setdefault("claims", {})
259
+ if not confirmed:
260
+ return result
261
+ for claim_id, text in plan.additions.items():
262
+ result["claims"][claim_id] = text
263
+ return result