@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.
- package/CHANGELOG.md +17 -0
- package/README.md +81 -38
- package/SKILL.md +14 -12
- package/assets/handoff-example.md +4 -4
- package/assets/handoff-template.md +3 -3
- package/bin/topmind-handoff.js +326 -0
- package/examples/README.md +146 -0
- package/package.json +47 -1
- package/references/compile.md +21 -4
- package/references/environment.md +16 -12
- package/references/export.md +1 -1
- package/references/import.md +2 -2
- package/references/spec.md +9 -9
- package/references/tool-adapters.md +1 -1
- package/topmind_handoff/admin.py +174 -30
- package/topmind_handoff/cli.py +20 -3
- package/topmind_handoff/compile.py +169 -19
- package/topmind_handoff/model.py +6 -0
package/topmind_handoff/admin.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
540
|
-
"""
|
|
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
|
-
|
|
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"
|
|
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(
|
|
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(
|
|
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("整理改写了信箱")
|
package/topmind_handoff/cli.py
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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"
|
|
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.
|
|
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=
|
|
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 =
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
package/topmind_handoff/model.py
CHANGED
|
@@ -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:
|