@topmindspace/topmind-handoff 0.6.0 → 0.6.4
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 +26 -0
- package/README.md +4 -3
- package/SKILL.md +36 -9
- package/assets/handoff-example.md +9 -3
- package/assets/handoff-template.md +2 -2
- package/package.json +1 -1
- package/references/compile.md +17 -7
- package/references/environment.md +2 -2
- package/references/export.md +7 -2
- package/references/import.md +2 -2
- package/references/privacy.md +1 -0
- package/references/spec.md +9 -7
- package/topmind_handoff/admin.py +259 -39
- package/topmind_handoff/cli.py +21 -8
- package/topmind_handoff/compile.py +227 -42
- package/topmind_handoff/model.py +37 -2
- package/topmind_handoff/package.py +23 -24
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
from datetime import date
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from topmind_handoff.model import (
|
|
9
|
-
|
|
10
|
+
CARD_STALE_DAYS,
|
|
11
|
+
REVIEW_MARK,
|
|
12
|
+
SLICES,
|
|
10
13
|
Card,
|
|
11
14
|
ClaimResult,
|
|
12
15
|
Compiled,
|
|
@@ -15,13 +18,20 @@ from topmind_handoff.model import (
|
|
|
15
18
|
compiler_version,
|
|
16
19
|
origin_markdown,
|
|
17
20
|
parse_authority,
|
|
21
|
+
parse_day,
|
|
22
|
+
review_suffix,
|
|
18
23
|
scenario_filename,
|
|
19
24
|
parse_card,
|
|
20
25
|
parse_outbox,
|
|
26
|
+
safe_origin,
|
|
21
27
|
scenario_names,
|
|
28
|
+
slice_title,
|
|
29
|
+
view_slice_order,
|
|
22
30
|
)
|
|
23
31
|
|
|
24
32
|
VIEW_ORDER = ("identity", "boundaries", "preference", "project", "fact")
|
|
33
|
+
_REVIEW_AS_OF = re.compile(r"^review_as_of:\s*(\d{4}-\d{2}-\d{2})\s*$", re.M)
|
|
34
|
+
_CITE_EDGE = r"[A-Za-z0-9_.-]"
|
|
25
35
|
|
|
26
36
|
|
|
27
37
|
def _ensure_nl(text: str) -> str:
|
|
@@ -66,7 +76,9 @@ def _conflict_block(result: ClaimResult) -> list[str]:
|
|
|
66
76
|
return lines
|
|
67
77
|
|
|
68
78
|
|
|
69
|
-
def _section_lines(
|
|
79
|
+
def _section_lines(
|
|
80
|
+
results: list[ClaimResult], *, inline_detail: bool, today: date | None = None
|
|
81
|
+
) -> dict[str, list[str]]:
|
|
70
82
|
sections = {name: [] for name in VIEW_ORDER}
|
|
71
83
|
for result in sorted(results, key=lambda item: item.claim_id):
|
|
72
84
|
if result.conflict or result.retracted or not result.heads:
|
|
@@ -75,20 +87,25 @@ def _section_lines(results: list[ClaimResult], *, inline_detail: bool) -> dict[s
|
|
|
75
87
|
if head.kind != "set":
|
|
76
88
|
continue
|
|
77
89
|
bucket = sections.setdefault(head.slice, [])
|
|
78
|
-
bucket.append(_fact_line(head))
|
|
90
|
+
bucket.append(_fact_line(head, review_suffix(head, today)))
|
|
79
91
|
if inline_detail and head.detail:
|
|
80
92
|
bucket.extend(f" {line}" for line in head.detail.splitlines())
|
|
81
93
|
return sections
|
|
82
94
|
|
|
83
95
|
|
|
84
|
-
def render_scope_view(
|
|
85
|
-
|
|
96
|
+
def render_scope_view(
|
|
97
|
+
title: str, results: list[ClaimResult], *, inline_detail: bool, today: date | None = None
|
|
98
|
+
) -> str:
|
|
99
|
+
sections = _section_lines(results, inline_detail=inline_detail, today=today)
|
|
86
100
|
conflicts = [result for result in results if result.conflict]
|
|
87
101
|
conflicts.sort(key=lambda item: item.claim_id)
|
|
88
102
|
lines = [f"# {title}", ""]
|
|
89
|
-
for slice_name in
|
|
90
|
-
lines.append(f"## {
|
|
103
|
+
for slice_name in view_slice_order(sections):
|
|
104
|
+
lines.append(f"## {slice_title(slice_name)}")
|
|
91
105
|
body = sections.get(slice_name) or []
|
|
106
|
+
# An unknown slice with no rows is not a section. Known slices stay, even when empty.
|
|
107
|
+
if slice_name not in VIEW_ORDER and not body:
|
|
108
|
+
continue
|
|
92
109
|
lines.extend(body or ["(无)"])
|
|
93
110
|
lines.append("")
|
|
94
111
|
lines.append("## 冲突")
|
|
@@ -101,9 +118,11 @@ def render_scope_view(title: str, results: list[ClaimResult], *, inline_detail:
|
|
|
101
118
|
return _ensure_nl("\n".join(lines))
|
|
102
119
|
|
|
103
120
|
|
|
104
|
-
def render_views(
|
|
121
|
+
def render_views(
|
|
122
|
+
compiled: Compiled, *, inline_detail: bool = True, today: date | None = None
|
|
123
|
+
) -> dict[str, str]:
|
|
105
124
|
shared = [result for (scope, _cid), result in compiled.claims.items() if scope == "shared"]
|
|
106
|
-
rendered = {"shared.md": render_scope_view("共享", shared, inline_detail=inline_detail)}
|
|
125
|
+
rendered = {"shared.md": render_scope_view("共享", shared, inline_detail=inline_detail, today=today)}
|
|
107
126
|
names = scenario_names(compiled.authority, compiled.cards, _all_ops(compiled))
|
|
108
127
|
for name in names:
|
|
109
128
|
filename = scenario_filename(name)
|
|
@@ -111,7 +130,9 @@ def render_views(compiled: Compiled, *, inline_detail: bool = True) -> dict[str,
|
|
|
111
130
|
continue
|
|
112
131
|
scope = f"scenario:{name}"
|
|
113
132
|
results = [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
|
|
114
|
-
rendered[filename] = render_scope_view(
|
|
133
|
+
rendered[filename] = render_scope_view(
|
|
134
|
+
f"场景:{name}", results, inline_detail=inline_detail, today=today
|
|
135
|
+
)
|
|
115
136
|
return rendered
|
|
116
137
|
|
|
117
138
|
|
|
@@ -124,7 +145,7 @@ def _all_ops(compiled: Compiled) -> list[Op]:
|
|
|
124
145
|
return ops
|
|
125
146
|
|
|
126
147
|
|
|
127
|
-
def render_status(compiled: Compiled) -> str:
|
|
148
|
+
def render_status(compiled: Compiled, today: date | None = None) -> str:
|
|
128
149
|
version = compiler_version()
|
|
129
150
|
lines = [
|
|
130
151
|
"# STATUS",
|
|
@@ -133,8 +154,11 @@ def render_status(compiled: Compiled) -> str:
|
|
|
133
154
|
"",
|
|
134
155
|
f"compiler: topmind-handoff {version}",
|
|
135
156
|
"",
|
|
136
|
-
"## compiled_from",
|
|
137
157
|
]
|
|
158
|
+
if today is not None:
|
|
159
|
+
lines.append(f"review_as_of: {today.isoformat()}")
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append("## compiled_from")
|
|
138
162
|
if compiled.tips:
|
|
139
163
|
for origin in sorted(compiled.tips):
|
|
140
164
|
tip = compiled.tips[origin] or "-"
|
|
@@ -178,6 +202,14 @@ def render_status(compiled: Compiled) -> str:
|
|
|
178
202
|
lines.append(f" text: {op.text}")
|
|
179
203
|
else:
|
|
180
204
|
lines.append("(无)")
|
|
205
|
+
lines.extend(["", "## 待提炼"])
|
|
206
|
+
refine = refinement_lines(
|
|
207
|
+
list(compiled.claims.values()),
|
|
208
|
+
compiled.cards,
|
|
209
|
+
today,
|
|
210
|
+
known_ids=_current_ids(compiled),
|
|
211
|
+
)
|
|
212
|
+
lines.extend(refine or ["(无)"])
|
|
181
213
|
lines.extend(["", "## origins"])
|
|
182
214
|
origins = sorted(set(compiled.tips) | set(compiled.cards))
|
|
183
215
|
if origins:
|
|
@@ -193,19 +225,40 @@ def render_status(compiled: Compiled) -> str:
|
|
|
193
225
|
return _ensure_nl("\n".join(lines))
|
|
194
226
|
|
|
195
227
|
|
|
196
|
-
def
|
|
197
|
-
"""
|
|
228
|
+
def stored_review_today(repo: Path) -> date | None:
|
|
229
|
+
"""The explicit day a previous compile or organize used. Absent means unlabeled views."""
|
|
230
|
+
path = Path(repo) / "STATUS.md"
|
|
231
|
+
if not path.is_file():
|
|
232
|
+
return None
|
|
233
|
+
match = _REVIEW_AS_OF.search(path.read_text(encoding="utf-8"))
|
|
234
|
+
if not match:
|
|
235
|
+
return None
|
|
236
|
+
return parse_day(match.group(1))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def compile_repo(repo: Path, today: date | None = None) -> Compiled:
|
|
240
|
+
"""Regenerate views and STATUS from outboxes. Existing view text is discarded.
|
|
241
|
+
|
|
242
|
+
Review labels are rendered from `at` and `today`. A later compile with no day
|
|
243
|
+
reuses `review_as_of` so it does not wipe labels. The day never picks a head.
|
|
244
|
+
"""
|
|
198
245
|
repo = Path(repo)
|
|
246
|
+
if today is None:
|
|
247
|
+
today = stored_review_today(repo)
|
|
199
248
|
compiled = build_compiled(repo)
|
|
200
249
|
views = repo / "views"
|
|
201
250
|
views.mkdir(parents=True, exist_ok=True)
|
|
202
|
-
rendered = render_views(compiled, inline_detail=True)
|
|
251
|
+
rendered = render_views(compiled, inline_detail=True, today=today)
|
|
203
252
|
for path in views.glob("scenario-*.md"):
|
|
204
253
|
if path.name not in rendered:
|
|
205
254
|
path.unlink()
|
|
206
255
|
for name, text in rendered.items():
|
|
207
256
|
(views / name).write_text(text, encoding="utf-8")
|
|
208
|
-
(repo / "STATUS.md").write_text(render_status(compiled), encoding="utf-8")
|
|
257
|
+
(repo / "STATUS.md").write_text(render_status(compiled, today=today), encoding="utf-8")
|
|
258
|
+
# Local import: admin imports this module. Only a stock README this skill wrote is replaced.
|
|
259
|
+
from topmind_handoff.admin import upgrade_stock_readme
|
|
260
|
+
|
|
261
|
+
upgrade_stock_readme(repo)
|
|
209
262
|
return compiled
|
|
210
263
|
|
|
211
264
|
|
|
@@ -272,7 +325,9 @@ def save_environment_card(repo: Path, actor: str, origin: str, content: str) ->
|
|
|
272
325
|
return dest
|
|
273
326
|
|
|
274
327
|
|
|
275
|
-
_FACT_LINE = re.compile(
|
|
328
|
+
_FACT_LINE = re.compile(
|
|
329
|
+
rf"^- (?P<body>.*){{id:(?P<cid>[^}}]+)}}(?P<suffix>\s*{re.escape(REVIEW_MARK)})?\s*$"
|
|
330
|
+
)
|
|
276
331
|
|
|
277
332
|
|
|
278
333
|
def _override_line(line: str, overrides: dict[str, str]) -> str | None:
|
|
@@ -358,6 +413,103 @@ def _project_shared(repo: Path, shared: str, overrides: dict[str, str]) -> str:
|
|
|
358
413
|
return _ensure_nl("\n".join(output))
|
|
359
414
|
|
|
360
415
|
|
|
416
|
+
def _results_for(compiled: Compiled, scope: str) -> list[ClaimResult]:
|
|
417
|
+
return [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _current_heads(results: list[ClaimResult]) -> list[Op]:
|
|
421
|
+
heads: list[Op] = []
|
|
422
|
+
for result in results:
|
|
423
|
+
if result.conflict or result.retracted or not result.heads:
|
|
424
|
+
continue
|
|
425
|
+
head = result.heads[0]
|
|
426
|
+
if head.kind == "set":
|
|
427
|
+
heads.append(head)
|
|
428
|
+
return heads
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _current_ids(compiled: Compiled) -> set[str]:
|
|
432
|
+
found: set[str] = set()
|
|
433
|
+
for result in compiled.claims.values():
|
|
434
|
+
for head in _current_heads([result]):
|
|
435
|
+
found.add(head.claim_id)
|
|
436
|
+
return found
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _scope_heads(compiled: Compiled, scope: str) -> dict[str, Op]:
|
|
440
|
+
return {head.claim_id: head for head in _current_heads(_results_for(compiled, scope))}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def cites_claim(text: str, claim_id: str) -> bool:
|
|
444
|
+
"""True when text names another claim id as a whole token, not as a prefix."""
|
|
445
|
+
if not text or not claim_id or claim_id not in text:
|
|
446
|
+
return False
|
|
447
|
+
pattern = re.compile(
|
|
448
|
+
r"(?<!" + _CITE_EDGE + r")" + re.escape(claim_id) + r"(?!" + _CITE_EDGE + r")"
|
|
449
|
+
)
|
|
450
|
+
return pattern.search(text) is not None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def card_is_due(card: Card, today: date | None) -> bool:
|
|
454
|
+
if card.stale:
|
|
455
|
+
return True
|
|
456
|
+
if today is None or not card.refreshed:
|
|
457
|
+
return False
|
|
458
|
+
written = parse_day(card.refreshed)
|
|
459
|
+
if written is None:
|
|
460
|
+
return False
|
|
461
|
+
return (today - written).days >= CARD_STALE_DAYS
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def refinement_lines(
|
|
465
|
+
results: list[ClaimResult],
|
|
466
|
+
cards: dict[str, Card],
|
|
467
|
+
today: date | None,
|
|
468
|
+
*,
|
|
469
|
+
known_ids: set[str],
|
|
470
|
+
) -> list[str]:
|
|
471
|
+
"""List review work. Does not retract, rewrite, or touch outboxes.
|
|
472
|
+
|
|
473
|
+
Lines are sentences, not `- ` claims, so a package import does not store them as facts.
|
|
474
|
+
"""
|
|
475
|
+
heads = _current_heads(results)
|
|
476
|
+
heads.sort(key=lambda op: (op.scope, op.claim_id, op.op_id))
|
|
477
|
+
lines: list[str] = []
|
|
478
|
+
for head in heads:
|
|
479
|
+
if head.slice not in SLICES:
|
|
480
|
+
lines.append(f"维度 `{head.slice}` 单独成节,没有并进事实。id: {head.claim_id}")
|
|
481
|
+
for head in heads:
|
|
482
|
+
blob = f"{head.text}\n{head.detail}"
|
|
483
|
+
for other in sorted(known_ids):
|
|
484
|
+
if other == head.claim_id:
|
|
485
|
+
continue
|
|
486
|
+
if cites_claim(blob, other):
|
|
487
|
+
lines.append(f"引用 `{head.claim_id}` 提到当前说法 `{other}`。不撤回,不改写。")
|
|
488
|
+
for origin in sorted(cards):
|
|
489
|
+
if card_is_due(cards[origin], today):
|
|
490
|
+
lines.append(f"环境卡片 `{origin}` 未刷新。")
|
|
491
|
+
return lines
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _label_line(line: str, heads: dict[str, Op], today: date | None) -> str:
|
|
495
|
+
if today is None:
|
|
496
|
+
return line
|
|
497
|
+
match = _FACT_LINE.match(line.rstrip())
|
|
498
|
+
if not match:
|
|
499
|
+
return line
|
|
500
|
+
head = heads.get(match.group("cid"))
|
|
501
|
+
if head is None:
|
|
502
|
+
return line
|
|
503
|
+
suffix = review_suffix(head, today)
|
|
504
|
+
return f"- {match.group('body').rstrip()} {{id:{match.group('cid')}}}{suffix}"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _label_text(text: str, heads: dict[str, Op], today: date | None) -> str:
|
|
508
|
+
if today is None:
|
|
509
|
+
return text
|
|
510
|
+
return _ensure_nl("\n".join(_label_line(line, heads, today) for line in text.splitlines()))
|
|
511
|
+
|
|
512
|
+
|
|
361
513
|
def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
|
|
362
514
|
blocks: list[str] = []
|
|
363
515
|
for result in sorted(compiled.claims.values(), key=lambda item: (item.scope, item.claim_id)):
|
|
@@ -370,41 +522,49 @@ def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
|
|
|
370
522
|
return _ensure_nl("\n".join(blocks))
|
|
371
523
|
|
|
372
524
|
|
|
373
|
-
def _origin_projection(compiled: Compiled, origin: str) -> str:
|
|
374
|
-
"""Sets whose scope is this origin
|
|
525
|
+
def _origin_projection(compiled: Compiled, origin: str, today: date | None = None) -> str:
|
|
526
|
+
"""Sets whose scope is this origin, each slice under its own heading.
|
|
527
|
+
|
|
528
|
+
Conflicts stay in the conflict section. Origin text is not part of the shared view.
|
|
529
|
+
"""
|
|
375
530
|
scope = f"origin:{origin}"
|
|
376
|
-
results =
|
|
377
|
-
|
|
378
|
-
for
|
|
379
|
-
|
|
380
|
-
continue
|
|
381
|
-
head = result.heads[0]
|
|
382
|
-
if head.kind != "set":
|
|
383
|
-
continue
|
|
384
|
-
lines.append(_fact_line(head))
|
|
385
|
-
if head.detail:
|
|
386
|
-
lines.extend(f" {line}" for line in head.detail.splitlines())
|
|
387
|
-
if not lines:
|
|
531
|
+
results = _results_for(compiled, scope)
|
|
532
|
+
sections = _section_lines(results, inline_detail=True, today=today)
|
|
533
|
+
filled = [name for name in view_slice_order(sections) if sections.get(name)]
|
|
534
|
+
if not filled:
|
|
388
535
|
return ""
|
|
389
|
-
|
|
536
|
+
lines = ["## 本环境范围", ""]
|
|
537
|
+
for name in filled:
|
|
538
|
+
lines.append(f"### {slice_title(name)}")
|
|
539
|
+
lines.extend(sections[name])
|
|
540
|
+
lines.append("")
|
|
541
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
390
542
|
|
|
391
543
|
|
|
392
|
-
def
|
|
393
|
-
"""
|
|
544
|
+
def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
545
|
+
"""One origin projection shared by startup and the offline package.
|
|
394
546
|
|
|
395
|
-
|
|
547
|
+
Reads views when they are inside the repo. Does not write files.
|
|
396
548
|
"""
|
|
397
549
|
repo = Path(repo)
|
|
550
|
+
origin = safe_origin(origin)
|
|
398
551
|
compiled = build_compiled(repo)
|
|
552
|
+
if today is None:
|
|
553
|
+
today = stored_review_today(repo)
|
|
399
554
|
views = repo / "views"
|
|
555
|
+
rendered = render_views(compiled, today=today)
|
|
400
556
|
shared_path = views / "shared.md"
|
|
401
557
|
if shared_path.is_file() and _path_inside(shared_path, views):
|
|
402
558
|
shared = shared_path.read_text(encoding="utf-8")
|
|
403
559
|
else:
|
|
404
|
-
shared =
|
|
560
|
+
shared = rendered["shared.md"]
|
|
405
561
|
card = compiled.cards.get(origin)
|
|
406
562
|
overrides = card.overrides if card else {}
|
|
407
|
-
projected =
|
|
563
|
+
projected = _label_text(
|
|
564
|
+
_project_shared(repo, shared, overrides),
|
|
565
|
+
_scope_heads(compiled, "shared"),
|
|
566
|
+
today,
|
|
567
|
+
)
|
|
408
568
|
role = card.role if card else ""
|
|
409
569
|
filename = scenario_filename(role) if role else None
|
|
410
570
|
scenario_path = views / filename if filename else None
|
|
@@ -413,14 +573,18 @@ def startup_read(repo: Path, origin: str) -> str:
|
|
|
413
573
|
if scenario_path is not None and scenario_path.is_file():
|
|
414
574
|
scenario = scenario_path.read_text(encoding="utf-8")
|
|
415
575
|
elif filename:
|
|
416
|
-
scenario =
|
|
576
|
+
scenario = rendered.get(filename, "# 场景\n\n(无)\n")
|
|
417
577
|
else:
|
|
418
578
|
scenario = "(无)\n"
|
|
579
|
+
scenario = _label_text(scenario, _scope_heads(compiled, f"scenario:{role}") if role else {}, today)
|
|
419
580
|
card_text = card.raw if card else "(无)\n"
|
|
420
|
-
origin_text = _origin_projection(compiled, origin).rstrip("\n")
|
|
581
|
+
origin_text = _origin_projection(compiled, origin, today=today).rstrip("\n")
|
|
582
|
+
visible: list[ClaimResult] = list(_results_for(compiled, "shared"))
|
|
583
|
+
if role:
|
|
584
|
+
visible.extend(_results_for(compiled, f"scenario:{role}"))
|
|
585
|
+
visible.extend(_results_for(compiled, f"origin:{origin}"))
|
|
586
|
+
refine = refinement_lines(visible, compiled.cards, today, known_ids=_current_ids(compiled))
|
|
421
587
|
parts = [
|
|
422
|
-
f"# 开工读取({origin})",
|
|
423
|
-
"",
|
|
424
588
|
"## 冲突(与本环境有关)",
|
|
425
589
|
_conflicts_for_origin(compiled, origin).rstrip("\n"),
|
|
426
590
|
"",
|
|
@@ -433,5 +597,26 @@ def startup_read(repo: Path, origin: str) -> str:
|
|
|
433
597
|
]
|
|
434
598
|
if origin_text:
|
|
435
599
|
parts.extend([origin_text, ""])
|
|
436
|
-
parts.extend(
|
|
600
|
+
parts.extend(
|
|
601
|
+
[
|
|
602
|
+
"## 环境卡片",
|
|
603
|
+
card_text.rstrip("\n"),
|
|
604
|
+
"",
|
|
605
|
+
"## 待提炼",
|
|
606
|
+
"\n".join(refine) if refine else "(无)",
|
|
607
|
+
"",
|
|
608
|
+
]
|
|
609
|
+
)
|
|
437
610
|
return _ensure_nl("\n".join(parts))
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def startup_read(repo: Path, origin: str, today: date | None = None) -> str:
|
|
614
|
+
"""Shared view, this origin's scenario, its card, and conflicts that involve it.
|
|
615
|
+
|
|
616
|
+
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.
|
|
618
|
+
"""
|
|
619
|
+
repo = Path(repo)
|
|
620
|
+
origin = safe_origin(origin)
|
|
621
|
+
body = projection_body(repo, origin, today=today).rstrip("\n")
|
|
622
|
+
return _ensure_nl(f"# 开工读取({origin})\n\n{body}")
|
package/topmind_handoff/model.py
CHANGED
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
7
8
|
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import date, datetime
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
ROOT = Path(__file__).resolve().parent.parent
|
|
@@ -21,11 +23,44 @@ REVIEW_DAYS = {
|
|
|
21
23
|
"boundaries": 180,
|
|
22
24
|
"preference": 90,
|
|
23
25
|
"project": 30,
|
|
26
|
+
"fact": 30,
|
|
24
27
|
}
|
|
28
|
+
CARD_STALE_DAYS = 90
|
|
29
|
+
REVIEW_MARK = "(未复核)"
|
|
25
30
|
INDEX_LINE_BUDGET = 200
|
|
26
31
|
INDEX_BYTE_BUDGET = 25 * 1024
|
|
27
32
|
|
|
28
33
|
|
|
34
|
+
def parse_day(value: str) -> date | None:
|
|
35
|
+
try:
|
|
36
|
+
return datetime.strptime(value, "%Y-%m-%d").date()
|
|
37
|
+
except ValueError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def review_suffix(op: Op, today: date | None) -> str:
|
|
42
|
+
"""Label a current head from its own `at`. The date never picks a chain head."""
|
|
43
|
+
if today is None:
|
|
44
|
+
return ""
|
|
45
|
+
limit = REVIEW_DAYS.get(op.slice)
|
|
46
|
+
written = parse_day(op.at)
|
|
47
|
+
if limit is None or written is None:
|
|
48
|
+
return ""
|
|
49
|
+
if (today - written).days >= limit:
|
|
50
|
+
return f" {REVIEW_MARK}"
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def slice_title(name: str) -> str:
|
|
55
|
+
"""Known slices keep their Chinese headings. Anything else stays its own section."""
|
|
56
|
+
return SLICE_TITLE.get(name, f"维度:{name}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def view_slice_order(present: Iterable[str]) -> list[str]:
|
|
60
|
+
extras = sorted({name for name in present if name not in SLICES})
|
|
61
|
+
return list(SLICES) + extras
|
|
62
|
+
|
|
63
|
+
|
|
29
64
|
def compiler_version() -> str:
|
|
30
65
|
pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
|
31
66
|
return str(pkg["version"])
|
|
@@ -162,9 +197,9 @@ def parse_outbox(text: str, origin: str) -> list[Op]:
|
|
|
162
197
|
base_raw = fields.get("base", "none").strip() or "none"
|
|
163
198
|
kind = fields.get("op", "set").strip() or "set"
|
|
164
199
|
scope = fields.get("scope", "shared").strip() or "shared"
|
|
200
|
+
# Missing slice stays fact. An unknown value, including `environment`, is kept.
|
|
201
|
+
# Coercing it would hide the row under 事实 and a typo must not abort the ledger.
|
|
165
202
|
slice_name = fields.get("slice", "fact").strip() or "fact"
|
|
166
|
-
if slice_name not in SLICES:
|
|
167
|
-
slice_name = "fact"
|
|
168
203
|
ops.append(
|
|
169
204
|
Op(
|
|
170
205
|
op_id=op_id,
|
|
@@ -5,10 +5,18 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
from copy import deepcopy
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import date
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
|
-
from topmind_handoff.compile import append_outbox, compile_repo
|
|
11
|
-
from topmind_handoff.model import
|
|
11
|
+
from topmind_handoff.compile import append_outbox, compile_repo, projection_body
|
|
12
|
+
from topmind_handoff.model import (
|
|
13
|
+
REVIEW_MARK,
|
|
14
|
+
ROOT,
|
|
15
|
+
compiler_version,
|
|
16
|
+
origin_markdown,
|
|
17
|
+
parse_outbox,
|
|
18
|
+
safe_origin,
|
|
19
|
+
)
|
|
12
20
|
|
|
13
21
|
APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
|
|
14
22
|
ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
|
|
@@ -38,20 +46,12 @@ def _front(text: str) -> dict[str, str]:
|
|
|
38
46
|
return found
|
|
39
47
|
|
|
40
48
|
|
|
41
|
-
def render_package(repo: Path, origin: str) -> str:
|
|
42
|
-
"""Compile, then render
|
|
49
|
+
def render_package(repo: Path, origin: str, today: date | None = None) -> str:
|
|
50
|
+
"""Compile, then render the same origin projection startup prints, plus the ledger."""
|
|
43
51
|
repo = Path(repo)
|
|
44
52
|
origin = safe_origin(origin)
|
|
45
|
-
compile_repo(repo)
|
|
46
|
-
|
|
47
|
-
card_path = origin_markdown(repo / "environments", origin)
|
|
48
|
-
card = card_path.read_text(encoding="utf-8") if card_path.is_file() else "(无)\n"
|
|
49
|
-
role = _front(card).get("role", "")
|
|
50
|
-
filename = scenario_filename(role) if role else None
|
|
51
|
-
scenario_path = repo / "views" / filename if filename else None
|
|
52
|
-
if scenario_path is not None and scenario_path.resolve().parent != (repo / "views").resolve():
|
|
53
|
-
scenario_path = None
|
|
54
|
-
scenario = scenario_path.read_text(encoding="utf-8") if scenario_path and scenario_path.is_file() else "(无)\n"
|
|
53
|
+
compile_repo(repo, today=today)
|
|
54
|
+
body = projection_body(repo, origin, today=today).rstrip("\n")
|
|
55
55
|
outbox_path = origin_markdown(repo / "outbox", origin)
|
|
56
56
|
ops_text = outbox_path.read_text(encoding="utf-8") if outbox_path.is_file() else ""
|
|
57
57
|
op_blocks = [op.raw.rstrip("\n") for op in parse_outbox(ops_text, origin)]
|
|
@@ -70,15 +70,7 @@ def render_package(repo: Path, origin: str) -> str:
|
|
|
70
70
|
"",
|
|
71
71
|
"# 交接包",
|
|
72
72
|
"",
|
|
73
|
-
|
|
74
|
-
"",
|
|
75
|
-
shared.rstrip("\n"),
|
|
76
|
-
"",
|
|
77
|
-
scenario.rstrip("\n"),
|
|
78
|
-
"",
|
|
79
|
-
"## 环境卡片",
|
|
80
|
-
"",
|
|
81
|
-
card.rstrip("\n"),
|
|
73
|
+
body,
|
|
82
74
|
"",
|
|
83
75
|
"## 10. 操作附录(已反映在上文视图中)",
|
|
84
76
|
"",
|
|
@@ -191,7 +183,7 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
|
|
|
191
183
|
continue
|
|
192
184
|
if skip or not stripped.startswith("- "):
|
|
193
185
|
continue
|
|
194
|
-
body = stripped[2:].strip()
|
|
186
|
+
body = _without_review_mark(stripped[2:].strip())
|
|
195
187
|
match = re.search(r"\{id:([^}]+)\}\s*$", body)
|
|
196
188
|
claim_id = match.group(1) if match else None
|
|
197
189
|
if match:
|
|
@@ -200,6 +192,13 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
|
|
|
200
192
|
return items
|
|
201
193
|
|
|
202
194
|
|
|
195
|
+
def _without_review_mark(body: str) -> str:
|
|
196
|
+
"""A due line ends with the review label after `{id:…}`. The label is not claim text."""
|
|
197
|
+
if body.endswith(REVIEW_MARK):
|
|
198
|
+
return body[: -len(REVIEW_MARK)].rstrip()
|
|
199
|
+
return body
|
|
200
|
+
|
|
201
|
+
|
|
203
202
|
def _anchor_of(text: str) -> tuple[str, str] | None:
|
|
204
203
|
"""An anchor line starts with the field name. A later mention is not an anchor."""
|
|
205
204
|
stripped = text.strip()
|