@topmindspace/topmind-handoff 0.6.4 → 0.7.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.
@@ -10,12 +10,12 @@ from pathlib import Path
10
10
  from topmind_handoff.compile import VIEW_ORDER as SLICE_ORDER
11
11
  from topmind_handoff.compile import build_compiled, compile_repo, render_status
12
12
  from topmind_handoff.model import (
13
- INDEX_BYTE_BUDGET,
14
- INDEX_LINE_BUDGET,
13
+ CLUSTER_SIZE,
15
14
  ClaimResult,
16
15
  Compiled,
17
16
  Op,
18
17
  origin_markdown,
18
+ over_budget,
19
19
  parse_authority,
20
20
  parse_day,
21
21
  review_suffix,
@@ -66,7 +66,8 @@ def memory_readme() -> str:
66
66
  "- 边界的主人也是 human。",
67
67
  "- 跨工具都适用的偏好写入 `scope: shared`。",
68
68
  "- 只在某个场景里成立的习惯用 `scope: scenario:<场景>`,不写入 shared。",
69
- "- 必须跟着这台机器走的步骤和未做完的事用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
69
+ "- 另一台要接着做的下一步用 `scope: shared` 或 `scope: scenario:<场景>`。只在这台的路径和工具状态用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
70
+ "- 产出只记状态、下一步和位置,不记正文。`slice` 用 `work` 或 `project`。`~/` 路径用 `scope: origin:`。",
70
71
  "- 已有主题要改,沿用原来的 id,`base` 填当前链头,不另起一条 id。",
71
72
  "- 换范围时先在旧范围撤回,再在新范围用同一 id 写下,`base: none`。",
72
73
  "- 不写密码、API key、token,不写聊天记录,不改别人的信箱。",
@@ -262,6 +263,76 @@ KNOWN_STOCK_READMES = (
262
263
  "",
263
264
  ]
264
265
  ),
