@topmindspace/topmind-handoff 0.4.11 → 0.5.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/CHANGELOG.md +17 -156
- package/README.md +36 -96
- package/SKILL.md +80 -96
- package/assets/handoff-example.md +68 -109
- package/assets/handoff-template.md +23 -129
- package/package.json +1 -1
- package/references/compile.md +110 -0
- package/references/environment.md +48 -81
- package/references/export.md +33 -164
- package/references/import.md +31 -217
- package/references/privacy.md +10 -10
- package/references/spec.md +59 -360
- package/references/tool-adapters.md +48 -112
- package/scripts/compile_memory.py +16 -0
- package/topmind_handoff/__init__.py +35 -0
- package/topmind_handoff/admin.py +337 -0
- package/topmind_handoff/cli.py +157 -0
- package/topmind_handoff/compile.py +413 -0
- package/topmind_handoff/model.py +353 -0
- package/topmind_handoff/package.py +255 -0
|
@@ -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,255 @@
|
|
|
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
|
+
def _claim_items(package: str) -> list[tuple[str | None, str]]:
|
|
183
|
+
items: list[tuple[str | None, str]] = []
|
|
184
|
+
for line in package.splitlines():
|
|
185
|
+
stripped = line.strip()
|
|
186
|
+
if not stripped.startswith("- "):
|
|
187
|
+
continue
|
|
188
|
+
body = stripped[2:].strip()
|
|
189
|
+
match = re.search(r"\{id:([^}]+)\}\s*$", body)
|
|
190
|
+
claim_id = match.group(1) if match else None
|
|
191
|
+
if match:
|
|
192
|
+
body = body[: match.start()].strip()
|
|
193
|
+
items.append((claim_id, body))
|
|
194
|
+
return items
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _anchor_of(text: str) -> tuple[str, str] | None:
|
|
198
|
+
for field_name in ANCHOR_FIELDS:
|
|
199
|
+
for separator in (":", ":"):
|
|
200
|
+
needle = field_name + separator
|
|
201
|
+
if needle in text:
|
|
202
|
+
return field_name, text.split(needle, 1)[1].strip()
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_tombstone(text: str) -> bool:
|
|
207
|
+
lowered = text.lower()
|
|
208
|
+
return "已撤回" in text or "withdrawn" in lowered
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
|
|
212
|
+
"""One import plan for every 1.1 package. `mode` does not select an algorithm."""
|
|
213
|
+
front = _front(package)
|
|
214
|
+
version = front.get("handoff_version", "1.1")
|
|
215
|
+
if version.startswith("2"):
|
|
216
|
+
raise ValueError(f"不支持的交接包大版本 {version}")
|
|
217
|
+
mode = front.get("mode") or "migration"
|
|
218
|
+
local_anchors = dict(local.get("anchors") or {})
|
|
219
|
+
local_claims = dict(local.get("claims") or {})
|
|
220
|
+
plan = LegacyPlan(mode=mode)
|
|
221
|
+
seen: set[str] = set()
|
|
222
|
+
for claim_id, text in _claim_items(package):
|
|
223
|
+
anchor = _anchor_of(text)
|
|
224
|
+
if anchor:
|
|
225
|
+
kind, value = anchor
|
|
226
|
+
current = local_anchors.get(kind, "")
|
|
227
|
+
if current and current != value:
|
|
228
|
+
plan.anchor_conflicts[kind] = (current, value)
|
|
229
|
+
continue
|
|
230
|
+
if claim_id:
|
|
231
|
+
seen.add(claim_id)
|
|
232
|
+
if _is_tombstone(text):
|
|
233
|
+
if claim_id:
|
|
234
|
+
plan.suggested_deletions.append(claim_id)
|
|
235
|
+
continue
|
|
236
|
+
if claim_id and claim_id not in local_claims:
|
|
237
|
+
plan.additions[claim_id] = text
|
|
238
|
+
for claim_id in local_claims:
|
|
239
|
+
if claim_id not in seen:
|
|
240
|
+
plan.omitted_local_ids.append(claim_id)
|
|
241
|
+
plan.suggested_deletions = sorted(set(plan.suggested_deletions))
|
|
242
|
+
plan.omitted_local_ids = sorted(plan.omitted_local_ids)
|
|
243
|
+
return plan
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def apply_legacy_plan(plan: LegacyPlan, local: dict, *, confirmed: bool) -> dict:
|
|
247
|
+
"""Write only confirmed non-anchor additions. Never delete, never overwrite anchors."""
|
|
248
|
+
result = deepcopy(local)
|
|
249
|
+
result.setdefault("anchors", {})
|
|
250
|
+
result.setdefault("claims", {})
|
|
251
|
+
if not confirmed:
|
|
252
|
+
return result
|
|
253
|
+
for claim_id, text in plan.additions.items():
|
|
254
|
+
result["claims"][claim_id] = text
|
|
255
|
+
return result
|