zymor 0.2.0__py3-none-any.whl

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.
zymor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Zymor: the dreaming memory system for AI agents."""
2
+
3
+ __version__ = "0.1.0"
zymor/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zymor.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
zymor/assembly.py ADDED
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from zymor.config import Config
9
+ from zymor.errors import IndexFailure, ZymorError
10
+ from zymor.invalidation import stale_reasons as node_stale_reasons
11
+ from zymor.model.focus import FOCUS_H2, FocusFile
12
+ from zymor.model.node import CATEGORIES
13
+ from zymor.store.index import Index
14
+ from zymor.store.nodes import read_node
15
+ from zymor.tasktype import task_type_from_text
16
+ from zymor.tokenizer import estimate_tokens, truncate_to_tokens
17
+ from zymor.util.time import days_ago_iso, parse_iso, utc_now
18
+ from zymor.vault import Vault
19
+
20
+ PENDING_LIMIT = 3
21
+ PENDING_ITEM_TOKENS = 60
22
+ CAPSULE_RECENT_DAYS = 30
23
+ CAPSULE_LINE_TOKENS = 60
24
+ TRUNCATION_MARKER = "…(已截断以满足 {budget} tokens 预算)"
25
+
26
+
27
+ @dataclass
28
+ class ContextResult:
29
+ text: str
30
+ tokens_estimate: int
31
+ section_tokens: dict[str, int]
32
+ task_type: str
33
+ degraded: bool
34
+ warnings: list[str]
35
+
36
+ def to_dict(self) -> dict[str, Any]:
37
+ """入参:无。出参:上下文组装结果字典。"""
38
+ return {
39
+ "text": self.text,
40
+ "tokens_estimate": self.tokens_estimate,
41
+ "section_tokens": self.section_tokens,
42
+ "task_type": self.task_type,
43
+ "degraded": self.degraded,
44
+ "warnings": self.warnings,
45
+ }
46
+
47
+
48
+ def _split_sections(body: str) -> dict[str, str]:
49
+ sections: dict[str, str] = {}
50
+ current: str | None = None
51
+ buffer: list[str] = []
52
+ for line in body.splitlines():
53
+ if line.startswith("## "):
54
+ if current is not None:
55
+ sections[current] = "\n".join(buffer).strip()
56
+ current = line[3:].strip()
57
+ buffer = []
58
+ elif current is not None:
59
+ buffer.append(line)
60
+ if current is not None:
61
+ sections[current] = "\n".join(buffer).strip()
62
+ return sections
63
+
64
+
65
+ def _load_focus(vault: Vault, session_id: str | None) -> FocusFile | None:
66
+ if session_id is not None:
67
+ path = vault.focus_path(session_id)
68
+ candidates = [path] if path.is_file() else []
69
+ else:
70
+ candidates = sorted(vault.focus_dir.glob("*.md"))
71
+ focuses: list[FocusFile] = []
72
+ for path in candidates:
73
+ try:
74
+ focus = FocusFile.from_markdown(path.read_text(encoding="utf-8"))
75
+ except (OSError, ZymorError):
76
+ continue
77
+ if focus.status == "active":
78
+ focuses.append(focus)
79
+ if not focuses:
80
+ return None
81
+ return max(focuses, key=lambda item: item.updated_at)
82
+
83
+
84
+ def _render_focus(focus: FocusFile, cap: int) -> str:
85
+ sections = _split_sections(focus.body)
86
+ priority = ["当前目标", "待验证"]
87
+ priority.extend(name for name in FOCUS_H2 if name not in priority)
88
+ lines: list[str] = []
89
+ used = 0
90
+ truncated = False
91
+ for name in priority:
92
+ content = sections.get(name, "").strip()
93
+ heading_cost = estimate_tokens(f"## {name}")
94
+ available = cap - used - heading_cost
95
+ if available <= 1:
96
+ truncated = truncated or bool(content)
97
+ continue
98
+ fitted, clipped = truncate_to_tokens(content, available)
99
+ block = f"## {name}\n{fitted}".rstrip()
100
+ cost = estimate_tokens(block) + 1
101
+ if clipped:
102
+ block += " …"
103
+ cost = estimate_tokens(block) + 1
104
+ truncated = True
105
+ if used + cost > cap:
106
+ truncated = True
107
+ continue
108
+ lines.append(block)
109
+ used += cost
110
+ if truncated:
111
+ lines.append("…(L1 焦点已截断)")
112
+ text = "\n\n".join(lines)
113
+ if estimate_tokens(text) > cap:
114
+ fitted, _ = truncate_to_tokens(text, cap)
115
+ text = fitted + " …"
116
+ return text
117
+
118
+
119
+ def _render_facets(
120
+ global_vault: Vault | None,
121
+ task_type: str,
122
+ max_facets: int,
123
+ cap: int,
124
+ ) -> str:
125
+ if global_vault is None or task_type == "other":
126
+ return ""
127
+ index = Index(global_vault.root)
128
+ rows = index.layer_rows("L4", statuses=["verified"], limit=max_facets * 5)
129
+ scored: list[tuple[float, str]] = []
130
+ now = utc_now()
131
+ for row in rows:
132
+ applies_to = [str(item) for item in row.get("applies_to", [])]
133
+ if task_type not in applies_to:
134
+ continue
135
+ try:
136
+ age_days = max(0.0, (now - parse_iso(str(row["updated_at"]))).total_seconds() / 86400)
137
+ except ValueError:
138
+ age_days = 0.0
139
+ recency = math.exp(-age_days / 30.0)
140
+ scored.append((float(row["confidence"]) * recency, str(row["id"])))
141
+ scored.sort(key=lambda item: item[0], reverse=True)
142
+ lines: list[str] = []
143
+ used = 0
144
+ for _, node_id in scored[:max_facets]:
145
+ try:
146
+ node, _ = read_node(global_vault, node_id)
147
+ except ZymorError:
148
+ continue
149
+ if node_stale_reasons(global_vault, node):
150
+ continue
151
+ guidance = _section_first_line(node.body, "行为指引") or node.summary
152
+ applies = ", ".join(node.applies_to)
153
+ line = f"- [verified] {node.title}:{guidance}(applies_to: {applies})"
154
+ cost = estimate_tokens(line) + 1
155
+ if used + cost > cap:
156
+ fitted, _ = truncate_to_tokens(line, max(1, cap - used - 1))
157
+ line = fitted + " …"
158
+ cost = estimate_tokens(line) + 1
159
+ if used + cost > cap:
160
+ break
161
+ lines.append(line)
162
+ used += cost
163
+ return "\n".join(lines)
164
+
165
+
166
+ def _section_first_line(body: str, section: str) -> str:
167
+ lines = body.splitlines()
168
+ inside = False
169
+ for line in lines:
170
+ stripped = line.strip()
171
+ if stripped == f"## {section}":
172
+ inside = True
173
+ continue
174
+ if inside and stripped.startswith("## "):
175
+ return ""
176
+ if inside and stripped:
177
+ return stripped.lstrip("- ").strip()
178
+ return ""
179
+
180
+
181
+ def _render_capsules(
182
+ project_vault: Vault,
183
+ project: str | None,
184
+ max_capsules: int,
185
+ cap: int,
186
+ ) -> str:
187
+ index = Index(project_vault.root)
188
+ rows = index.layer_rows(
189
+ "L2",
190
+ statuses=["hypothesis", "verified", "disputed"],
191
+ project=project,
192
+ updated_since=days_ago_iso(CAPSULE_RECENT_DAYS),
193
+ limit=max(max_capsules * 3, max_capsules),
194
+ )
195
+ lines: list[str] = []
196
+ used = 0
197
+ for row in rows:
198
+ if len(lines) >= max_capsules:
199
+ break
200
+ try:
201
+ capsule_node, _ = read_node(project_vault, str(row["id"]))
202
+ except ZymorError:
203
+ continue
204
+ if node_stale_reasons(project_vault, capsule_node):
205
+ continue
206
+ label = str(row.get("project") or project or "项目")
207
+ status = str(row.get("status") or "hypothesis")
208
+ updated = str(row["updated_at"])[:10]
209
+ summary, _ = truncate_to_tokens(str(row["summary"]), CAPSULE_LINE_TOKENS)
210
+ line = f"- [{status}] {label}:{summary}(updated {updated})"
211
+ cost = estimate_tokens(line) + 1
212
+ if used + cost > cap:
213
+ break
214
+ lines.append(line)
215
+ used += cost
216
+ return "\n".join(lines)
217
+
218
+
219
+ def _merged_counts(
220
+ project_vault: Vault,
221
+ global_vault: Vault | None,
222
+ ) -> tuple[dict[str, Any], list[tuple[str, int]]]:
223
+ indexes = [Index(project_vault.root)]
224
+ if global_vault is not None:
225
+ indexes.append(Index(global_vault.root))
226
+ total = 0
227
+ categories: dict[str, int] = {}
228
+ tag_totals: dict[str, int] = {}
229
+ for index in indexes:
230
+ counts = index.counts()
231
+ total += int(counts.get("total", 0))
232
+ for category, count in counts.get("categories", {}).items():
233
+ categories[category] = categories.get(category, 0) + int(count)
234
+ for tag, count in index.tag_counts(limit=50):
235
+ tag_totals[tag] = tag_totals.get(tag, 0) + count
236
+ tags = sorted(tag_totals.items(), key=lambda item: (-item[1], item[0]))[:8]
237
+ return {"total": total, "categories": categories}, tags
238
+
239
+
240
+ def _render_atlas_pointer(counts: dict[str, Any], tags: list[tuple[str, int]], cap: int) -> str:
241
+ categories = counts.get("categories", {})
242
+ category_text = " / ".join(
243
+ f"{category} {categories.get(category, 0)}" for category in CATEGORIES
244
+ )
245
+ tag_text = ", ".join(f"{tag}({count})" for tag, count in tags) or "(无)"
246
+ text = (
247
+ f"categories: {category_text};top tags: {tag_text}\n"
248
+ "调用 memory_browse 查看目录,memory_search 做精确检索。"
249
+ )
250
+ if estimate_tokens(text) <= cap:
251
+ return text
252
+ for keep in range(len(tags) - 1, -1, -1):
253
+ tag_text = ", ".join(f"{tag}({count})" for tag, count in tags[:keep]) or "(无)"
254
+ text = (
255
+ f"categories: {category_text};top tags: {tag_text}\n"
256
+ "调用 memory_browse 查看目录,memory_search 做精确检索。"
257
+ )
258
+ if estimate_tokens(text) <= cap:
259
+ return text
260
+ return truncate_to_tokens(text, cap)[0] + " …"
261
+
262
+
263
+ def _render_pending(
264
+ items: list[dict[str, Any]],
265
+ cap: int,
266
+ ) -> str:
267
+ lines: list[str] = []
268
+ used = 0
269
+ shown = 0
270
+ for item in items:
271
+ if shown >= PENDING_LIMIT:
272
+ break
273
+ question = str(item.get("question") or "").strip()
274
+ new_id = str(item.get("new_id") or "")
275
+ old_id = str(item.get("old_id") or "")
276
+ entry_type = str(item.get("type") or "conflict")
277
+ qid = str(item.get("qid") or item.get("id") or "")
278
+ fitted, _ = truncate_to_tokens(question, PENDING_ITEM_TOKENS)
279
+ if old_id:
280
+ line = f"- Q{qid} · {fitted}({new_id} vs {old_id})"
281
+ elif entry_type == "persona-proposal":
282
+ line = f"- Q{qid} · {fitted}(人格规则待确认:{new_id})"
283
+ else:
284
+ line = f"- Q{qid} · {fitted}"
285
+ cost = estimate_tokens(line) + 1
286
+ if used + cost > cap:
287
+ break
288
+ lines.append(line)
289
+ used += cost
290
+ shown += 1
291
+ if len(items) > shown:
292
+ lines.append(f"- 另有 {len(items) - shown} 条未决追问")
293
+ return "\n".join(lines)
294
+
295
+
296
+ def _assemble(
297
+ sections: list[tuple[str, str, int]],
298
+ budget: int,
299
+ ) -> tuple[str, dict[str, int], bool]:
300
+ active = [(name, text, priority) for name, text, priority in sections if text]
301
+ trimmed = False
302
+ while estimate_tokens("\n\n".join(text for _, text, _ in active)) > budget:
303
+ droppable = [item for item in active if item[2] > 1]
304
+ if not droppable:
305
+ break
306
+ victim = max(droppable, key=lambda item: item[2])
307
+ active.remove(victim)
308
+ trimmed = True
309
+ if trimmed:
310
+ marker = TRUNCATION_MARKER.format(budget=budget)
311
+ active.append(("truncated", marker, 0))
312
+ while estimate_tokens("\n\n".join(text for _, text, _ in active)) > budget:
313
+ droppable = [item for item in active if item[0] != "truncated" and item[2] > 1]
314
+ if not droppable:
315
+ break
316
+ active.remove(max(droppable, key=lambda item: item[2]))
317
+ text = "\n\n".join(text for _, text, _ in active)
318
+ estimate = estimate_tokens(text)
319
+ if estimate > budget:
320
+ text, _ = truncate_to_tokens(text, budget)
321
+ trimmed = True
322
+ text = text + " …"
323
+ estimate = estimate_tokens(text)
324
+ section_tokens = {name: estimate_tokens(text_value) for name, text_value, _ in active}
325
+ return text, section_tokens, trimmed
326
+
327
+
328
+ def memory_context(
329
+ *,
330
+ project_vault: Vault,
331
+ config: Config,
332
+ global_vault: Vault | None = None,
333
+ task: str | None = None,
334
+ project: str | None = None,
335
+ budget_tokens: int | None = None,
336
+ session_id: str | None = None,
337
+ task_type: str | None = None,
338
+ dissonance_provider: Callable[[Vault], list[dict[str, Any]]] | None = None,
339
+ ) -> ContextResult:
340
+ """入参:项目库/用户库、任务与预算。出参:Tier 0 常驻装配的 Markdown 与 token 估算。"""
341
+ focus: FocusFile | None = None
342
+ warnings: list[str] = []
343
+ try:
344
+ focus = _load_focus(project_vault, session_id)
345
+ except ZymorError as error:
346
+ warnings.append(f"L1 焦点读取失败: {error.message}")
347
+ resolved_task_type = task_type
348
+ if resolved_task_type is None and focus is not None:
349
+ resolved_task_type = focus.task_type
350
+ if resolved_task_type is None:
351
+ resolved_task_type = task_type_from_text(task or "")
352
+ budget = int(budget_tokens or config.get("context.total_tokens"))
353
+ header = "## Zymor Memory"
354
+ sections: list[tuple[str, str, int]] = [("header", header, 0)]
355
+
356
+ try:
357
+ l1_cap = int(config.get("context.l1_tokens"))
358
+ if focus is not None:
359
+ focus_text = _render_focus(focus, l1_cap)
360
+ sections.append(("focus", f"### Current focus (L1)\n{focus_text}", 1))
361
+ else:
362
+ warnings.append("没有 active 焦点文件(L1)")
363
+
364
+ facets_cap = int(config.get("context.l4_tokens"))
365
+ facets_text = _render_facets(
366
+ global_vault,
367
+ resolved_task_type,
368
+ int(config.get("context.max_facets")),
369
+ facets_cap,
370
+ )
371
+ if facets_text:
372
+ sections.append(("facets", f"### Persona facets (L4)\n{facets_text}", 2))
373
+
374
+ capsules_cap = int(config.get("context.l2_tokens"))
375
+ capsules_text = _render_capsules(
376
+ project_vault,
377
+ project,
378
+ int(config.get("context.max_capsules")),
379
+ capsules_cap,
380
+ )
381
+ if capsules_text:
382
+ sections.append(("capsules", f"### Active capsules (L2)\n{capsules_text}", 3))
383
+
384
+ atlas_cap = int(config.get("context.atlas_tokens"))
385
+ counts, tags = _merged_counts(project_vault, global_vault)
386
+ atlas_text = _render_atlas_pointer(counts, tags, atlas_cap)
387
+ sections.append(("atlas", f"### Atlas pointers\n{atlas_text}", 4))
388
+
389
+ pending_cap = int(config.get("context.pending_questions_tokens"))
390
+ if dissonance_provider is not None:
391
+ try:
392
+ pending_items = dissonance_provider(project_vault)
393
+ except Exception as error:
394
+ pending_items = []
395
+ warnings.append(f"追问队列读取失败: {error}")
396
+ pending_text = _render_pending(list(pending_items), pending_cap)
397
+ if pending_text:
398
+ sections.append(("pending", f"### Pending questions\n{pending_text}", 5))
399
+ except (IndexFailure, ZymorError) as error:
400
+ warnings.append(f"记忆索引不可用: {error.message}")
401
+ fallback = [header]
402
+ if focus is not None:
403
+ fallback.append(f"### Current focus (L1)\n{_render_focus(focus, l1_cap)}")
404
+ fallback.append(f"> 记忆索引不可用:{error.message}。请运行 `zymor index sync` 后重试。")
405
+ text = "\n\n".join(fallback)
406
+ return ContextResult(
407
+ text=text,
408
+ tokens_estimate=estimate_tokens(text),
409
+ section_tokens={"fallback": estimate_tokens(text)},
410
+ task_type=resolved_task_type,
411
+ degraded=True,
412
+ warnings=warnings,
413
+ )
414
+
415
+ text, section_tokens, trimmed = _assemble(sections, budget)
416
+ if trimmed:
417
+ section_tokens["truncated"] = estimate_tokens(TRUNCATION_MARKER.format(budget=budget))
418
+ return ContextResult(
419
+ text=text,
420
+ tokens_estimate=estimate_tokens(text),
421
+ section_tokens=section_tokens,
422
+ task_type=resolved_task_type,
423
+ degraded=False,
424
+ warnings=warnings,
425
+ )
zymor/atlas.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from zymor.model.node import CATEGORIES, LAYERS
9
+ from zymor.store.index import Index
10
+ from zymor.tokenizer import estimate_tokens
11
+ from zymor.util.fs import atomic_write_text
12
+ from zymor.vault import Vault
13
+
14
+ GENERATED_HEADER = "<!-- generated by zymor; do not edit -->"
15
+ CATALOG_LINE_BUDGET = 60
16
+ CATALOG_LINE_LIMIT = 80
17
+ ATLAS_MAX_LINES = 200
18
+ ATLAS_RECENT_LIMIT = 20
19
+ ATLAS_TAG_LIMIT = 100
20
+ STATUSES = ("hypothesis", "verified", "disputed", "deprecated")
21
+
22
+ CATALOG_RE = re.compile(
23
+ r"^- \[(?P<title>.+?)\]\((?P<link>[^)]*)\)"
24
+ r" · (?P<node_id>[^·]*) · (?P<status>[^·]*)"
25
+ r" · (?P<confidence>[^·]*) · (?P<updated_at>.+)$"
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class CatalogEntry:
31
+ title: str
32
+ link: str
33
+ node_id: str | None
34
+ status: str
35
+ confidence: float | None
36
+ updated_at: str
37
+ raw: str
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ """入参:无。出参:目录行字典(browse 工具用)。"""
41
+ return {
42
+ "title": self.title,
43
+ "link": self.link,
44
+ "id": self.node_id,
45
+ "status": self.status,
46
+ "confidence": self.confidence,
47
+ "updated_at": self.updated_at,
48
+ }
49
+
50
+
51
+ def catalog_path(vault: Vault, category: str) -> Path:
52
+ """入参:vault 与分类。出参:该分类 catalog 文件路径。"""
53
+ return vault.nodes_dir / category / "_catalog.md"
54
+
55
+
56
+ def _fit_title(title: str, fixed: str) -> str:
57
+ available = CATALOG_LINE_BUDGET - estimate_tokens(fixed)
58
+ if available <= 2:
59
+ return ""
60
+ if estimate_tokens(title) <= available:
61
+ return title
62
+ kept: list[str] = []
63
+ for char in title:
64
+ candidate = "".join(kept) + char
65
+ if estimate_tokens(candidate + "…") > available:
66
+ break
67
+ kept.append(char)
68
+ return "".join(kept) + "…"
69
+
70
+
71
+ def render_catalog_line(
72
+ node_id: str,
73
+ title: str,
74
+ status: str,
75
+ confidence: float,
76
+ updated_at: str,
77
+ ) -> str:
78
+ """入参:节点元数据。出参:catalog 行(≤60 tokens 目标、≤80 上限)。"""
79
+ fixed = f"- []({node_id}.md) · {node_id} · {status} · {confidence:.2f} · {updated_at}"
80
+ fitted = _fit_title(title, fixed)
81
+ line = f"- [{fitted}]({node_id}.md) · {node_id} · {status} · {confidence:.2f} · {updated_at}"
82
+ if estimate_tokens(line) > CATALOG_LINE_LIMIT:
83
+ line = line[:CATALOG_LINE_LIMIT]
84
+ return line
85
+
86
+
87
+ def parse_catalog_line(line: str) -> CatalogEntry | None:
88
+ """入参:catalog 文本行。出参:解析结果;不是 catalog 行时返回 None。"""
89
+ match = CATALOG_RE.match(line.strip())
90
+ if match is None:
91
+ return None
92
+ node_id = match.group("node_id").strip() or None
93
+ link = match.group("link").strip()
94
+ if node_id is None and link.endswith(".md"):
95
+ node_id = Path(link).stem or None
96
+ confidence_text = match.group("confidence").strip()
97
+ try:
98
+ confidence = float(confidence_text)
99
+ except ValueError:
100
+ confidence = None
101
+ return CatalogEntry(
102
+ title=match.group("title"),
103
+ link=link,
104
+ node_id=node_id,
105
+ status=match.group("status").strip(),
106
+ confidence=confidence,
107
+ updated_at=match.group("updated_at").strip(),
108
+ raw=line,
109
+ )
110
+
111
+
112
+ def read_catalog(vault: Vault, category: str) -> list[CatalogEntry]:
113
+ """入参:vault 与分类。出参:catalog 条目列表;文件不存在时返回空列表。"""
114
+ path = catalog_path(vault, category)
115
+ if not path.is_file():
116
+ return []
117
+ entries: list[CatalogEntry] = []
118
+ for line in path.read_text(encoding="utf-8").splitlines():
119
+ entry = parse_catalog_line(line)
120
+ if entry is not None:
121
+ entries.append(entry)
122
+ return entries
123
+
124
+
125
+ def generate_catalog(vault: Vault, category: str) -> Path:
126
+ """入参:vault 与分类。出参:写出的 catalog 路径。生成一行一节点的可 grep 目录。"""
127
+ if category not in CATEGORIES:
128
+ raise ValueError(f"未知分类: {category}")
129
+ rows = Index(vault.root).rows_for_category(category)
130
+ lines = [GENERATED_HEADER, ""]
131
+ for row in rows:
132
+ lines.append(
133
+ render_catalog_line(
134
+ node_id=str(row["id"]),
135
+ title=str(row["title"]),
136
+ status=str(row["status"]),
137
+ confidence=float(row["confidence"]),
138
+ updated_at=str(row["updated_at"]),
139
+ )
140
+ )
141
+ target = catalog_path(vault, category)
142
+ atomic_write_text(target, "\n".join(lines) + "\n")
143
+ return target
144
+
145
+
146
+ def generate_catalogs(vault: Vault) -> dict[str, Path]:
147
+ """入参:vault。出参:分类到 catalog 路径的映射。"""
148
+ return {category: generate_catalog(vault, category) for category in CATEGORIES}
149
+
150
+
151
+ def generate_atlas(vault: Vault) -> Path:
152
+ """入参:vault。出参:写出的 ATLAS 路径。生成 ≤200 行的顶层导航。"""
153
+ index = Index(vault.root)
154
+ counts = index.counts()
155
+ tags = index.tag_counts(limit=ATLAS_TAG_LIMIT)
156
+ recent = index.recent_rows("2000-01-01T00:00:00+00:00", limit=ATLAS_RECENT_LIMIT)
157
+ lines = [GENERATED_HEADER, "", "# ATLAS", ""]
158
+ lines.append("## 概览")
159
+ lines.append(f"- 节点总数: {counts['total']}")
160
+ lines.append(
161
+ "- 层级: " + " / ".join(f"{layer} {counts['layers'].get(layer, 0)}" for layer in LAYERS)
162
+ )
163
+ lines.append(
164
+ "- 分类: " + " / ".join(f"{cat} {counts['categories'].get(cat, 0)}" for cat in CATEGORIES)
165
+ )
166
+ lines.append(
167
+ "- 状态: "
168
+ + " / ".join(f"{status} {counts['statuses'].get(status, 0)}" for status in STATUSES)
169
+ )
170
+ lines.append(f"- 链接: {counts['links']}")
171
+ lines.append("")
172
+ lines.append("## 目录")
173
+ for category in CATEGORIES:
174
+ count = counts["categories"].get(category, 0)
175
+ lines.append(f"- [{category}](nodes/{category}/_catalog.md)({count})")
176
+ lines.append("")
177
+ lines.append("## Top tags")
178
+ if tags:
179
+ chunks = [f"{tag}({count})" for tag, count in tags]
180
+ for start in range(0, len(chunks), 10):
181
+ lines.append("- " + ", ".join(chunks[start : start + 10]))
182
+ else:
183
+ lines.append("- (无)")
184
+ lines.append("")
185
+ lines.append("## 最近更新")
186
+ if recent:
187
+ for row in recent:
188
+ lines.append(
189
+ f"- {row['updated_at']} [{row['title']}]({row['path']}) — "
190
+ f"{row['layer']}/{row['category']} {row['status']}"
191
+ )
192
+ else:
193
+ lines.append("- (无)")
194
+ if len(lines) > ATLAS_MAX_LINES:
195
+ lines = lines[: ATLAS_MAX_LINES - 1] + [f"…(ATLAS 超过 {ATLAS_MAX_LINES} 行已截断)"]
196
+ target = vault.atlas_dir / "ATLAS.md"
197
+ atomic_write_text(target, "\n".join(lines) + "\n")
198
+ return target
199
+
200
+
201
+ def generate(vault: Vault) -> dict[str, str]:
202
+ """入参:vault。出参:生成物相对路径映射。生成失败抛异常,由调用方决定是否阻塞。"""
203
+ paths = generate_catalogs(vault)
204
+ paths["atlas"] = generate_atlas(vault)
205
+ return {key: vault.relative(value) for key, value in paths.items()}