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 +3 -0
- zymor/__main__.py +4 -0
- zymor/assembly.py +425 -0
- zymor/atlas.py +205 -0
- zymor/cli.py +1050 -0
- zymor/config.py +263 -0
- zymor/dream/__init__.py +27 -0
- zymor/dream/arbitration.py +833 -0
- zymor/dream/decay.py +95 -0
- zymor/dream/engine.py +298 -0
- zymor/dream/evidence.py +272 -0
- zymor/dream/nrem.py +1227 -0
- zymor/dream/persona.py +997 -0
- zymor/dream/promote.py +110 -0
- zymor/dream/rem.py +348 -0
- zymor/dream/state.py +264 -0
- zymor/dream/types.py +281 -0
- zymor/embeddings.py +272 -0
- zymor/errors.py +95 -0
- zymor/invalidation.py +293 -0
- zymor/llm.py +349 -0
- zymor/maintenance.py +118 -0
- zymor/mcp_server.py +1217 -0
- zymor/model/__init__.py +1 -0
- zymor/model/event.py +169 -0
- zymor/model/focus.py +148 -0
- zymor/model/node.py +362 -0
- zymor/promotion.py +277 -0
- zymor/retrieval.py +1028 -0
- zymor/rule_checks.py +178 -0
- zymor/security.py +80 -0
- zymor/store/__init__.py +1 -0
- zymor/store/audit.py +35 -0
- zymor/store/index.py +755 -0
- zymor/store/nodes.py +161 -0
- zymor/tasktype.py +19 -0
- zymor/tokenizer.py +125 -0
- zymor/util/__init__.py +0 -0
- zymor/util/fs.py +51 -0
- zymor/util/ids.py +30 -0
- zymor/util/time.py +56 -0
- zymor/util/yamlutil.py +82 -0
- zymor/vault.py +340 -0
- zymor/write_service.py +2110 -0
- zymor-0.2.0.dist-info/METADATA +185 -0
- zymor-0.2.0.dist-info/RECORD +49 -0
- zymor-0.2.0.dist-info/WHEEL +4 -0
- zymor-0.2.0.dist-info/entry_points.txt +2 -0
- zymor-0.2.0.dist-info/licenses/LICENSE +202 -0
zymor/__init__.py
ADDED
zymor/__main__.py
ADDED
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()}
|