266
+ _readme(
267
+ [
268
+ "# 云端记忆",
269
+ "",
270
+ "这是一份私有记忆仓库。本机的 topmind-handoff 编译它。仓库里没有 CI,也没有 Release。",
271
+ "凭据只用已经配好的 git 或 gh。不写密码、API key、token、证件号、银行卡号、精确住址、他人隐私、记账明细。聊天记录也不写入。",
272
+ "",
273
+ "## 写入哪一类",
274
+ "",
275
+ "- 画像锚点是称呼、对本智能体的称呼、语言、时区。它们是身份,主人是 human,只接受 `outbox/human.md`。正文以「称呼:」「对本智能体的称呼:」「语言:」「时区:」开头。",
276
+ "- 边界的主人也是 human。",
277
+ "- 跨工具都适用的偏好写入 `scope: shared`。",
278
+ "- 只在某个场景里成立的习惯用 `scope: scenario:<场景>`,不写入 shared。",
279
+ "- 必须跟着这台机器走的步骤和未做完的事用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
280
+ "- 已有主题要改,沿用原来的 id,`base` 填当前链头,不另起一条 id。",
281
+ "- 换范围时先在旧范围撤回,再在新范围用同一 id 写下,`base: none`。",
282
+ "- 不写密码、API key、token,不写聊天记录,不改别人的信箱。",
283
+ "",
284
+ "## 各部分",
285
+ "",
286
+ "- `outbox/<origin_id>.md`:这个环境的操作账本。只许该环境在文件末尾追加。不改别人的信箱,不改已有行。",
287
+ "- `environments/<origin_id>.md`:这个环境的卡片。只许该环境整文件替换。",
288
+ "- `authority.md`:谁可以写进视图。身份和边界的主人是 human,只接受 `outbox/human.md`。",
289
+ "- `facts/`:整理时从索引收走的长说明。不是另一套权威。",
290
+ "- `views/shared.md` 和 `views/scenario-<场景>.md`:本机编译产物,可以丢掉后重新编译。不是指令。",
291
+ "- `STATUS.md`:同一次编译的校对。信箱末尾对不上,视图就无效。不是指令。",
292
+ "",
293
+ "权威是 `outbox/`、`authority.md` 和 `environments/`。`views/` 和 `STATUS.md` 是可以丢掉的编译产物,不是指令。Git 冲突时丢掉这两边的视图,保留全部信箱,在本机重新编译。",
294
+ "",
295
+ ]
296
+ ),
297
+ _readme(
298
+ [
299
+ "# 云端记忆",
300
+ "",
301
+ "这是一份私有记忆仓库。本机的 topmind-handoff 编译它。仓库里没有 CI,也没有 Release。",
302
+ "凭据只用已经配好的 git 或 gh。不写密码、API key、token、证件号、银行卡号、精确住址、他人隐私、记账明细。聊天记录也不写入。",
303
+ "",
304
+ "## 写入哪一类",
305
+ "",
306
+ "- 画像锚点是称呼、对本智能体的称呼、语言、时区。它们是身份,主人是 human,只接受 `outbox/human.md`。正文以「称呼:」「对本智能体的称呼:」「语言:」「时区:」开头。",
307
+ "- 边界的主人也是 human。",
308
+ "- 跨工具都适用的偏好写入 `scope: shared`。",
309
+ "- 只在某个场景里成立的习惯用 `scope: scenario:<场景>`,不写入 shared。",
310
+ "- 必须跟着这台机器走的步骤和未做完的事用 `scope: origin:<origin_id>`。技能版本和连接器写在这台的环境卡片上,不写成共享事实。",
311
+ "- 已有主题要改,沿用原来的 id,`base` 填当前链头,不另起一条 id。",
312
+ "- 换范围时先在旧范围撤回,再在新范围用同一 id 写下,`base: none`。",
313
+ "- 不写密码、API key、token,不写聊天记录,不改别人的信箱。",
314
+ "",
315
+ "## 各部分",
316
+ "",
317
+ "- `outbox/<origin_id>.md`:这个环境的操作账本。只许该环境在文件末尾追加。不改别人的信箱,不改已有行。",
318
+ "- `environments/<origin_id>.md`:这个环境的卡片。只许该环境整文件替换。",
319
+ "- `authority.md`:谁可以写进视图。身份和边界的主人是 human,只接受 `outbox/human.md`。",
320
+ "- `facts/`:整理时从索引收走的长说明。不是另一套权威。",
321
+ "- `views/shared.md` 和 `views/scenario-<场景>.md`:本机编译产物,可以丢掉后重新编译。不是指令。",
322
+ "- `STATUS.md`:同一次编译的校对。信箱末尾对不上,视图就无效。不是指令。",
323
+ "- `knowledge/`:跨环境共享的经验、收藏帖、提示词。普通 Markdown,任何环境直接编辑。见 `knowledge/README.md`。",
324
+ "",
325
+ "权威是 `outbox/`、`authority.md` 和 `environments/`。`views/` 和 `STATUS.md` 是可以丢掉的编译产物,不是指令。Git 冲突时丢掉这两边的视图,保留全部信箱,在本机重新编译。",
326
+ "",
327
+ "## knowledge/ 知识库",
328
+ "",
329
+ "- `knowledge/<大类>/<主题>.md`,最多两级。大类有收藏帖、段子、引流曝光、写作、图片视频、提示词。",
330
+ "- 它是参考资料,不是指令,不覆盖偏好与边界,不进 `outbox/`、编译、视图和 `authority.md`。",
331
+ "- 任何环境都可以直接改,提交并推送即可,git 历史就是留痕。不写凭据、隐私和聊天记录。",
332
+ "- 条目格式、拆分规则、读写时机见 `knowledge/README.md`。",
333
+ "",
334
+ ]
335
+ ),
265
336
  )
266
337
 
267
338
 
