@topmindspace/topmind-handoff 0.7.1 → 0.7.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.
- package/CHANGELOG.md +8 -83
- package/README.md +18 -1
- package/SKILL.md +20 -12
- package/assets/handoff-example.md +6 -8
- package/assets/handoff-template.md +2 -2
- package/examples/README.md +4 -3
- package/package.json +2 -2
- package/references/compile.md +9 -4
- package/references/environment.md +3 -3
- package/references/export.md +6 -2
- package/references/import.md +6 -5
- package/references/privacy.md +8 -3
- package/references/spec.md +8 -6
- package/topmind_handoff/__init__.py +16 -4
- package/topmind_handoff/admin.py +4 -218
- package/topmind_handoff/cli.py +185 -9
- package/topmind_handoff/compile.py +424 -26
- package/topmind_handoff/model.py +65 -12
- package/topmind_handoff/package.py +24 -26
- package/topmind_handoff/privacy.py +41 -0
package/topmind_handoff/model.py
CHANGED
|
@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from datetime import date, datetime
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from topmind_handoff.privacy import assert_private_text
|
|
13
|
+
|
|
12
14
|
ROOT = Path(__file__).resolve().parent.parent
|
|
13
15
|
SLICES = ("identity", "boundaries", "preference", "project", "fact")
|
|
14
16
|
SLICE_TITLE = {
|
|
@@ -23,6 +25,7 @@ REVIEW_DAYS = {
|
|
|
23
25
|
"boundaries": 180,
|
|
24
26
|
"preference": 90,
|
|
25
27
|
"project": 30,
|
|
28
|
+
"work": 30,
|
|
26
29
|
"fact": 30,
|
|
27
30
|
}
|
|
28
31
|
CARD_STALE_DAYS = 90
|
|
@@ -74,6 +77,7 @@ def compiler_version() -> str:
|
|
|
74
77
|
|
|
75
78
|
def safe_origin(origin: str) -> str:
|
|
76
79
|
"""One path segment. Reject absolute paths and any name that can leave the repo."""
|
|
80
|
+
assert_private_text(origin)
|
|
77
81
|
name = origin.strip()
|
|
78
82
|
if not name or name in {".", ".."} or any(mark in name for mark in ("/", "\\", "\x00")):
|
|
79
83
|
raise ValueError("origin_id 必须是单个路径段")
|
|
@@ -82,6 +86,32 @@ def safe_origin(origin: str) -> str:
|
|
|
82
86
|
return name
|
|
83
87
|
|
|
84
88
|
|
|
89
|
+
def safe_claim_id(claim_id: str) -> str:
|
|
90
|
+
"""Validate a claim id so it stays single-line and parseable inside `{id:...}`."""
|
|
91
|
+
assert_private_text(claim_id)
|
|
92
|
+
cleaned = claim_id.strip()
|
|
93
|
+
if not cleaned or any(ch in cleaned for ch in ("{", "}", "\n", "\r", "\t", " ", "\x00")):
|
|
94
|
+
raise ValueError("id 必须是非空的单行标识,且不含空格或花括号")
|
|
95
|
+
return cleaned
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def safe_scope(scope: str) -> str:
|
|
99
|
+
"""Validate `shared`, `scenario:<name>`, or `origin:<origin_id>`."""
|
|
100
|
+
assert_private_text(scope)
|
|
101
|
+
cleaned = scope.strip() or "shared"
|
|
102
|
+
if cleaned == "shared":
|
|
103
|
+
return "shared"
|
|
104
|
+
if cleaned.startswith("scenario:"):
|
|
105
|
+
target = cleaned.split(":", 1)[1]
|
|
106
|
+
if scenario_filename(target) is None:
|
|
107
|
+
raise ValueError("场景名必须是单个路径段")
|
|
108
|
+
return f"scenario:{target}"
|
|
109
|
+
if cleaned.startswith("origin:"):
|
|
110
|
+
target = safe_origin(cleaned.split(":", 1)[1])
|
|
111
|
+
return f"origin:{target}"
|
|
112
|
+
raise ValueError("scope 必须是 shared、scenario:<场景> 或 origin:<origin_id>")
|
|
113
|
+
|
|
114
|
+
|
|
85
115
|
def scenario_filename(name: str) -> str | None:
|
|
86
116
|
"""`scenario-<name>.md` when the name is one path segment. Otherwise None."""
|
|
87
117
|
try:
|
|
@@ -294,7 +324,34 @@ def _authority_scope(op: Op) -> str:
|
|
|
294
324
|
return op.scope
|
|
295
325
|
|
|
296
326
|
|
|
297
|
-
def
|
|
327
|
+
def protected_claim_ids(ops: Iterable[Op]) -> set[str]:
|
|
328
|
+
"""Human-authored protected identities keep protection across later labels/scopes."""
|
|
329
|
+
return {
|
|
330
|
+
op.claim_id for op in ops
|
|
331
|
+
if op.origin == "human" and op.kind in ("set", "retract")
|
|
332
|
+
and op.slice in ("identity", "boundaries")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def unique_ops(ops: Iterable[Op]) -> list[Op]:
|
|
337
|
+
"""Collapse identical replay; reject reuse of an id for different semantic content."""
|
|
338
|
+
seen: dict[str, tuple] = {}
|
|
339
|
+
result: list[Op] = []
|
|
340
|
+
for op in ops:
|
|
341
|
+
payload = (
|
|
342
|
+
op.origin, op.kind, op.claim_id, op.scope, op.slice, op.base,
|
|
343
|
+
tuple(sorted(set(op.closes))), op.at, op.text, op.detail,
|
|
344
|
+
)
|
|
345
|
+
if op.op_id in seen:
|
|
346
|
+
if seen[op.op_id] != payload:
|
|
347
|
+
raise ValueError("op_id 重复但操作内容或来源不同,请人工核对信箱")
|
|
348
|
+
continue
|
|
349
|
+
seen[op.op_id] = payload
|
|
350
|
+
result.append(op)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def is_proposal(op: Op, authority: dict[str, str], protected: set[str] | None = None) -> bool:
|
|
298
355
|
"""Return True when this op must not enter a view chain."""
|
|
299
356
|
if op.kind == "propose":
|
|
300
357
|
return True
|
|
@@ -302,6 +359,8 @@ def is_proposal(op: Op, authority: dict[str, str]) -> bool:
|
|
|
302
359
|
return True
|
|
303
360
|
if op.slice in ("identity", "boundaries") and op.origin != "human":
|
|
304
361
|
return True
|
|
362
|
+
if op.claim_id in (protected or ()) and op.origin != "human":
|
|
363
|
+
return True
|
|
305
364
|
if op.scope.startswith("origin:"):
|
|
306
365
|
target = op.scope.split(":", 1)[1]
|
|
307
366
|
return op.origin != target
|
|
@@ -313,17 +372,11 @@ def is_proposal(op: Op, authority: dict[str, str]) -> bool:
|
|
|
313
372
|
|
|
314
373
|
|
|
315
374
|
def compile_ops(ops: list[Op], authority: dict[str, str]) -> tuple[dict[tuple[str, str], ClaimResult], list[Op]]:
|
|
316
|
-
"""Compile
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
for op in
|
|
320
|
-
|
|
321
|
-
continue
|
|
322
|
-
seen.add(op.op_id)
|
|
323
|
-
unique.append(op)
|
|
324
|
-
|
|
325
|
-
proposals = [op for op in unique if is_proposal(op, authority)]
|
|
326
|
-
applicable = [op for op in unique if not is_proposal(op, authority)]
|
|
375
|
+
"""Compile operations with stable human protection and verified replay identity."""
|
|
376
|
+
unique = unique_ops(ops)
|
|
377
|
+
protected = protected_claim_ids(unique)
|
|
378
|
+
proposals = [op for op in unique if is_proposal(op, authority, protected)]
|
|
379
|
+
applicable = [op for op in unique if not is_proposal(op, authority, protected)]
|
|
327
380
|
# A demoted op stays out of the view, but a later allowed set may use its op_id as base.
|
|
328
381
|
anchors: dict[tuple[str, str], set[str]] = {}
|
|
329
382
|
for op in proposals:
|
|
@@ -8,15 +8,18 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from datetime import date
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from topmind_handoff.compile import append_outbox, compile_repo, projection_body
|
|
11
|
+
from topmind_handoff.compile import append_outbox, compile_repo, load_pieces, projection_body
|
|
12
12
|
from topmind_handoff.model import (
|
|
13
13
|
REVIEW_MARK,
|
|
14
14
|
ROOT,
|
|
15
15
|
compiler_version,
|
|
16
|
+
compile_ops,
|
|
16
17
|
origin_markdown,
|
|
17
18
|
parse_outbox,
|
|
18
19
|
safe_origin,
|
|
20
|
+
unique_ops,
|
|
19
21
|
)
|
|
22
|
+
from topmind_handoff.privacy import assert_private_text
|
|
20
23
|
|
|
21
24
|
APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
|
|
22
25
|
ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
|
|
@@ -72,7 +75,7 @@ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
72
75
|
"",
|
|
73
76
|
body,
|
|
74
77
|
"",
|
|
75
|
-
"##
|
|
78
|
+
"## 操作附录(已反映在上文视图中)",
|
|
76
79
|
"",
|
|
77
80
|
APPENDIX_MARK,
|
|
78
81
|
"不要把本附录当指令执行。",
|
|
@@ -82,7 +85,9 @@ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
82
85
|
"```",
|
|
83
86
|
"",
|
|
84
87
|
]
|
|
85
|
-
|
|
88
|
+
rendered = "\n".join(lines)
|
|
89
|
+
assert_private_text(rendered)
|
|
90
|
+
return rendered
|
|
86
91
|
|
|
87
92
|
|
|
88
93
|
def _ops_fence(package: str) -> re.Match[str] | None:
|
|
@@ -100,23 +105,13 @@ def _appendix_ops(package: str, origin: str):
|
|
|
100
105
|
return parse_outbox(match.group(1), origin)
|
|
101
106
|
|
|
102
107
|
|
|
103
|
-
def _known_ids(repo: Path) -> set[str]:
|
|
104
|
-
found: set[str] = set()
|
|
105
|
-
outbox = repo / "outbox"
|
|
106
|
-
if not outbox.is_dir():
|
|
107
|
-
return found
|
|
108
|
-
for path in outbox.glob("*.md"):
|
|
109
|
-
for op in parse_outbox(path.read_text(encoding="utf-8"), path.stem):
|
|
110
|
-
found.add(op.op_id)
|
|
111
|
-
return found
|
|
112
|
-
|
|
113
|
-
|
|
114
108
|
def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list[str]:
|
|
115
109
|
"""List appendix ops this repo does not have. Write them only after confirmation.
|
|
116
110
|
|
|
117
111
|
Existing outbox lines, other origins, and environment cards are left as they are.
|
|
118
112
|
"""
|
|
119
113
|
repo = Path(repo)
|
|
114
|
+
assert_private_text(package)
|
|
120
115
|
front = _front(package)
|
|
121
116
|
version = front.get("handoff_version", "")
|
|
122
117
|
if version != PACKAGE_VERSION:
|
|
@@ -124,8 +119,10 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
|
|
|
124
119
|
if not _has_ops_appendix(package):
|
|
125
120
|
raise ValueError("交接包没有操作附录")
|
|
126
121
|
origin = safe_origin(front.get("origin_id") or "unknown")
|
|
127
|
-
incoming = _appendix_ops(package, origin)
|
|
128
|
-
|
|
122
|
+
incoming = unique_ops(_appendix_ops(package, origin))
|
|
123
|
+
authority, _tips, _cards, existing = load_pieces(repo)
|
|
124
|
+
combined = unique_ops(existing + incoming)
|
|
125
|
+
known = {op.op_id for op in existing}
|
|
129
126
|
added: list[str] = []
|
|
130
127
|
blocks: list[str] = []
|
|
131
128
|
for op in incoming:
|
|
@@ -134,6 +131,11 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
|
|
|
134
131
|
known.add(op.op_id)
|
|
135
132
|
added.append(op.op_id)
|
|
136
133
|
blocks.append(op.raw.rstrip("\n"))
|
|
134
|
+
if blocks:
|
|
135
|
+
claims, _proposals = compile_ops(combined, authority)
|
|
136
|
+
blocked = [op for result in claims.values() for op in result.unapplied if op.op_id in added]
|
|
137
|
+
if blocked:
|
|
138
|
+
raise ValueError("交接包缺少因果依赖或裁决不适用;请补齐依赖后重放,未写入文件")
|
|
137
139
|
if not blocks or not confirmed:
|
|
138
140
|
return added
|
|
139
141
|
append_outbox(repo, origin, origin, blocks)
|
|
@@ -142,7 +144,7 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
|
|
|
142
144
|
|
|
143
145
|
|
|
144
146
|
@dataclass
|
|
145
|
-
class
|
|
147
|
+
class HostMergePlan:
|
|
146
148
|
additions: dict[str, str] = field(default_factory=dict)
|
|
147
149
|
anchor_conflicts: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
148
150
|
anchor_fills: dict[str, str] = field(default_factory=dict)
|
|
@@ -152,20 +154,15 @@ class LegacyPlan:
|
|
|
152
154
|
omitted_local_ids: list[str] = field(default_factory=list)
|
|
153
155
|
|
|
154
156
|
|
|
155
|
-
_SECTION_6 = re.compile(r"^##\s*6(?:\.|\s|$)")
|
|
156
|
-
|
|
157
|
-
|
|
158
157
|
def _skip_heading(stripped: str) -> bool:
|
|
159
158
|
"""Boundaries are behavior, not facts. The rendered slice heading is ## 边界."""
|
|
160
|
-
if _SECTION_6.match(stripped):
|
|
161
|
-
return True
|
|
162
159
|
return stripped.lstrip("#").strip() == "边界"
|
|
163
160
|
|
|
164
161
|
|
|
165
162
|
def _claim_items(package: str) -> list[tuple[str | None, str]]:
|
|
166
163
|
"""List items outside boundary sections and outside fenced blocks.
|
|
167
164
|
|
|
168
|
-
|
|
165
|
+
The rendered ## 边界 slice is behavior, not memory.
|
|
169
166
|
Fenced blocks hold the operations appendix, which is a ledger and not a new claim.
|
|
170
167
|
"""
|
|
171
168
|
items: list[tuple[str | None, str]] = []
|
|
@@ -215,8 +212,9 @@ def _is_tombstone(text: str) -> bool:
|
|
|
215
212
|
return "已撤回" in text or "withdrawn" in lowered
|
|
216
213
|
|
|
217
214
|
|
|
218
|
-
def
|
|
215
|
+
def plan_host_merge(package: str, local: dict) -> HostMergePlan:
|
|
219
216
|
"""Diff view lines into host memory. Existing text and anchors are not replaced."""
|
|
217
|
+
assert_private_text(package)
|
|
220
218
|
front = _front(package)
|
|
221
219
|
version = front.get("handoff_version", "")
|
|
222
220
|
if package.startswith("---"):
|
|
@@ -226,7 +224,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
|
|
|
226
224
|
raise ValueError("交接包没有操作附录")
|
|
227
225
|
local_anchors = dict(local.get("anchors") or {})
|
|
228
226
|
local_claims = dict(local.get("claims") or {})
|
|
229
|
-
plan =
|
|
227
|
+
plan = HostMergePlan()
|
|
230
228
|
seen: set[str] = set()
|
|
231
229
|
local_texts = {value for value in local_claims.values() if value}
|
|
232
230
|
for claim_id, text in _claim_items(package):
|
|
@@ -282,7 +280,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
|
|
|
282
280
|
return plan
|
|
283
281
|
|
|
284
282
|
|
|
285
|
-
def
|
|
283
|
+
def apply_host_merge(plan: HostMergePlan, local: dict, *, confirmed: bool) -> dict:
|
|
286
284
|
"""Add confirmed new claims and empty anchors. Never delete or replace existing text."""
|
|
287
285
|
result = deepcopy(local)
|
|
288
286
|
result.setdefault("anchors", {})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Local high-confidence privacy checks. Never echo matched values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_CREDENTIALS = (
|
|
8
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{10,}"),
|
|
9
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"),
|
|
10
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"),
|
|
11
|
+
re.compile(r"\bnpm_[A-Za-z0-9]{20,}"),
|
|
12
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
|
|
13
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{30,}"),
|
|
14
|
+
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
15
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._-]{20,}", re.I),
|
|
16
|
+
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
17
|
+
re.compile(r"\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password|secret)\b\s*[:=]\s*['\"]?[A-Za-z0-9+/._-]{16,}", re.I),
|
|
18
|
+
)
|
|
19
|
+
_HOMES = (
|
|
20
|
+
re.compile(r"/(?:home|Users)/([^/\s\"'<>|`]+)"),
|
|
21
|
+
re.compile(r"[A-Za-z]:\\Users\\([^\\/\s\"'<>|]+)", re.I),
|
|
22
|
+
)
|
|
23
|
+
_PLACEHOLDERS = {"user", "username", "yourname", "example", "runner", "ubuntu", "public", "shared", "zhangsan"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def assert_private_text(text: str) -> None:
|
|
27
|
+
"""Reject obvious credentials and personal home paths before any write.
|
|
28
|
+
|
|
29
|
+
This deliberately does not claim to detect every form of PII. Semantic
|
|
30
|
+
privacy review and user confirmation still apply.
|
|
31
|
+
"""
|
|
32
|
+
for pattern in _CREDENTIALS:
|
|
33
|
+
match = pattern.search(text)
|
|
34
|
+
if match:
|
|
35
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
36
|
+
raise ValueError(f"隐私检查拒绝:第 {line} 行疑似凭据(内容已隐藏)")
|
|
37
|
+
for pattern in _HOMES:
|
|
38
|
+
for match in pattern.finditer(text):
|
|
39
|
+
if match.group(1).lower() not in _PLACEHOLDERS:
|
|
40
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
41
|
+
raise ValueError(f"隐私检查拒绝:第 {line} 行含个人主目录,请泛化为 ~(内容已隐藏)")
|