@topmindspace/topmind-handoff 0.5.2 → 0.6.3

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.
@@ -1,17 +1,26 @@
1
- """Render a handoff package and apply a legacy 1.1 import. Same compiler, no network."""
1
+ """Render one handoff package and replay its ledger. Same compiler, no network."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
6
  from copy import deepcopy
7
7
  from dataclasses import dataclass, field
8
+ from datetime import date
8
9
  from pathlib import Path
9
10
 
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
11
+ from topmind_handoff.compile import append_outbox, compile_repo, projection_body
12
+ from topmind_handoff.model import (
13
+ REVIEW_MARK,
14
+ ROOT,
15
+ compiler_version,
16
+ origin_markdown,
17
+ parse_outbox,
18
+ safe_origin,
19
+ )
12
20
 
13
21
  APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
14
22
  ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
23
+ PACKAGE_VERSION = "2"
15
24
 
16
25
 
17
26
  def receiver_guide() -> str:
@@ -37,20 +46,12 @@ def _front(text: str) -> dict[str, str]:
37
46
  return found
38
47
 
39
48
 
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."""
49
+ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
50
+ """Compile, then render the same origin projection startup prints, plus the ledger."""
42
51
  repo = Path(repo)
43
52
  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"
53
+ compile_repo(repo, today=today)
54
+ body = projection_body(repo, origin, today=today).rstrip("\n")
54
55
  outbox_path = origin_markdown(repo / "outbox", origin)
55
56
  ops_text = outbox_path.read_text(encoding="utf-8") if outbox_path.is_file() else ""
56
57
  op_blocks = [op.raw.rstrip("\n") for op in parse_outbox(ops_text, origin)]
@@ -58,7 +59,7 @@ def render_package(repo: Path, origin: str) -> str:
58
59
  version = compiler_version()
59
60
  lines = [
60
61
  "---",
61
- 'handoff_version: "1.1"',
62
+ f'handoff_version: "{PACKAGE_VERSION}"',
62
63
  "language: zh-CN",
63
64
  "scope: private",
64
65
  f"origin_id: {origin}",
@@ -69,15 +70,7 @@ def render_package(repo: Path, origin: str) -> str:
69
70
  "",
70
71
  "# 交接包",
71
72
  "",
72
- "## 视图",
73
- "",
74
- shared.rstrip("\n"),
75
- "",
76
- scenario.rstrip("\n"),
77
- "",
78
- "## 环境卡片",
79
- "",
80
- card.rstrip("\n"),
73
+ body,
81
74
  "",
82
75
  "## 10. 操作附录(已反映在上文视图中)",
83
76
  "",
@@ -92,39 +85,16 @@ def render_package(repo: Path, origin: str) -> str:
92
85
  return "\n".join(lines)
93
86
 
94
87
 
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)
88
+ def _ops_fence(package: str) -> re.Match[str] | None:
89
+ return re.search(r"```ops\n(.*?)```", package, re.S)
90
+
91
+
92
+ def _has_ops_appendix(package: str) -> bool:
93
+ return _ops_fence(package) is not None
124
94
 
125
95
 
126
96
  def _appendix_ops(package: str, origin: str):
127
- match = re.search(r"```ops\n(.*?)```", package, re.S)
97
+ match = _ops_fence(package)
128
98
  if not match:
129
99
  return []
130
100
  return parse_outbox(match.group(1), origin)
@@ -149,10 +119,11 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
149
119
  repo = Path(repo)
150
120
  front = _front(package)
151
121
  version = front.get("handoff_version", "")
152
- if version.startswith("2"):
153
- raise ValueError(f"不支持的交接包大版本 {version}")
122
+ if version != PACKAGE_VERSION:
123
+ raise ValueError(f"不支持的交接包版本 {version or '(空)'}")
124
+ if not _has_ops_appendix(package):
125
+ raise ValueError("交接包没有操作附录")
154
126
  origin = safe_origin(front.get("origin_id") or "unknown")
155
- dest = origin_markdown(repo / "outbox", origin)
156
127
  incoming = _appendix_ops(package, origin)
157
128
  known = _known_ids(repo)
158
129
  added: list[str] = []
@@ -165,11 +136,7 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
165
136
  blocks.append(op.raw.rstrip("\n"))
166
137
  if not blocks or not confirmed:
167
138
  return added