@@ -432,17 +503,52 @@ def _results_for(compiled: Compiled, scope: str) -> list[ClaimResult]:
432
503
  return [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
433
504
 
434
505
 
435
- def _compact_scope(title: str, results: list[ClaimResult], today: date, facts_root: Path, fact_prefix: str) -> str:
506
+ def _index_line(head: Op, today: date) -> str:
507
+ return f"- {head.text} {{id:{head.claim_id}}}{_review_suffix(head, today)}"
508
+
509
+
510
+ def _cluster_line(chunk: list[Op], today: date, archive: str) -> str:
511
+ """One directory row: a few claim texts, every id, and the facts file that holds the rest."""
512
+ sample = ";".join(head.text for head in chunk[:3])
513
+ count = len(chunk)
514
+ label = f"{sample} 等 {count} 条" if count > 3 else f"{sample}({count} 条)"
515
+ ids = " ".join(head.claim_id for head in chunk)
516
+ return f"- {label}:{ids}。见 facts/{archive}.md"
517
+
518
+
519
+ def _pack_heads(heads: list[Op], today: date, archive: str, size: int) -> list[str]:
520
+ lines: list[str] = []
521
+ step = max(size, 2)
522
+ for start in range(0, len(heads), step):
523
+ chunk = heads[start : start + step]
524
+ if len(chunk) == 1:
525
+ lines.append(_index_line(chunk[0], today))
526
+ else:
527
+ lines.append(_cluster_line(chunk, today, archive))
528
+ return lines
529
+
530
+
531
+ def _compact_scope(
532
+ title: str,
533
+ results: list[ClaimResult],
534
+ today: date,
535
+ facts_root: Path,
536
+ fact_prefix: str,
537
+ *,
538
+ fit: bool,
539
+ ) -> str:
436
540
  sections: dict[str, list[str]] = {name: [] for name in SLICE_ORDER}
437
541
  details: dict[str, list[str]] = {name: [] for name in SLICE_ORDER}
542
+ section_heads: dict[str, list[Op]] = {}
438
543
  for result in sorted(results, key=lambda item: item.claim_id):
439
544
  if result.conflict or result.retracted or not result.heads:
440
545
  continue
441
546
  head = result.heads[0]
442
547
  if head.kind != "set":
443
548
  continue
444
- line = f"- {head.text} {{id:{head.claim_id}}}{_review_suffix(head, today)}"
549
+ line = _index_line(head, today)
445
550
  sections.setdefault(head.slice, []).append(line)
551
+ section_heads.setdefault(head.slice, []).append(head)
446
552
  if head.detail or _review_suffix(head, today):
447
553
  block = [f"## {head.claim_id}", line]
448
554
  if head.detail:
@@ -481,23 +587,42 @@ def _compact_scope(title: str, results: list[ClaimResult], today: date, facts_ro
481
587
  dest.write_text(_nl("# " + slice_name + "\n\n" + "\n".join(block)), encoding="utf-8")
482
588
 
483
589
  text = render(sections)
484
- # Collapse the largest slices until the index fits the startup budget.
485
- collapsible = [name for name in ("fact", "preference", "project", "identity", "boundaries")]
486
- collapsible.extend(
487
- name
488
- for name in sections
489
- if name not in SLICE_ORDER and _SAFE_NAME.fullmatch(name) and name not in collapsible
490
- )
491
- while (text.count("\n") > INDEX_LINE_BUDGET or len(text.encode("utf-8")) > INDEX_BYTE_BUDGET) and any(
492
- len(sections.get(name) or []) > 1 for name in collapsible
493
- ):
494
- name = max(collapsible, key=lambda item: len(sections.get(item) or []))
495
- count = len(sections.get(name) or [])
496
- if count <= 1:
590
+ if not fit:
591
+ return text
592
+ sizes: dict[str, int] = {}
593
+ frozen: set[str] = set()
594
+ while over_budget(text):
595
+ pending = [
596
+ name
597
+ for name, heads in section_heads.items()
598
+ if name not in frozen and len(heads) > 1 and _archive_path(facts_root, fact_prefix, name) is not None
599
+ ]
600
+ if not pending:
497
601
  break
498
- _write_slice_archive(facts_root, fact_prefix, name, sections.get(name) or [], details.get(name) or [])
499
- sections[name] = [f"- 见 facts/{fact_prefix}-{name}.md({count} 条)"]
602
+ name = max(pending, key=lambda item: len(section_heads[item]))
603
+ heads = section_heads[name]
604
+ size = sizes.get(name, CLUSTER_SIZE)
605
+ if name in sizes:
606
+ if size >= len(heads):
607
+ frozen.add(name)
608
+ continue
609
+ size = min(len(heads), size * 2)
610
+ sizes[name] = size
611
+ archive = _archive_path(facts_root, fact_prefix, name)
612
+ if archive is None:
613
+ frozen.add(name)
614
+ continue
615
+ _write_slice_archive(
616
+ facts_root,
617
+ fact_prefix,
618
+ name,
619
+ [_index_line(head, today) for head in heads],
620
+ details.get(name) or [],
621
+ )
622
+ sections[name] = _pack_heads(heads, today, archive.stem, size)
500
623
  text = render(sections)
624
+ if size >= len(heads) and over_budget(text):
625
+ frozen.add(name)
501
626
  return text
502
627
 
503
628
 
@@ -536,22 +661,19 @@ def _card_refreshed(raw: str) -> date | None:
536
661
  return None
537
662
 
538
663
 
539
- def organize_repo(repo: Path, today: date | None = None) -> Compiled:
540
- """Compact the fact index, label due facts, and mark stale cards.
664
+ def write_index_views(repo: Path, compiled: Compiled, today: date | None, *, fit: bool) -> None:
665
+ """Write one-line views. `fit` clusters slices that are past the startup budget.
541
666
 
542
- Outbox files are not rewritten. There is no timer and no resident process.
667
+ Detail stays in the outbox. A fitted pass also copies it under facts/. Outboxes are not touched.
543
668
  """
544
669
  repo = Path(repo)
545
670
  today = today or date.today()
546
- outbox = repo / "outbox"
547
- before = {path.name: path.read_bytes() for path in sorted(outbox.glob("*.md"))} if outbox.is_dir() else {}
548
- compiled = build_compiled(repo)
549
671
  views = repo / "views"
550
672
  views.mkdir(parents=True, exist_ok=True)
551
673
  facts_root = repo / "facts"
552
- shared = _results_for(compiled, "shared")
553
674
  (views / "shared.md").write_text(
554
- _compact_scope("共享", shared, today, facts_root, "shared"), encoding="utf-8"
675
+ _compact_scope("共享", _results_for(compiled, "shared"), today, facts_root, "shared", fit=fit),
676
+ encoding="utf-8",
555
677
  )
556
678
  known_ops: list[Op] = list(compiled.proposals)
557
679
  for result in compiled.claims.values():
@@ -565,18 +687,40 @@ def organize_repo(repo: Path, today: date | None = None) -> Compiled:
565
687
  continue
566
688
  keep.add(filename)
567
689
  scope = f"scenario:{name}"
568
- text = _compact_scope(f"场景:{name}", _results_for(compiled, scope), today, facts_root, f"scenario-{name}")
690
+ text = _compact_scope(
691
+ f"场景:{name}",
692
+ _results_for(compiled, scope),
693
+ today,
694
+ facts_root,
695
+ f"scenario-{name}",
696
+ fit=fit,
697
+ )
569
698
  (views / filename).write_text(text, encoding="utf-8")
570
699
  for path in views.glob("scenario-*.md"):
571
700
  if path.name not in keep:
572
701
  path.unlink()
702
+
703
+
704
+ def organize_repo(repo: Path, today: date | None = None) -> Compiled:
705
+ """Fit the index, label due facts, and mark stale cards.
706
+
707
+ Outbox files are not rewritten. There is no timer and no resident process.
708
+ """
709
+ repo = Path(repo)
710
+ today = today or date.today()
711
+ outbox = repo / "outbox"
712
+ before = {path.name: path.read_bytes() for path in sorted(outbox.glob("*.md"))} if outbox.is_dir() else {}
713
+ compiled = build_compiled(repo)
714
+ write_index_views(repo, compiled, today, fit=True)
573
715
  environments = repo / "environments"
574
716
  if environments.is_dir():
575
717
  for path in sorted(environments.glob("*.md")):
576
718
  _mark_stale(path, today)
577
719
  # Cards may now be stale; rebuild status from the same outbox tips.
578
720
  compiled = build_compiled(repo)
579
- (repo / "STATUS.md").write_text(render_status(compiled, today=today), encoding="utf-8")
721
+ (repo / "STATUS.md").write_text(
722
+ render_status(compiled, today=today, index_fit="organized"), encoding="utf-8"
723
+ )
580
724
  after = {path.name: path.read_bytes() for path in sorted(outbox.glob("*.md"))} if outbox.is_dir() else {}
581
725
  if before != after:
582
726
  raise RuntimeError("整理改写了信箱")
@@ -7,7 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
 
9
9
  from topmind_handoff.admin import PublicRepoRefused, init_memory_repo, organize_repo, set_scenario_owner
10
- from topmind_handoff.compile import compile_repo, startup_read, status_is_valid
10
+ from topmind_handoff.compile import compile_repo, open_claim, startup_read, status_is_valid
11
11
  from topmind_handoff.package import render_package
12
12
 
13
13
  USAGE = """\
@@ -17,9 +17,10 @@ USAGE = """\
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 --open <记忆目录> --id <claim_id>
20
21
  python3 scripts/compile_memory.py --render <记忆目录> --origin <origin_id> [--out 文件] [--today YYYY-MM-DD]
21
22
  python3 scripts/compile_memory.py --own <记忆目录> --scenario <场景> --origin <origin_id>
22
- python3 scripts/compile_memory.py --startup <记忆目录> --origin <origin_id> [--today YYYY-MM-DD]
23
+ python3 scripts/compile_memory.py --startup <记忆目录> --origin <origin_id> [--also <场景>] [--today YYYY-MM-DD]
23
24
  """
24
25
 
25
26
 
@@ -39,6 +40,8 @@ def main(argv: list[str]) -> int:
39
40
  return _init(argv[1:])
40
41
  if argv[0] == "--organize":
41
42
  return _organize(argv[1:])
43
+ if argv[0] == "--open":
44
+ return _open(argv[1:])
42
45
  if argv[0] == "--render":
43
46
  return _render(argv[1:])
44
47
  if argv[0] == "--own":
@@ -98,6 +101,19 @@ def _organize(argv: list[str]) -> int:
98
101
  return 0
99
102
 
100
103
 
104
+ def _open(argv: list[str]) -> int:
105
+ parser = argparse.ArgumentParser(add_help=False)
106
+ parser.add_argument("path")
107
+ parser.add_argument("--id", required=True)
108
+ args = parser.parse_args(argv)
109
+ try:
110
+ print(open_claim(Path(args.path), args.id), end="")
111
+ except ValueError as exc:
112
+ print(str(exc))
113
+ return 1
114
+ return 0
115
+
116
+
101
117
  def _render(argv: list[str]) -> int:
102
118
  parser = argparse.ArgumentParser(add_help=False)
103
119
  parser.add_argument("path")
@@ -138,11 +154,12 @@ def _startup(argv: list[str]) -> int:
138
154
  parser = argparse.ArgumentParser(add_help=False)
139
155
  parser.add_argument("path")
140
156
  parser.add_argument("--origin", required=True)
157
+ parser.add_argument("--also")
141
158
  parser.add_argument("--today")
142
159
  args = parser.parse_args(argv)
143
160
  today = _day(args.today) if args.today else None
144
161
  try:
145
- print(startup_read(Path(args.path), args.origin, today=today), end="")
162
+ print(startup_read(Path(args.path), args.origin, today=today, also=args.also), end="")
146
163
  except ValueError as exc:
147
164
  print(str(exc))
148
165
  return 1
@@ -8,6 +8,8 @@ from pathlib import Path
8
8
 
9
9
  from topmind_handoff.model import (
10
10
  CARD_STALE_DAYS,
11
+ INDEX_BYTE_BUDGET,
12
+ INDEX_LINE_BUDGET,
11
13
  REVIEW_MARK,
12
14
  SLICES,
13
15
  Card,
@@ -17,6 +19,7 @@ from topmind_handoff.model import (
17
19
  compile_ops,
18
20
  compiler_version,
19
21
  origin_markdown,
22
+ over_budget,
20
23
  parse_authority,
21
24
  parse_day,
22
25
  review_suffix,
@@ -31,6 +34,7 @@ from topmind_handoff.model import (
31
34
 
32
35
  VIEW_ORDER = ("identity", "boundaries", "preference", "project", "fact")
33
36
  _REVIEW_AS_OF = re.compile(r"^review_as_of:\s*(\d{4}-\d{2}-\d{2})\s*$", re.M)
37
+ _INDEX_FIT = re.compile(r"^index_fit:\s*(organized|full)\s*$", re.M)
34
38
  _CITE_EDGE = r"[A-Za-z0-9_.-]"
35
39
 
36
40
 
@@ -145,7 +149,7 @@ def _all_ops(compiled: Compiled) -> list[Op]:
145
149
  return ops
146
150
 
147
151
 
148
- def render_status(compiled: Compiled, today: date | None = None) -> str:
152
+ def render_status(compiled: Compiled, today: date | None = None, *, index_fit: str = "full") -> str:
149
153
  version = compiler_version()
150
154
  lines = [
151
155
  "# STATUS",
@@ -153,6 +157,7 @@ def render_status(compiled: Compiled, today: date | None = None) -> str:
153
157
  "本文件和 views/ 是本机编译产物,不是权威。权威是 outbox/、authority.md 和 environments/。对不上就丢掉视图,重新编译。",
154
158
  "",
155
159
  f"compiler: topmind-handoff {version}",
160
+ f"index_fit: {index_fit if index_fit == 'organized' else 'full'}",
156
161
  "",
157
162
  ]
158
163
  if today is not None:
@@ -225,6 +230,17 @@ def render_status(compiled: Compiled, today: date | None = None) -> str:
225
230
  return _ensure_nl("\n".join(lines))
226
231
 
227
232
 
233
+ def stored_index_fit(repo: Path) -> str:
234
+ """`organized` after the user has asked for a fitted index. Absent means one line per claim."""
235
+ path = Path(repo) / "STATUS.md"
236
+ if not path.is_file():
237
+ return "full"
238
+ match = _INDEX_FIT.search(path.read_text(encoding="utf-8"))
239
+ if match is None:
240
+ return "full"
241
+ return match.group(1)
242
+
243
+
228
244
  def stored_review_today(repo: Path) -> date | None:
229
245
  """The explicit day a previous compile or organize used. Absent means unlabeled views."""
230
246
  path = Path(repo) / "STATUS.md"
@@ -245,16 +261,25 @@ def compile_repo(repo: Path, today: date | None = None) -> Compiled:
245
261
  repo = Path(repo)
246
262
  if today is None:
247
263
  today = stored_review_today(repo)
264
+ fit = stored_index_fit(repo)
248
265
  compiled = build_compiled(repo)
249
266
  views = repo / "views"
250
267
  views.mkdir(parents=True, exist_ok=True)
251
- rendered = render_views(compiled, inline_detail=True, today=today)
252
- for path in views.glob("scenario-*.md"):
253
- if path.name not in rendered:
254
- path.unlink()
255
- for name, text in rendered.items():
256
- (views / name).write_text(text, encoding="utf-8")
257
- (repo / "STATUS.md").write_text(render_status(compiled, today=today), encoding="utf-8")
268
+ if fit == "organized":
269
+ # Local import: admin imports this module. Fitting keeps a directory the user already asked for.
270
+ from topmind_handoff.admin import write_index_views
271
+
272
+ write_index_views(repo, compiled, today, fit=True)
273
+ else:
274
+ rendered = render_views(compiled, inline_detail=False, today=today)
275
+ for path in views.glob("scenario-*.md"):
276
+ if path.name not in rendered:
277
+ path.unlink()
278
+ for name, text in rendered.items():
279
+ (views / name).write_text(text, encoding="utf-8")
280
+ (repo / "STATUS.md").write_text(
281
+ render_status(compiled, today=today, index_fit=fit), encoding="utf-8"
282
+ )
258
283
  # Local import: admin imports this module. Only a stock README this skill wrote is replaced.
259
284
  from topmind_handoff.admin import upgrade_stock_readme
260
285
 
@@ -362,7 +387,7 @@ def _apply_overrides(shared: str, overrides: dict[str, str]) -> str:
362
387
  return _ensure_nl("\n".join(kept))
363
388
 
364
389
 
365
- _FACT_POINTER = re.compile(r"^- 见 facts/(?P<name>[^/\s\\]+)\.md")
390
+ _FACT_POINTER = re.compile(r"见 facts/(?P<name>[A-Za-z0-9_-]+)\.md")
366
391
 
367
392
 
368
393
  def _path_inside(path: Path, directory: Path) -> bool:
@@ -406,7 +431,7 @@ def _project_shared(repo: Path, shared: str, overrides: dict[str, str]) -> str:
406
431
  output: list[str] = []
407
432
  for line in projected.splitlines():
408
433
  output.append(line)
409
- pointer = _FACT_POINTER.match(line.rstrip())
434
+ pointer = _FACT_POINTER.search(line.rstrip())
410
435
  if pointer is None:
411
436
  continue
412
437
  output.extend(_archived_override_lines(facts, pointer.group("name"), overrides, seen))
@@ -529,7 +554,7 @@ def _origin_projection(compiled: Compiled, origin: str, today: date | None = Non
529
554
  """
530
555
  scope = f"origin:{origin}"
531
556
  results = _results_for(compiled, scope)
532
- sections = _section_lines(results, inline_detail=True, today=today)
557
+ sections = _section_lines(results, inline_detail=False, today=today)
533
558
  filled = [name for name in view_slice_order(sections) if sections.get(name)]
534
559
  if not filled:
535
560
  return ""
@@ -541,6 +566,39 @@ def _origin_projection(compiled: Compiled, origin: str, today: date | None = Non
541
566
  return "\n".join(lines).rstrip() + "\n"
542
567
 
543
568
 
569
+ def _without_claim_details(text: str) -> str:
570
+ """Drop indented detail under a claim line. Conflict sub-lines are not claim lines."""
571
+ source = text.splitlines()
572
+ kept: list[str] = []
573
+ index = 0
574
+ while index < len(source):
575
+ kept.append(source[index])
576
+ if _FACT_LINE.match(source[index].rstrip()):
577
+ index += 1
578
+ while index < len(source) and _is_detail_line(source[index]):
579
+ index += 1
580
+ continue
581
+ index += 1
582
+ return _ensure_nl("\n".join(kept))
583
+
584
+
585
+ def organize_advice(shared: str, scenario: str) -> str:
586
+ """Prompt text when an index is past the startup budget. It does not write or retract."""
587
+ notes: list[str] = []
588
+ limit = f"{INDEX_LINE_BUDGET} 行或 {INDEX_BYTE_BUDGET // 1024}KB"
589
+ if over_budget(shared):
590
+ notes.append(f"共享索引超过预算({limit})。")
591
+ if over_budget(scenario):
592
+ notes.append(f"本场景索引超过预算({limit})。")
593
+ if not notes:
594
+ return ""
595
+ lines = ["## 建议整理", ""]
596
+ for note in notes:
597
+ lines.append(f"- {note}做完这一轮再问要不要整理云端记忆。不自动运行 --organize。")
598
+ lines.append("")
599
+ return _ensure_nl("\n".join(lines))
600
+
601
+
544
602
  def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
545
603
  """One origin projection shared by startup and the offline package.
546
604
 
@@ -552,7 +610,7 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
552
610
  if today is None:
553
611
  today = stored_review_today(repo)
554
612
  views = repo / "views"
555
- rendered = render_views(compiled, today=today)
613
+ rendered = render_views(compiled, inline_detail=False, today=today)
556
614
  shared_path = views / "shared.md"
557
615
  if shared_path.is_file() and _path_inside(shared_path, views):
558
616
  shared = shared_path.read_text(encoding="utf-8")
@@ -560,10 +618,12 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
560
618
  shared = rendered["shared.md"]
561
619
  card = compiled.cards.get(origin)
562
620
  overrides = card.overrides if card else {}
563
- projected = _label_text(
564
- _project_shared(repo, shared, overrides),
565
- _scope_heads(compiled, "shared"),
566
- today,
621
+ projected = _without_claim_details(
622
+ _label_text(
623
+ _project_shared(repo, shared, overrides),
624
+ _scope_heads(compiled, "shared"),
625
+ today,
626
+ )
567
627
  )
568
628
  role = card.role if card else ""
569
629
  filename = scenario_filename(role) if role else None
@@ -576,7 +636,9 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
576
636
  scenario = rendered.get(filename, "# 场景\n\n(无)\n")
577
637
  else:
578
638
  scenario = "(无)\n"
579
- scenario = _label_text(scenario, _scope_heads(compiled, f"scenario:{role}") if role else {}, today)
639
+ scenario = _without_claim_details(
640
+ _label_text(scenario, _scope_heads(compiled, f"scenario:{role}") if role else {}, today)
641
+ )
580
642
  card_text = card.raw if card else "(无)\n"
581
643
  origin_text = _origin_projection(compiled, origin, today=today).rstrip("\n")
582
644
  visible: list[ClaimResult] = list(_results_for(compiled, "shared"))
@@ -607,16 +669,104 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
607
669
  "",
608
670
  ]
609
671
  )
672
+ advice = organize_advice(projected, scenario)
673
+ if advice:
674
+ parts.extend([advice.rstrip("\n"), ""])
610
675
  return _ensure_nl("\n".join(parts))
611
676
 
612
677
 
613
- def startup_read(repo: Path, origin: str, today: date | None = None) -> str:
678
+ def _also_scenario(repo: Path, origin: str, also: str, today: date | None) -> str:
679
+ """One extra scenario view. The card role is not changed."""
680
+ if scenario_filename(also) is None:
681
+ raise ValueError("场景名必须是单个路径段")
682
+ compiled = build_compiled(repo)
683
+ card = compiled.cards.get(origin)
684
+ role = card.role if card else ""
685
+ if also == role:
686
+ return ""
687
+ if today is None:
688
+ today = stored_review_today(repo)
689
+ filename = scenario_filename(also)
690
+ views = repo / "views"
691
+ path = views / filename if filename else None
692
+ rendered = render_views(compiled, inline_detail=False, today=today)
693
+ if path is not None and path.is_file() and _path_inside(path, views):
694
+ text = path.read_text(encoding="utf-8")
695
+ elif filename:
696
+ text = rendered.get(filename, f"# 场景:{also}\n\n(无)\n")
697
+ else:
698
+ text = "(无)\n"
699
+ text = _without_claim_details(
700
+ _label_text(text, _scope_heads(compiled, f"scenario:{also}"), today)
701
+ )
702
+ return _ensure_nl(f"## 另带场景:{also}\n\n{text.rstrip()}")
703
+
704
+
705
+ def startup_read(repo: Path, origin: str, today: date | None = None, also: str | None = None) -> str:
614
706
  """Shared view, this origin's scenario, its card, and conflicts that involve it.
615
707
 
616
708
  Card overrides are applied only in this projection. views/shared.md is not rewritten.
617
- Due labels and the 待提炼 list are printed. Nothing is retracted.
709
+ Due labels, the 待提炼 list, and an organize prompt are printed. Nothing is retracted.
618
710
  """
619
711
  repo = Path(repo)
620
712
  origin = safe_origin(origin)
621
713
  body = projection_body(repo, origin, today=today).rstrip("\n")
714
+ if also:
715
+ extra = _also_scenario(repo, origin, also, today).rstrip("\n")
716
+ if extra:
717
+ body = f"{body}\n\n{extra}"
622
718
  return _ensure_nl(f"# 开工读取({origin})\n\n{body}")
719
+
720
+
721
+ def open_claim(repo: Path, claim_id: str) -> str:
722
+ """Print one claim from the outbox. Does not write files or follow facts/ pointers."""
723
+ claim_id = claim_id.strip()
724
+ if not claim_id or any(mark in claim_id for mark in ("/", "\\", "\x00", "\n", " ")):
725
+ raise ValueError("id 无效")
726
+ repo = Path(repo)
727
+ compiled = build_compiled(repo)
728
+ today = stored_review_today(repo)
729
+ blocks: list[str] = [f"# 打开 {claim_id}", ""]
730
+ found = False
731
+ for scope, cid in sorted(compiled.claims):
732
+ if cid != claim_id:
733
+ continue
734
+ found = True
735
+ result = compiled.claims[(scope, cid)]
736
+ blocks.append(f"## {scope}")
737
+ if result.conflict:
738
+ blocks.append("未裁决")
739
+ for head in result.heads:
740
+ blocks.extend(_open_head(head, today))
741
+ elif result.retracted:
742
+ blocks.append("已撤回")
743
+ elif result.heads:
744
+ blocks.extend(_open_head(result.heads[0], today))
745
+ blocks.append("")
746
+ proposals = [op for op in compiled.proposals if op.claim_id == claim_id]
747
+ if proposals:
748
+ found = True
749
+ blocks.append("## 提议")
750
+ for op in proposals:
751
+ blocks.append(f"- op_id: {op.op_id}")
752
+ blocks.append(f" scope: {op.scope}")
753
+ blocks.append(f" text: {op.text}")
754
+ if op.detail:
755
+ blocks.append(" detail: |")
756
+ blocks.extend(f" {line}" for line in op.detail.splitlines())
757
+ blocks.append("")
758
+ if not found:
759
+ return _ensure_nl(f"没有这条当前说法:{claim_id}")
760
+ return _ensure_nl("\n".join(blocks))
761
+
762
+
763
+ def _open_head(head: Op, today: date | None) -> list[str]:
764
+ lines = [
765
+ f"- slice: {head.slice}",
766
+ f" at: {head.at}",
767
+ f" text: {head.text}{review_suffix(head, today)}",
768
+ ]
769
+ if head.detail:
770
+ lines.append(" detail: |")
771
+ lines.extend(f" {line}" for line in head.detail.splitlines())
772
+ return lines
@@ -29,6 +29,12 @@ CARD_STALE_DAYS = 90
29
29
  REVIEW_MARK = "(未复核)"
30
30
  INDEX_LINE_BUDGET = 200
31
31
  INDEX_BYTE_BUDGET = 25 * 1024
32
+ CLUSTER_SIZE = 40
33
+
34
+
35
+ def over_budget(text: str) -> bool:
36
+ """True when an index is past the startup budget. The count never picks a chain head."""
37
+ return text.count("\n") > INDEX_LINE_BUDGET or len(text.encode("utf-8")) > INDEX_BYTE_BUDGET
32
38
 
33
39
 
34
40
  def parse_day(value: str) -> date | None: