@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.
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import subprocess
6
- from datetime import date, datetime
7
+ from datetime import date
7
8
  from pathlib import Path
8
9
 
9
10
  from topmind_handoff.compile import VIEW_ORDER as SLICE_ORDER
@@ -11,18 +12,22 @@ from topmind_handoff.compile import build_compiled, compile_repo, render_status
11
12
  from topmind_handoff.model import (
12
13
  INDEX_BYTE_BUDGET,
13
14
  INDEX_LINE_BUDGET,
14
- REVIEW_DAYS,
15
- SLICE_TITLE,
16
15
  ClaimResult,
17
16
  Compiled,
18
17
  Op,
19
18
  origin_markdown,
20
19
  parse_authority,
20
+ parse_day,
21
+ review_suffix,
21
22
  safe_origin,
22
23
  scenario_filename,
23
24
  scenario_names,
25
+ slice_title,
26
+ view_slice_order,
24
27
  )
25
28
 
29
+ _SAFE_NAME = re.compile(r"[A-Za-z0-9_-]+")
30
+
26
31
 
27
32
  class PublicRepoRefused(RuntimeError):
28
33
  """Raised when init is asked to write a public memory repo."""
@@ -32,6 +37,124 @@ def _nl(text: str) -> str:
32
37
  return text if text.endswith("\n") else text + "\n"
33
38
 
34
39
 