168
- dest.parent.mkdir(parents=True, exist_ok=True)
169
- existing = dest.read_text(encoding="utf-8") if dest.is_file() else f"# outbox {origin}\n"
170
- if not existing.endswith("\n"):
171
- existing += "\n"
172
- dest.write_text(existing + "\n".join(blocks) + "\n", encoding="utf-8")
139
+ append_outbox(repo, origin, origin, blocks)
173
140
  compile_repo(repo)
174
141
  return added
175
142
 
@@ -183,17 +150,23 @@ class LegacyPlan:
183
150
  unkeyed: list[str] = field(default_factory=list)
184
151
  suggested_deletions: list[str] = field(default_factory=list)
185
152
  omitted_local_ids: list[str] = field(default_factory=list)
186
- mode: str = "migration"
187
153
 
188
154
 
189
155
  _SECTION_6 = re.compile(r"^##\s*6(?:\.|\s|$)")
190
156
 
191
157
 
158
+ def _skip_heading(stripped: str) -> bool:
159
+ """Boundaries are behavior, not facts. The rendered slice heading is ## 边界."""
160
+ if _SECTION_6.match(stripped):
161
+ return True
162
+ return stripped.lstrip("#").strip() == "边界"
163
+
164
+
192
165
  def _claim_items(package: str) -> list[tuple[str | None, str]]:
193
- """List items outside legacy section 6 and outside fenced blocks.
166
+ """List items outside boundary sections and outside fenced blocks.
194
167
 
195
- Section 6 is a behavior constraint, not memory. Fenced blocks hold the
196
- operations appendix, which is a ledger and not a new claim.
168
+ Section 6 and the rendered ## 边界 slice are behavior, not memory.
169
+ Fenced blocks hold the operations appendix, which is a ledger and not a new claim.
197
170
  """
198
171
  items: list[tuple[str | None, str]] = []
199
172
  skip = False
@@ -206,11 +179,11 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
206
179
  if fenced:
207
180
  continue
208
181
  if stripped.startswith("##"):
209
- skip = _SECTION_6.match(stripped) is not None
182
+ skip = _skip_heading(stripped)
210
183
  continue
211
184
  if skip or not stripped.startswith("- "):
212
185
  continue
213
- body = stripped[2:].strip()
186
+ body = _without_review_mark(stripped[2:].strip())
214
187
  match = re.search(r"\{id:([^}]+)\}\s*$", body)
215
188
  claim_id = match.group(1) if match else None
216
189
  if match:
@@ -219,6 +192,13 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
219
192
  return items
220
193
 
221
194
 
195
+ def _without_review_mark(body: str) -> str:
196
+ """A due line ends with the review label after `{id:…}`. The label is not claim text."""
197
+ if body.endswith(REVIEW_MARK):
198
+ return body[: -len(REVIEW_MARK)].rstrip()
199
+ return body
200
+
201
+
222
202
  def _anchor_of(text: str) -> tuple[str, str] | None:
223
203
  """An anchor line starts with the field name. A later mention is not an anchor."""
224
204
  stripped = text.strip()
@@ -236,15 +216,17 @@ def _is_tombstone(text: str) -> bool:
236
216
 
237
217
 
238
218
  def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
239
- """One import plan for every 1.1 package. `mode` does not select an algorithm."""
219
+ """Diff view lines into host memory. Existing text and anchors are not replaced."""
240
220
  front = _front(package)
241
- version = front.get("handoff_version", "1.1")
242
- if version.startswith("2"):
243
- raise ValueError(f"不支持的交接包大版本 {version}")
244
- mode = front.get("mode") or "migration"
221
+ version = front.get("handoff_version", "")
222
+ if package.startswith("---"):
223
+ if version != PACKAGE_VERSION:
224
+ raise ValueError(f"不支持的交接包版本 {version or '(空)'}")
225
+ if not _has_ops_appendix(package):
226
+ raise ValueError("交接包没有操作附录")
245
227
  local_anchors = dict(local.get("anchors") or {})
246
228
  local_claims = dict(local.get("claims") or {})
247
- plan = LegacyPlan(mode=mode)
229
+ plan = LegacyPlan()
248
230
  seen: set[str] = set()
249
231
  local_texts = {value for value in local_claims.values() if value}
250
232
  for claim_id, text in _claim_items(package):