40
+ AUTHORITY_INTRO = [
41
+ "# 写入权",
42
+ "",
43
+ "身份和边界的主人固定是 human,只接受 outbox/human.md 里的操作。",
44
+ "每个环境只许在自己的 outbox 末尾追加,只许整文件替换自己的 environments 卡片。",
45
+ "场景主人写在下面。别人写该场景只记成提议。",
46
+ "",
47
+ ]
48
+
49
+
50
+ def _readme(lines: list[str]) -> str:
51
+ return _nl("\n".join(lines))
52
+
53
+
54
+ def memory_readme() -> str:
55
+ """Text an initialized repo uses to explain itself. Two inits write the same bytes."""
56
+ return _readme(
57
+ [
58
+ "# 云端记忆",
59
+ "",
60
+ "这是一份私有记忆仓库。本机的 topmind-handoff 编译它。仓库里没有 CI,也没有 Release。",
61
+ "凭据只用已经配好的 git 或 gh。不写密码、API key、token、证件号、银行卡号、精确住址、他人隐私、记账明细。聊天记录也不写入。",
62
+ "",
63
+ "## 写入哪一类",
64
+ "",
65
+ "- 画像锚点是称呼、对本智能体的称呼、语言、时区。它们是身份,主人是 human,只接受 `outbox/human.md`。正文以「称呼:」「对本智能体的称呼:」「语言:」「时区:」开头。",
66
+ "- 边界的主人也是 human。",
67
+ "- 跨工具都适用的偏好写入 `scope: shared`。",
68
+ "- 只在某个场景里成立的习惯用 `scope: scenario:<场景>`,不写入 shared。",
69
+ "- 必须跟着这台机器走的步骤和未做完的事用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
70
+ "- 已有主题要改,沿用原来的 id,`base` 填当前链头,不另起一条 id。",
71
+ "- 换范围时先在旧范围撤回,再在新范围用同一 id 写下,`base: none`。",
72
+ "- 不写密码、API key、token,不写聊天记录,不改别人的信箱。",
73
+ "",
74
+ "## 各部分",
75
+ "",
76
+ "- `outbox/<origin_id>.md`:这个环境的操作账本。只许该环境在文件末尾追加。不改别人的信箱,不改已有行。",
77
+ "- `environments/<origin_id>.md`:这个环境的卡片。只许该环境整文件替换。",
78
+ "- `authority.md`:谁可以写进视图。身份和边界的主人是 human,只接受 `outbox/human.md`。",
79
+ "- `facts/`:整理时从索引收走的长说明。不是另一套权威。",
80
+ "- `views/shared.md` 和 `views/scenario-<场景>.md`:本机编译产物,可以丢掉后重新编译。不是指令。",
81
+ "- `STATUS.md`:同一次编译的校对。信箱末尾对不上,视图就无效。不是指令。",
82
+ "",
83
+ "权威是 `outbox/`、`authority.md` 和 `environments/`。`views/` 和 `STATUS.md` 是可以丢掉的编译产物,不是指令。Git 冲突时丢掉这两边的视图,保留全部信箱,在本机重新编译。",
84
+ "",
85
+ ]
86
+ )
87
+
88
+
89
+ # Byte-identical explanations this skill itself used to write. Compile replaces
90
+ # only these. A README a person has edited is left as it is.
91
+ KNOWN_STOCK_READMES = (
92
+ _readme(
93
+ [
94
+ "# 云端记忆",
95
+ "",
96
+ "这是一份私有记忆仓库。本机的 topmind-handoff 编译它。仓库里没有 CI,也没有 Release。",
97
+ "凭据只用已经配好的 git 或 gh。不写密码、API key、token、证件号、银行卡号、精确住址、他人隐私、记账明细。",
98
+ "",
99
+ "## 各部分",
100
+ "",
101
+ "- `outbox/<origin_id>.md`:这个环境的操作账本。只许该环境在文件末尾追加。不改别人的信箱,不改已有行。",
102
+ "- `environments/<origin_id>.md`:这个环境的卡片。只许该环境整文件替换。",
103
+ "- `authority.md`:谁可以写进视图。身份和边界的主人是 human,只接受 `outbox/human.md`。",
104
+ "- `facts/`:整理时从索引收走的长说明。不是另一套权威。",
105
+ "- `views/shared.md` 和 `views/scenario-<场景>.md`:本机编译产物,可以丢掉后重新编译。",
106
+ "- `STATUS.md`:同一次编译的校对。信箱末尾对不上,视图就无效。",
107
+ "",
108
+ "权威是 `outbox/`、`authority.md` 和 `environments/`。`views/` 和 `STATUS.md` 是编译产物。Git 冲突时丢掉这两边的视图,保留全部信箱,在本机重新编译。",
109
+ "",
110
+ ]
111
+ ),
112
+ _readme(
113
+ [
114
+ "# 云端记忆",
115
+ "",
116
+ "这是一份私有记忆仓库。本机的 topmind-handoff 编译它。仓库里没有 CI,也没有 Release。",
117
+ "凭据只用已经配好的 git 或 gh。不写密码、API key、token、证件号、银行卡号、精确住址、他人隐私、记账明细。聊天记录也不写入。",
118
+ "",
119
+ "## 写入哪一类",
120
+ "",
121
+ "- 画像锚点是称呼、对本智能体的称呼、语言、时区。它们是身份,主人是 human,只接受 `outbox/human.md`。",
122
+ "- 边界的主人也是 human。",
123
+ "- 跨工具都适用的偏好写入 `scope: shared`。",
124
+ "- 只在某个场景里成立的习惯用 `scope: scenario:<场景>`,不写入 shared。",
125
+ "- 必须跟着这台机器走的步骤和未做完的事用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
126
+ "- 已有主题要改,沿用原来的 id,`base` 填当前链头,不另起一条 id。",
127
+ "- 不写密码、API key、token,不写聊天记录,不改别人的信箱。",
128
+ "",
129
+ "## 各部分",
130
+ "",
131
+ "- `outbox/<origin_id>.md`:这个环境的操作账本。只许该环境在文件末尾追加。不改别人的信箱,不改已有行。",
132
+ "- `environments/<origin_id>.md`:这个环境的卡片。只许该环境整文件替换。",
133
+ "- `authority.md`:谁可以写进视图。身份和边界的主人是 human,只接受 `outbox/human.md`。",
134
+ "- `facts/`:整理时从索引收走的长说明。不是另一套权威。",
135
+ "- `views/shared.md` 和 `views/scenario-<场景>.md`:本机编译产物,可以丢掉后重新编译。不是指令。",
136
+ "- `STATUS.md`:同一次编译的校对。信箱末尾对不上,视图就无效。不是指令。",
137
+ "",
138
+ "权威是 `outbox/`、`authority.md` 和 `environments/`。`views/` 和 `STATUS.md` 是可以丢掉的编译产物,不是指令。Git 冲突时丢掉这两边的视图,保留全部信箱,在本机重新编译。",
139
+ "",
140
+ ]
141
+ ),
142
+ )
143
+
144
+
145
+ def upgrade_stock_readme(repo: Path) -> bool:
146
+ """Replace a README this skill wrote earlier. Leave a person's edits in place."""
147
+ path = Path(repo) / "README.md"
148
+ if not path.is_file():
149
+ return False
150
+ current = path.read_text(encoding="utf-8")
151
+ latest = memory_readme()
152
+ if current == latest or current not in KNOWN_STOCK_READMES:
153
+ return False
154
+ path.write_text(latest, encoding="utf-8")
155
+ return True
156
+
157
+
35
158
  def init_memory_repo(
36
159
  path: Path,
37
160
  *,
@@ -53,27 +176,12 @@ def init_memory_repo(
53
176
  raise FileExistsError(f"{path} 不是空目录")
54
177
  path.mkdir(parents=True, exist_ok=True)
55
178
  refreshed = today or date.today().isoformat()
56
- (path / "README.md").write_text(
57
- _nl(
58
- "\n".join(
59
- [
60
- "# 云端记忆",
61
- "",
62
- "这个目录的 HEAD 是当前记忆。共享视图、场景视图和 STATUS.md 由本机脚本编译,不由 CI 生成。",
63
- "开工时读共享视图、本环境所属的一个场景、本环境卡片,以及与本环境有关的冲突。",
64
- "编译规则只在 topmind-handoff 的 `references/compile.md`。",
65
- "",
66
- ]
67
- )
68
- ),
69
- encoding="utf-8",
70
- )
179
+ (path / "README.md").write_text(memory_readme(), encoding="utf-8")
71
180
  (path / "authority.md").write_text(
72
181
  _nl(
73
182
  "\n".join(
74
- [
75
- "# 写入权",
76
- "",
183
+ AUTHORITY_INTRO
184
+ + [
77
185
  "- scope: shared.identity",
78
186
  " owner: human",
79
187
  "- scope: shared.boundaries",
@@ -84,6 +192,11 @@ def init_memory_repo(
84
192
  ),
85
193
  encoding="utf-8",
86
194
  )
195
+ (path / "facts").mkdir()
196
+ (path / "facts" / "README.md").write_text(
197
+ _nl("# 长说明\n\n整理时才把索引收进这里。这些文件不是另一套权威。权威仍是信箱。\n"),
198
+ encoding="utf-8",
199
+ )
87
200
  outbox = path / "outbox"
88
201
  outbox.mkdir()
89
202
  origin_markdown(outbox, origin).write_text(_nl(f"# outbox {origin}\n"), encoding="utf-8")
@@ -124,7 +237,7 @@ def set_scenario_owner(repo: Path, scenario: str, origin: str) -> None:
124
237
  current[f"scenario.{scenario}"] = origin
125
238
  order = ["shared.identity", "shared.boundaries"]
126
239
  rest = sorted(key for key in current if key not in order)
127
- lines = ["# 写入权", ""]
240
+ lines = list(AUTHORITY_INTRO)
128
241
  for key in order + rest:
129
242
  lines.append(f"- scope: {key}")
130
243
  lines.append(f" owner: {current[key]}")
@@ -152,21 +265,22 @@ def _git_commit(path: Path, message: str) -> None:
152
265
  )
153
266
 
154
267
 
155
- def _parse_day(value: str) -> date | None:
156
- try:
157
- return datetime.strptime(value, "%Y-%m-%d").date()
158
- except ValueError:
159
- return None
268
+ def _review_suffix(op: Op, today: date) -> str:
269
+ return review_suffix(op, today)
160
270
 
161
271
 
162
- def _review_suffix(op: Op, today: date) -> str:
163
- limit = REVIEW_DAYS.get(op.slice)
164
- written = _parse_day(op.at)
165
- if limit is None or written is None:
166
- return ""
167
- if (today - written).days >= limit:
168
- return " (未复核)"
169
- return ""
272
+ def _archive_path(facts_root: Path, fact_prefix: str, slice_name: str) -> Path | None:
273
+ """Stay inside facts/. A slice such as `../evil` is kept inline and does not abort."""
274
+ if _SAFE_NAME.fullmatch(fact_prefix) is None or _SAFE_NAME.fullmatch(slice_name) is None:
275
+ return None
276
+ facts_root.mkdir(parents=True, exist_ok=True)
277
+ candidate = facts_root / f"{fact_prefix}-{slice_name}.md"
278
+ try:
279
+ if candidate.resolve().parent != facts_root.resolve():
280
+ return None
281
+ except OSError:
282
+ return None
283
+ return candidate
170
284
 
171
285
 
172
286
  def _write_slice_archive(
@@ -177,16 +291,15 @@ def _write_slice_archive(
177
291
  rich: list[str],
178
292
  ) -> None:
179
293
  """Keep every collapsed index line, including facts that have no detail."""
180
- facts_root.mkdir(parents=True, exist_ok=True)
294
+ dest = _archive_path(facts_root, fact_prefix, slice_name)
295
+ if dest is None:
296
+ return
181
297
  rich_lines = set(rich)
182
298
  body = list(rich)
183
299
  body.extend(line for line in lines if line not in rich_lines)
184
300
  if not body:
185
301
  body = list(lines)
186
- (facts_root / f"{fact_prefix}-{slice_name}.md").write_text(
187
- _nl("# " + slice_name + "\n\n" + "\n".join(body)),
188
- encoding="utf-8",
189
- )
302
+ dest.write_text(_nl("# " + slice_name + "\n\n" + "\n".join(body)), encoding="utf-8")
190
303
 
191
304
 
192
305
  def _results_for(compiled: Compiled, scope: str) -> list[ClaimResult]:
@@ -213,9 +326,11 @@ def _compact_scope(title: str, results: list[ClaimResult], today: date, facts_ro
213
326
 
214
327
  def render(section_map: dict[str, list[str]]) -> str:
215
328
  lines = [f"# {title}", ""]
216
- for slice_name in SLICE_ORDER:
217
- lines.append(f"## {SLICE_TITLE[slice_name]}")
329
+ for slice_name in view_slice_order(section_map):
218
330
  body = section_map.get(slice_name) or []
331
+ if slice_name not in SLICE_ORDER and not body:
332
+ continue
333
+ lines.append(f"## {slice_title(slice_name)}")
219
334
  lines.extend(body or ["(无)"])
220
335
  lines.append("")
221
336
  lines.append("## 冲突")
@@ -234,14 +349,19 @@ def _compact_scope(title: str, results: list[ClaimResult], today: date, facts_ro
234
349
  for slice_name, block in details.items():
235
350
  if not block:
236
351
  continue
237
- facts_root.mkdir(parents=True, exist_ok=True)
238
- (facts_root / f"{fact_prefix}-{slice_name}.md").write_text(
239
- _nl("# " + slice_name + "\n\n" + "\n".join(block)), encoding="utf-8"
240
- )
352
+ dest = _archive_path(facts_root, fact_prefix, slice_name)
353
+ if dest is None:
354
+ continue
355
+ dest.write_text(_nl("# " + slice_name + "\n\n" + "\n".join(block)), encoding="utf-8")
241
356
 
242
357
  text = render(sections)
243
358
  # Collapse the largest slices until the index fits the startup budget.
244
359
  collapsible = [name for name in ("fact", "preference", "project", "identity", "boundaries")]
360
+ collapsible.extend(
361
+ name
362
+ for name in sections
363
+ if name not in SLICE_ORDER and _SAFE_NAME.fullmatch(name) and name not in collapsible
364
+ )
245
365
  while (text.count("\n") > INDEX_LINE_BUDGET or len(text.encode("utf-8")) > INDEX_BYTE_BUDGET) and any(
246
366
  len(sections.get(name) or []) > 1 for name in collapsible
247
367
  ):
@@ -286,7 +406,7 @@ def _mark_stale(card_path: Path, today: date) -> None:
286
406
  def _card_refreshed(raw: str) -> date | None:
287
407
  for line in raw.splitlines():
288
408
  if line.startswith("refreshed:"):
289
- return _parse_day(line.split(":", 1)[1].strip())
409
+ return parse_day(line.split(":", 1)[1].strip())
290
410
  return None
291
411
 
292
412
 
@@ -330,7 +450,7 @@ def organize_repo(repo: Path, today: date | None = None) -> Compiled:
330
450
  _mark_stale(path, today)
331
451
  # Cards may now be stale; rebuild status from the same outbox tips.
332
452
  compiled = build_compiled(repo)
333
- (repo / "STATUS.md").write_text(render_status(compiled), encoding="utf-8")
453
+ (repo / "STATUS.md").write_text(render_status(compiled, today=today), encoding="utf-8")
334
454
  after = {path.name: path.read_bytes() for path in sorted(outbox.glob("*.md"))} if outbox.is_dir() else {}
335
455
  if before != after:
336
456
  raise RuntimeError("整理改写了信箱")
@@ -8,19 +8,18 @@ from pathlib import Path
8
8
 
9
9
  from topmind_handoff.admin import PublicRepoRefused, init_memory_repo, organize_repo, set_scenario_owner
10
10
  from topmind_handoff.compile import compile_repo, startup_read, status_is_valid
11
- from topmind_handoff.package import render_legacy_package, render_package
11
+ from topmind_handoff.package import render_package
12
12
 
13
13
  USAGE = """\
14
14
  用法:
15
- python3 scripts/compile_memory.py <记忆目录>
15
+ python3 scripts/compile_memory.py <记忆目录> [--today YYYY-MM-DD]
16
16
  python3 scripts/compile_memory.py --check <记忆目录>
17
17
  python3 scripts/compile_memory.py --init <目录> --private --origin <origin_id> [--role 场景] [--today YYYY-MM-DD]
18
18
  python3 scripts/compile_memory.py --init <目录> --public --origin <origin_id>
19
19
  python3 scripts/compile_memory.py --organize <记忆目录> [--today YYYY-MM-DD]
20
- python3 scripts/compile_memory.py --render <记忆目录> --origin <origin_id> [--out 文件]
21
- python3 scripts/compile_memory.py --render-legacy --out 文件
20
+ python3 scripts/compile_memory.py --render <记忆目录> --origin <origin_id> [--out 文件] [--today YYYY-MM-DD]
22
21
  python3 scripts/compile_memory.py --own <记忆目录> --scenario <场景> --origin <origin_id>
23
- python3 scripts/compile_memory.py --startup <记忆目录> --origin <origin_id>
22
+ python3 scripts/compile_memory.py --startup <记忆目录> --origin <origin_id> [--today YYYY-MM-DD]
24
23
  """
25
24
 
26
25
 
@@ -42,8 +41,6 @@ def main(argv: list[str]) -> int:
42
41
  return _organize(argv[1:])
43
42
  if argv[0] == "--render":
44
43
  return _render(argv[1:])
45
- if argv[0] == "--render-legacy":
46
- return _render_legacy(argv[1:])
47
44
  if argv[0] == "--own":
48
45
  return _own(argv[1:])
49
46
  if argv[0] == "--startup":
@@ -51,9 +48,7 @@ def main(argv: list[str]) -> int:
51
48
  if argv[0].startswith("--"):
52
49
  print(USAGE)
53
50
  return 2
54
- compile_repo(Path(argv[0]))
55
- print(f"compiled {argv[0]}")
56
- return 0
51
+ return _compile(argv)
57
52
 
58
53
 
59
54
  def _init(argv: list[str]) -> int:
@@ -81,6 +76,17 @@ def _init(argv: list[str]) -> int:
81
76
  return 0
82
77
 
83
78
 
79
+ def _compile(argv: list[str]) -> int:
80
+ parser = argparse.ArgumentParser(add_help=False)
81
+ parser.add_argument("path")
82
+ parser.add_argument("--today")
83
+ args = parser.parse_args(argv)
84
+ today = _day(args.today) if args.today else None
85
+ compile_repo(Path(args.path), today)
86
+ print(f"compiled {args.path}")
87
+ return 0
88
+
89
+
84
90
  def _organize(argv: list[str]) -> int:
85
91
  parser = argparse.ArgumentParser(add_help=False)
86
92
  parser.add_argument("path")
@@ -97,9 +103,11 @@ def _render(argv: list[str]) -> int:
97
103
  parser.add_argument("path")
98
104
  parser.add_argument("--origin", required=True)
99
105
  parser.add_argument("--out")
106
+ parser.add_argument("--today")
100
107
  args = parser.parse_args(argv)
108
+ today = _day(args.today) if args.today else None
101
109
  try:
102
- text = render_package(Path(args.path), args.origin)
110
+ text = render_package(Path(args.path), args.origin, today=today)
103
111
  except ValueError as exc:
104
112
  print(str(exc))
105
113
  return 1
@@ -130,28 +138,14 @@ def _startup(argv: list[str]) -> int:
130
138
  parser = argparse.ArgumentParser(add_help=False)
131
139
  parser.add_argument("path")
132
140
  parser.add_argument("--origin", required=True)
141
+ parser.add_argument("--today")
133
142
  args = parser.parse_args(argv)
143
+ today = _day(args.today) if args.today else None
134
144
  try:
135
- print(startup_read(Path(args.path), args.origin), end="")
145
+ print(startup_read(Path(args.path), args.origin, today=today), end="")
136
146
  except ValueError as exc:
137
147
  print(str(exc))
138
148
  return 1
139
149
  return 0
140
150
 
141
151
 
142
- def _render_legacy(argv: list[str]) -> int:
143
- parser = argparse.ArgumentParser(add_help=False)
144
- parser.add_argument("--out", required=True)
145
- parser.add_argument("--mode", default="migration")
146
- args = parser.parse_args(argv)
147
- text = render_legacy_package(
148
- [
149
- {"section": "1. 这个人是谁(Identity)", "text": "称呼:示例", "id": "anchor-name"},
150
- {"section": "1. 这个人是谁(Identity)", "text": "时区:Asia/Shanghai", "id": "anchor-tz"},
151
- {"section": "5. 事实与记忆(Facts & memories)", "text": "已撤回:示例条目", "id": "pref-vim"},
152
- ],
153
- mode=args.mode,
154
- )
155
- Path(args.out).write_text(text, encoding="utf-8")
156
- print(args.out)
157
- return 0