loop-memory 0.4.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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2021 @@
1
+ """Evolution Consolidator — a 5-stage distillation pipeline.
2
+
3
+ Replaces the old single-pass LLM consolidator with a hierarchical,
4
+ feedback-driven loop. The pipeline is:
5
+
6
+ Stage 1 Signal-Aware Scoring
7
+ Each memory's importance is blended with its behavioural
8
+ signals (recall_count, positive/negative feedback). This
9
+ makes "things the user actually uses" float to the top.
10
+
11
+ Stage 2 Semantic Batching
12
+ Memories are embedded (or use a hashed fallback) and
13
+ clustered into K buckets via greedy cosine clustering.
14
+ Each cluster <= CLUSTER_MAX so the LLM never sees too much
15
+ at once and the topic stays focused.
16
+
17
+ Stage 3 Per-Cluster Distillation
18
+ For each cluster we ask the LLM to produce:
19
+ * a 1-sentence cluster summary
20
+ * a refined importance per row
21
+ * a list of "keep / drop / rewrite" actions
22
+ This is the cheap-per-cluster pass that decides what's
23
+ noise vs. signal.
24
+
25
+ Stage 4 Hierarchical Wiki Synthesis
26
+ Cluster summaries + the user's existing wiki pages are
27
+ passed to the LLM, which produces/updates wiki pages
28
+ grouped by *user-profile dimension*:
29
+ - preferences (how the user likes things done)
30
+ - decisions (concrete choices the user made)
31
+ - projects (ongoing work / topics)
32
+ - domain (technical knowledge to keep)
33
+ - feedback (corrections, dislikes, do/don't)
34
+ Re-running merges with existing wiki pages by slug.
35
+
36
+ Stage 5 Evolution Memo
37
+ We persist a short "evolution memo" (which wiki pages
38
+ changed, how much importance shifted, what signals were
39
+ used). The next run's Stage 4 prompt includes the memo so
40
+ the LLM keeps learning the user's preferences across runs
41
+ without us having to retrain anything.
42
+
43
+ The consolidator is fully optional: the existing single-pass
44
+ ``LLMConsolidator`` still works and is what the UI's "AI Consolidate"
45
+ button calls by default. ``EvolutionConsolidator`` is wired in as an
46
+ opt-in mode so the user can A/B compare.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import hashlib
52
+ import json
53
+ import math
54
+ import re
55
+ import time
56
+ from collections.abc import Callable
57
+ from dataclasses import dataclass, field
58
+ from typing import Any
59
+
60
+ from ..llm.base import ChatHistory, LLMClient, Message
61
+ from ..storage.sqlite_store import MemoryStore, StoredMemory
62
+ from ..wiki.prompts import (
63
+ MAX_WIKI_PROMPTS_PER_PAGE,
64
+ MIN_BODY_CHARS,
65
+ MIN_BULLETS_PER_PAGE,
66
+ count_bullets,
67
+ expansion_prompt,
68
+ meets_body_floor,
69
+ normalise_lang,
70
+ wiki_system_prompt,
71
+ )
72
+
73
+ # ----------------------------------------------------------------------------
74
+ # Constants
75
+ # ----------------------------------------------------------------------------
76
+
77
+ # Bullet-line splitter used by ``_expand_under_floor_pages`` to merge the
78
+ # "old" body with the "expansion" body returned by the LLM. We keep the
79
+ # order stable: all existing bullets first, then any new bullet whose
80
+ # first 60 chars aren't already present.
81
+ _BULLET_LINE_RE = re.compile(r"^[\s]*[-\*][\s]+\S", re.MULTILINE)
82
+
83
+
84
+ def _split_bullets(body: str) -> list[str]:
85
+ """Return a list of bullet lines (without trailing blank lines)."""
86
+ if not body:
87
+ return []
88
+ lines = []
89
+ buf: list[str] = []
90
+ for line in body.splitlines():
91
+ if _BULLET_LINE_RE.match(line):
92
+ if buf:
93
+ lines.append("\n".join(buf).rstrip())
94
+ buf = []
95
+ buf.append(line)
96
+ else:
97
+ if buf:
98
+ buf.append(line)
99
+ if buf:
100
+ lines.append("\n".join(buf).rstrip())
101
+ return [ln for ln in lines if ln.strip()]
102
+
103
+
104
+ def _merge_bullet_lists(old: str, new: str) -> str:
105
+ """Append-only bullet merge. Existing bullets keep their order; new
106
+ bullets whose first 60 chars are not already present get appended.
107
+ Blank lines and prose paragraphs in ``new`` are also appended at the
108
+ end so the LLM can keep its "_Source:" footer or similar."""
109
+ old_lines = _split_bullets(old or "")
110
+ new_lines = _split_bullets(new or "")
111
+ seen = {ln.strip()[:60].lower() for ln in old_lines}
112
+ appended: list[str] = []
113
+ for nl in new_lines:
114
+ key = nl.strip()[:60].lower()
115
+ if not key or key in seen:
116
+ continue
117
+ seen.add(key)
118
+ appended.append(nl)
119
+ if not appended:
120
+ return old
121
+ return (old.rstrip() + "\n" + "\n".join(appended)).rstrip()
122
+
123
+
124
+ _TOPIC_WORD_RE = re.compile(r"[a-z0-9][a-z0-9_.+-]{2,}", re.IGNORECASE)
125
+ _TOPIC_CJK_RE = re.compile(r"[\u4e00-\u9fff]{2,}")
126
+ _TOPIC_STOP_WORDS = {
127
+ "project", "domain", "decision", "feedback", "preferences",
128
+ "the", "and", "for", "with", "from", "this", "that",
129
+ }
130
+
131
+
132
+ def _topic_tokens(value: Any) -> set[str]:
133
+ """Return lightweight bilingual tokens for evidence recovery."""
134
+ text = str(value or "").lower()
135
+ tokens = {
136
+ token for token in _TOPIC_WORD_RE.findall(text)
137
+ if token not in _TOPIC_STOP_WORDS
138
+ }
139
+ for span in _TOPIC_CJK_RE.findall(text):
140
+ if len(span) <= 8:
141
+ tokens.add(span)
142
+ tokens.update(span[index:index + 2] for index in range(len(span) - 1))
143
+ return tokens
144
+
145
+
146
+ def _recover_page_evidence(
147
+ page: dict[str, Any],
148
+ clusters: list[dict[str, Any]],
149
+ ) -> list[str]:
150
+ """Validate LLM citations and recover omitted IDs from matching clusters."""
151
+ cluster_ids = {
152
+ str(memory_id)
153
+ for cluster in clusters
154
+ for memory_id in (cluster.get("evidence_ids") or [])
155
+ if memory_id
156
+ }
157
+ supplied = page.get("evidence_ids") or []
158
+ if isinstance(supplied, list):
159
+ valid = [str(memory_id) for memory_id in supplied if str(memory_id) in cluster_ids]
160
+ if valid:
161
+ return list(dict.fromkeys(valid))[:50]
162
+
163
+ page_text = " ".join([
164
+ str(page.get("slug") or ""),
165
+ str(page.get("title") or ""),
166
+ str(page.get("summary") or ""),
167
+ str(page.get("body") or ""),
168
+ " ".join(str(tag) for tag in (page.get("tags") or [])),
169
+ ])
170
+ page_tokens = _topic_tokens(page_text)
171
+ ranked: list[tuple[int, int, dict[str, Any]]] = []
172
+ for index, cluster in enumerate(clusters):
173
+ evidence_ids = cluster.get("evidence_ids") or []
174
+ if not evidence_ids:
175
+ continue
176
+ score = len(page_tokens & _topic_tokens(cluster.get("text") or ""))
177
+ ranked.append((score, -index, cluster))
178
+ ranked.sort(reverse=True, key=lambda item: (item[0], item[1]))
179
+ if not ranked:
180
+ return []
181
+ best_score = ranked[0][0]
182
+ if best_score <= 0 and len(ranked) != 1:
183
+ return []
184
+ selected = [ranked[0][2]]
185
+ if best_score > 0:
186
+ threshold = max(1, math.ceil(best_score * 0.6))
187
+ selected = [cluster for score, _index, cluster in ranked if score >= threshold]
188
+ recovered = [
189
+ str(memory_id)
190
+ for cluster in selected
191
+ for memory_id in (cluster.get("evidence_ids") or [])
192
+ if memory_id
193
+ ]
194
+ return list(dict.fromkeys(recovered))[:50]
195
+
196
+
197
+ CLUSTER_MAX = 15 # max memories per cluster (Stage 3 prompt size)
198
+ WIKI_INPUT_CLUSTERS = 8 # how many cluster summaries feed Stage 4
199
+ PROFILE_DIMS = ("preferences", "decisions", "projects", "domain", "feedback")
200
+
201
+ # ----------------------------------------------------------------------------
202
+ # Text noise cleaners (used by Stage 0 dedup + rule-based wiki fallback)
203
+ # ----------------------------------------------------------------------------
204
+
205
+ # Common assistant boilerplate / meta-narration to strip from raw memory text
206
+ # when synthesizing wiki bodies without an LLM. We keep the regex list small
207
+ # and targeted so a real, atomic fact still survives.
208
+ # Recognise the common "wrapper" prefixes the loop-memory hooks prepend
209
+ # to assistant text. We strip the whole wrapper so the atomic fact inside
210
+ # can survive, instead of being glued to cron ids / working-directory tags.
211
+ _NOISE_WRAPPER_PATTERNS = [
212
+ # Cron-prompt headers: "[cron:UUID label] 请立即生成..."
213
+ r"\[cron:[^\]]+\]",
214
+ # Working-directory wrapper: "[Working directory: ~/.openclaw/workspace] 帮我..."
215
+ r"\[Working directory:[^\]]*\]\s*",
216
+ # Provider / source prefix: "[codex] ", "[claude] ", "[hermes] ", "[openclaw] "
217
+ r"\[(?:codex|claude|hermes|openclaw|claude-code|chatgpt|hermes-cli)\][\s::]+",
218
+ # Outcome / Result / Status / Task / Latest / Output prefix
219
+ r"^(?:Outcome|Result|Status|Task|Latest|Output|Reply|Response|Assistant|Human|User intent)\s*[::]\s*",
220
+ # "[thinking]" inline blocks: keep stripping
221
+ r"\[thinking\][\s\S]*?\[/thinking\]",
222
+ r"\[thinking\]\s*",
223
+ # "You are an assistant..." tool-system narration
224
+ r"You are an? [^.\n]{1,160}\.\s*",
225
+ # "<cwd>", "<shell>", "<current_date>", "<permissions_instructions>" env tags
226
+ r"<\w+>[\s\S]*?</\w+>",
227
+ r"<\w+>\s*",
228
+ # "**已完成**" / "**全部完成**" / "DONE" markers as standalone
229
+ r"^\*\*(?:已完成|全部完成|DONE|DONE\.|OK|完成|完成\.)\*\*\s*[::]?\s*",
230
+ ]
231
+ _NOISE_PREFIX_RE = re.compile(
232
+ "|".join(_NOISE_WRAPPER_PATTERNS),
233
+ re.IGNORECASE,
234
+ )
235
+ _NOISE_CODE_FENCE_RE = re.compile(r"```[^`]*```", re.DOTALL)
236
+ _NOISE_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
237
+ _NOISE_PATH_RE = re.compile(r"/Users/[^\s)\"<>]+")
238
+ _NOISE_URL_RE = re.compile(r"https?://[^\s)\"<>]+")
239
+ _NOISE_WS_RE = re.compile(r"[ \t]+")
240
+ _NOISE_NEWLINES_RE = re.compile(r"\n{2,}")
241
+ # Markdown table rows: "| col | col | col |" — never a fact
242
+ _NOISE_TABLE_ROW_RE = re.compile(r"(?:\|[^|\n]*)+\|")
243
+ # Markdown headings: "## Heading" or "### Heading"
244
+ _NOISE_HEADING_RE = re.compile(r"^#+\s+[^\n]+", re.MULTILINE)
245
+ # Markdown emphasis that wraps an entire short line: "**全部完成。**"
246
+ _NOISE_BOLD_LINE_RE = re.compile(r"^\*\*[^\n]{1,40}\*\*\s*[::]?\s*", re.MULTILINE)
247
+ # Repeated punctuation runs (---, ====, *****)
248
+ _NOISE_RULE_RE = re.compile(r"^[-=*]{3,}\s*$", re.MULTILINE)
249
+
250
+
251
+ def _clean_noise(text: str, *, max_len: int = 240) -> str:
252
+ """Strip the wrapper off a raw memory so the atomic fact can survive.
253
+
254
+ The goal is *not* to summarize (the LLM does that); it is to remove the
255
+ 80% of characters that are pleasantries, code fences, narration, file
256
+ paths, and tool chatter so a downstream bullet is readable on its own.
257
+
258
+ Order matters: bigger wrappers (cron headers, env tags) first, then
259
+ inline / line-level noise (markdown tables, bold-as-line, code).
260
+ """
261
+ if not text:
262
+ return ""
263
+ s = text
264
+ # Drop code fences entirely (they are usually tool output, not a fact).
265
+ s = _NOISE_CODE_FENCE_RE.sub(" ", s)
266
+ # Markdown structural noise: tables, headings, hrules, whole-line bold
267
+ s = _NOISE_TABLE_ROW_RE.sub(" ", s)
268
+ s = _NOISE_RULE_RE.sub(" ", s)
269
+ s = _NOISE_HEADING_RE.sub(" ", s)
270
+ s = _NOISE_BOLD_LINE_RE.sub("", s)
271
+ # Drop wrapper prefixes (cron, working directory, provider tags, [thinking],
272
+ # <cwd> env tags, "Outcome: ", "User intent: ", etc.). Use a loop because
273
+ # the same text can have multiple stacked wrappers ("[openclaw] [Working
274
+ # directory: ...] 帮我...") and a single sub() only catches the first.
275
+ for _ in range(4):
276
+ prev_new = _NOISE_PREFIX_RE.sub("", s)
277
+ if prev_new == s:
278
+ break
279
+ s = prev_new
280
+ # Inline code: keep the inside, not the backticks.
281
+ s = _NOISE_INLINE_CODE_RE.sub(r"\1", s)
282
+ # Drop absolute home paths (these leak the user's filesystem, not a fact).
283
+ s = _NOISE_PATH_RE.sub(" ", s)
284
+ # Drop URLs (rarely a fact worth keeping in a wiki bullet).
285
+ s = _NOISE_URL_RE.sub(" ", s)
286
+ # Collapse whitespace.
287
+ s = _NOISE_WS_RE.sub(" ", s)
288
+ s = _NOISE_NEWLINES_RE.sub(" ", s)
289
+ s = s.strip(" \t\n.,;:|/\u3000")
290
+ # Final hard cap
291
+ if len(s) > max_len:
292
+ s = s[: max_len - 1].rstrip(" ,;:|/") + "…"
293
+ return s
294
+
295
+
296
+ # Patterns that mark a memory as pure status narration / completion ping
297
+ # with no atomic fact a wiki page should remember. Matched on the CLEANED
298
+ # (lowercased) form. The bar is: would a future user re-derive something
299
+ # they didn't already know? If the answer is no, drop it.
300
+ _LOW_SIGNAL_PATTERNS = (
301
+ # English
302
+ r"^tests? (is|are) green",
303
+ r"^ci (passed|green|succeeded|is green)",
304
+ r"^all (good|done|set|clear|green|passing|checks? pass)",
305
+ r"^running\s*\.\.\.?\s*$",
306
+ r"^done\.?$",
307
+ r"^ok\.?$",
308
+ r"^green\.?$",
309
+ r"^passed\.?$",
310
+ r"^succeeded\.?$",
311
+ r"^pushed successfully",
312
+ r"^now (commit|wait|push|run|deploy|test)",
313
+ r"^everything is green",
314
+ r"^all tests pass",
315
+ r"^build (passed|succeeded|is green)",
316
+ r"^lints? clean",
317
+ r"^ruff (clean|is clean|ok)",
318
+ r"^verified? (locally|and ready|ok)",
319
+ # Chinese
320
+ r"^全部完成",
321
+ r"^已完成",
322
+ r"^完成\.?",
323
+ r"^已经完成",
324
+ r"^都 (好|搞定|完成|通过了)",
325
+ r"^测试 (通过|成功|过了|全过)",
326
+ r"^跑通了?",
327
+ r"^没问题",
328
+ r"^好的",
329
+ r"^已推送",
330
+ r"^推送成功",
331
+ r"^现在 (推送|提交|等待|运行|测试)",
332
+ r"^任务完成",
333
+ r"^看板 (已|现在) 显示",
334
+ r"^一切 (正常|就绪|顺利)",
335
+ r"^191/191",
336
+ r"^\d+/\d+",
337
+ )
338
+
339
+ # Heuristic flags for RAW USER PROMPTS that should never become wiki
340
+ # bullets. These are conversational imperatives that wrap the user's
341
+ # request — useful for session replay, but they contain no atomic fact a
342
+ # future user (or model) would re-derive. Matched on the cleaned form.
343
+ _RAW_PROMPT_PATTERNS = (
344
+ # "帮我..." (help me...)
345
+ r"^帮我",
346
+ r"\s帮我(?!知道|看一下|看看)",
347
+ r"帮我(检查|梳理|分析|整理|写|生成|跑|修复|优化|测试|部署|调研|对比|总结|封装|压缩|转换|翻译)",
348
+ # "请立即..." / "请..." (Please immediately)
349
+ r"^请立即",
350
+ r"^请(?!求|求你|告诉我)",
351
+ # Catch short imperative + concrete action even after wrapper stripping
352
+ r"^请立即生成",
353
+ r"^请把",
354
+ r"^请你",
355
+ # "the user is asking..." / "the user wants..." - assistant narration
356
+ r"the user (is asking|wants|wants? to|asked|wants me to|requested)",
357
+ r"^the user ",
358
+ # "如何..." / "How to..." / "怎么..." (how to)
359
+ r"^(如何|怎么|怎样|怎么(样|做)|how (to|do|can|should))\b",
360
+ # English imperatives the user types verbatim
361
+ r"^(please\s+)?(help|write|create|make|build|find|fix|run|do|generate|check|analy[sz]e|review|explain|compare|summari[sz]e|optimi[sz]e|clean|deploy)\s+(me\s+|a\s+|an\s+|the\s+|this\s+|that\s+|my\s+|our\s+|some\s+|all\s+)?",
362
+ # Verb-first project briefs
363
+ r"^(thoroughly|completely|fully|deeply|carefully)\s+(explore|review|analy[sz]e|investigate|examine|rewrite|rebuild|redesign)\b",
364
+ # "整理..." wrappers
365
+ r"^整理[\"\u201c].*?(目录|文件|报告|教程|笔记)",
366
+ r"^梳理.*?(市场|标的|行业|板块|投资|整个项目|项目|缺陷|代码|逻辑)",
367
+ # More Chinese imperatives (verb-first) — match the verb alone so
368
+ # "整理整个项目" / "分析下当前" / "运行测试" all flag.
369
+ r"^(分析|调研|梳理|整理|检查|生成|写|制作|开发|修复|优化|跑通|跑|测试|运行|启动|部署|上传|下载|导出|导入|删除|添加|新增|更新|修改|重写|重构|封装|压缩|转换|翻译|读取|写入|抓取|爬取|搜索|查看|打开|关闭|重启|停止|暂停|继续|创建|构建|编译|操作|执行|移动|复制|粘贴|撤销|恢复|清理|清空|刷新|加载|渲染|展示)",
370
+ # Verb + 下/一下/看看
371
+ r"(帮我|请)?(看|处理|搞|弄|调整|改|写|跑|做|检查|分析|调研|梳理|整理|总结|测试|运行|启动|部署|搜索|查看)(一下|看|看看|下)",
372
+ r"^(看|处理|搞|弄|调整|改|写|跑|做|检查|分析|调研|梳理|整理|总结|测试|运行|启动|部署|搜索|查看)(一下|看|看看|下)",
373
+ # "我..." user-self imperative
374
+ r"^我(想|需要|要|想请|想让|希望)",
375
+ # "You are ..." assistant task instructions (colon or period)
376
+ r"^you are (implementing|fixing|reviewing|writing|building|creating|checking|verifying|analy[sz]ing|investigating|designing|updating|modifying)\b",
377
+ # Pure file-path / tool recipes
378
+ r"^[/~][\w./_-]+/\w+\.(py|md|sh|json|yml|yaml|toml)\s*$",
379
+ # "PDF was generated" / "I am now going to" type assistant narration
380
+ r"^(pdf|the pdf|the file|the report|it) was (generated|created|saved|uploaded|sent)",
381
+ r"^(i|now|next|then) (will|am going to|need to|should|want to)\b",
382
+ # "let me ..." assistant narration
383
+ r"^let me (check|look|run|try|verify|test|create|build|do|now)\b",
384
+ r"^now let me ",
385
+ # "I\'m going to ..." assistant narration
386
+ r"^i.m going to ",
387
+ # Verb-imperative-only (when text is 4-7 chars, no period, ends in noun)
388
+ r"^[一-鿿]{2,4}[一-鿿a-z]",
389
+ # "看下X" / "整理X" / etc. — verb + 下/一下/看/看看 with anything after
390
+ r"^[看处理搞弄调整改写跑做检查分析调研梳理整理总结测试运行启动部署搜索查看](下|一下|看|看看)",
391
+ )
392
+
393
+
394
+ def _is_low_signal(text: str) -> bool:
395
+ """Return True if a memory contains no actionable information worth a wiki
396
+ bullet. Used to drop near-empty / pure-status memories before clustering.
397
+ Operates on the cleaned form so "Outcome: tests are green" still matches."""
398
+ if not text:
399
+ return True
400
+ s = _clean_noise(text, max_len=400).strip().lower()
401
+ if len(s) < 12:
402
+ return True
403
+ for pat in _LOW_SIGNAL_PATTERNS:
404
+ if re.match(pat, s):
405
+ return True
406
+ return False
407
+
408
+
409
+ def _looks_like_raw_prompt(text: str) -> bool:
410
+ """Return True if a memory is *primarily* a raw user prompt — i.e. its
411
+ atomic value is essentially the question / command itself, not a fact.
412
+
413
+ The wiki must NOT show these: the user already remembers the question,
414
+ and stuffing it into the wiki pollutes recall. We DO keep the memory in
415
+ the store (so context can be replayed) but the rule-based wiki step
416
+ drops the prompt form. A real LLM distillation would also drop these.
417
+ """
418
+ if not text:
419
+ return True
420
+ s = _clean_noise(text, max_len=400).strip().lower()
421
+ # Threshold: very short texts (< 4 chars) are too ambiguous to
422
+ # classify (a single Chinese verb is itself 2 chars, so 4 is the
423
+ # smallest useful unit for an imperative).
424
+ if len(s) < 4:
425
+ return False
426
+ for pat in _RAW_PROMPT_PATTERNS:
427
+ if re.search(pat, s):
428
+ return True
429
+ # "Thoroughly explore the X project at I need to understand: 1. 2. 3. ..."
430
+ # type run-on briefs. If the cleaned text still contains "i need to
431
+ # understand" or "i want to" verbatim, it's a brief, not a fact.
432
+ if re.search(r"i (need|want) to (understand|know|do|build|check|see|review|explore)", s):
433
+ return True
434
+ # Long imperative sentence (>140 cleaned chars) with no period in the
435
+ # first 80 chars is almost always a user prompt, not an observation.
436
+ head = s[:80]
437
+ if len(s) > 140 and "." not in head and (" " in head[:20] or "\n" not in head):
438
+ # Additional sanity: starts with a verb or 帮我 / 请 / how / please
439
+ if re.match(r"^(please|how|why|what|when|where|can|could|would|should|do|does|is|are|was|were|i|you|we|let)", head):
440
+ return True
441
+ return False
442
+
443
+
444
+ def _title_from(text: str, fallback_kind: str = "", max_len: int = 60) -> str:
445
+ """Make a real, noun-phrase title out of a raw memory string. Strips the
446
+ wrapper, picks the first meaningful clause, and clamps length."""
447
+ cleaned = _clean_noise(text, max_len=max_len * 2)
448
+ if not cleaned:
449
+ return (fallback_kind.title() if fallback_kind else "Cluster")[:max_len]
450
+ # Prefer the part before the first sentence break
451
+ head = re.split(r"[.!?。!?\n]", cleaned, maxsplit=1)[0].strip(" ,;:|/")
452
+ if not head:
453
+ head = cleaned
454
+ if len(head) > max_len:
455
+ head = head[: max_len - 1].rstrip(" ,;:|/") + "…"
456
+ return head
457
+
458
+
459
+ # Stage 1 weight on signals. importance in [0,1] is the LLM/original value;
460
+ # we blend in recall_count and negative as dampeners.
461
+ W_RECALL = 0.10 # +0.10 per high-recall cluster, capped
462
+ W_NEGATIVE = 0.15 # -0.15 per negative feedback event, capped
463
+ RECALL_SATURATION = 5 # recall_count / 5 saturates the recall boost
464
+ NEGATIVE_SATURATION = 3 # 3 negative events = full dampener
465
+
466
+ # Stage 3 system prompt: per-cluster, keep/rewrite/drop actions.
467
+ #
468
+ # Length policy (v2 — completeness over compactness):
469
+ # * No hard character caps anywhere. The goal is one COMPLETE, USABLE fact
470
+ # per item, written so a human or another LLM can act on it later without
471
+ # re-reading the original transcript.
472
+ # * Brevity is still preferred where it does not lose information: prefer a
473
+ # single declarative sentence; collapse obvious tautologies; but NEVER cut
474
+ # a fact, a constraint, a number, a name, or a qualifier just to hit a
475
+ # length target.
476
+ # * Strip ONLY the wrapper noise (see below). The body of the fact stays.
477
+ _CLUSTER_SYSTEM = (
478
+ "You are an assistant that tidies a small cluster of personal memory snippets. "
479
+ "Treat every item as raw evidence and pull out the ATOMIC fact, never the wrapper.\n"
480
+ "COMPLETENESS OVER COMPACTNESS: the user is rebuilding a long-term personal "
481
+ "knowledge base. Losing a fact is much worse than writing a longer distill. "
482
+ "If the row contains a concrete decision, preference, number, name, "
483
+ "constraint, error, or workaround — preserve it verbatim. Prefer a single "
484
+ "declarative sentence but DO NOT truncate mid-clause to hit a length cap.\n"
485
+ "For EACH item return a JSON object with:\n"
486
+ ' keep: boolean (true = the row contains real signal worth keeping long-term)\n'
487
+ ' importance: number 0..1 (your new estimate of long-term importance for a '
488
+ 'user-profile knowledge base — be strict; routine tool chatter is <0.3)\n'
489
+ ' distill: a complete, standalone rewritten version of the core fact. '
490
+ 'NO HARD CHARACTER LIMIT. Strip ONLY wrapper noise: greeting/pleasantries, '
491
+ 'tool chatter ("Now let me check...", "Let me run..."), [thinking] blocks, '
492
+ 'code fences, raw user prompts repeated verbatim, "Outcome:" prefixes that '
493
+ "just echo the user's question, file paths inside the user's home directory, "
494
+ 'console-style progress narration, and meta commentary about the assistant '
495
+ 'itself. The fact itself — every decision, number, name, error message, '
496
+ 'command, or constraint — MUST survive intact. If the row has no real fact '
497
+ 'after stripping wrapper noise, set distill to "".\n'
498
+ ' tags: array of up to 5 lowercase tags (no duplicates, snake_case preferred).\n'
499
+ 'Set keep=false for: pure repetition of an earlier item, status pings with no '
500
+ 'fact ("Tests are green"), single-line acknowledgements, or text whose only '
501
+ 'information is a file path the user already knows.\n'
502
+ 'Reply with JSON: {"items": [...]}, no prose.'
503
+ )
504
+
505
+ # Stage 4 system prompt: build/update wiki pages from cluster summaries.
506
+ #
507
+ # Length policy (v2 — completeness over compactness):
508
+ # * No hard character caps on title / summary / body / slug.
509
+ # * Bullets must be COMPLETE atomic facts — every decision, number, name,
510
+ # constraint, error, or workaround from the source clusters must survive
511
+ # in at least one bullet on the relevant page.
512
+ # * Brevity is still preferred where it does not lose information — but the
513
+ # test is: "could the user act on this bullet without re-reading the
514
+ # original conversation?". If not, expand it.
515
+ _WIKI_SYSTEM = (
516
+ "You maintain a personal knowledge base for ONE user. You receive the user's "
517
+ "existing wiki pages plus a batch of distilled cluster summaries (each already "
518
+ "filtered for noise).\n"
519
+ "GOAL: each page must be a COMPLETE, ACTIONABLE atomic note the user would "
520
+ "actually want to recall later — NOT a quote of the original conversation, "
521
+ "and NOT a half-fact that loses the actionable detail.\n"
522
+ "COMPLETENESS OVER COMPACTNESS: if a cluster contains a number, a name, a "
523
+ "decision, a constraint, an error message, or a workaround, that detail "
524
+ "MUST land on the relevant page. The user will rely on this knowledge base "
525
+ "to skip re-deriving facts. Losing a fact is much worse than a longer page.\n"
526
+ "ALWAYS bucket into one of these dimensions, and make the slug reflect the topic:\n"
527
+ " preferences-<topic>, decision-<topic>, project-<topic>, domain-<topic>, "
528
+ "feedback-<topic>. Examples: 'prefers-dark-mode', 'decision-batch-size-50', "
529
+ "'project-loop-memory', 'domain-crypto-swing-trades', 'feedback-no-mixed-lang'.\n"
530
+ "Each page MUST have:\n"
531
+ ' slug: lowercase, hyphen-separated, prefixed with the dimension; no hard '
532
+ 'length cap, but keep it readable in a URL. NEVER a truncated user prompt.\n'
533
+ ' title: a real noun phrase that names the topic; no hard length cap, but '
534
+ 'keep it under ~12 words. NEVER a truncated user prompt.\n'
535
+ ' summary: 1-3 sentence definition that stands on its own; no hard length '
536
+ 'cap, no truncation mid-clause, MUST be understandable without the source.\n'
537
+ ' body: bullet-point markdown, each bullet starting with "- ". One atomic '
538
+ 'fact per bullet. NO hard cap on bullet count — use as many bullets as the '
539
+ 'source clusters justify, typically 4-20 for a real page. Every decision, '
540
+ 'number, name, error, constraint, or workaround from the source MUST appear '
541
+ 'in at least one bullet. No prose paragraphs. No "Outcome: ..." echoes. '
542
+ 'Code fences ONLY when the fact is literally a command or config snippet.\n'
543
+ ' tags: 3-6 lowercase tags, snake_case\n'
544
+ ' importance: 0..1 (1 = critical user preference/project, 0.3 = transient detail)\n'
545
+ ' evidence_ids: list of memory ids that back this page (cite real ids from the input)\n'
546
+ "SKIP a cluster summary if it is just a user prompt, status update, or repeats "
547
+ "another cluster. PREFER updating an existing page (same slug) over creating a "
548
+ "near-duplicate — when updating, APPEND new atomic facts rather than rewriting "
549
+ "existing ones, so cumulative knowledge is preserved. Reply with JSON: "
550
+ "{\"pages\": [...]}. If nothing adds new info, reply {\"pages\": []}. No prose, "
551
+ "no markdown outside the JSON."
552
+ )
553
+
554
+
555
+ # ----------------------------------------------------------------------------
556
+ # Public dataclass
557
+ # ----------------------------------------------------------------------------
558
+
559
+
560
+ @dataclass
561
+ class EvolutionStats:
562
+ scanned: int = 0
563
+ rescored: int = 0
564
+ dropped: int = 0
565
+ deduped: int = 0 # near-duplicate memories collapsed
566
+ resummarized: int = 0
567
+ clusters: int = 0
568
+ cluster_calls: int = 0
569
+ wiki_calls: int = 0
570
+ wiki_created: int = 0
571
+ wiki_updated: int = 0
572
+ wiki_retired: int = 0 # noisy legacy wiki pages removed
573
+ elapsed_ms: float = 0.0
574
+ notes: list[str] = field(default_factory=list)
575
+ stages: dict[str, dict[str, Any]] = field(default_factory=dict)
576
+
577
+ def to_dict(self) -> dict[str, Any]:
578
+ return {
579
+ "scanned": self.scanned,
580
+ "rescored": self.rescored,
581
+ "dropped": self.dropped,
582
+ "deduped": self.deduped,
583
+ "resummarized": self.resummarized,
584
+ "clusters": self.clusters,
585
+ "cluster_calls": self.cluster_calls,
586
+ "wiki_calls": self.wiki_calls,
587
+ "wiki_created": self.wiki_created,
588
+ "wiki_updated": self.wiki_updated,
589
+ "wiki_retired": self.wiki_retired,
590
+ "elapsed_ms": round(self.elapsed_ms, 2),
591
+ "notes": self.notes,
592
+ "stages": self.stages,
593
+ }
594
+
595
+
596
+ # ----------------------------------------------------------------------------
597
+ # Helpers
598
+ # ----------------------------------------------------------------------------
599
+
600
+
601
+ def _extract_json(text: str) -> Any | None:
602
+ """Pull the first balanced JSON object out of an LLM reply."""
603
+ import re
604
+ if not text:
605
+ return None
606
+ # direct
607
+ try:
608
+ return json.loads(text)
609
+ except Exception:
610
+ pass
611
+ # fenced ```json ... ```
612
+ m = re.search(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", text, re.S)
613
+ if m:
614
+ try:
615
+ return json.loads(m.group(1))
616
+ except Exception:
617
+ pass
618
+ # first {...} or first [...]
619
+ for opener, closer in [("{", "}"), ("[", "]")]:
620
+ i = text.find(opener)
621
+ if i < 0:
622
+ continue
623
+ depth = 0
624
+ for j in range(i, len(text)):
625
+ c = text[j]
626
+ if c == opener:
627
+ depth += 1
628
+ elif c == closer:
629
+ depth -= 1
630
+ if depth == 0:
631
+ try:
632
+ return json.loads(text[i : j + 1])
633
+ except Exception:
634
+ break
635
+ return None
636
+
637
+
638
+ def _hash_embed(text: str, dim: int = 128) -> list[float]:
639
+ """Deterministic 128-dim embedding (feature hashing). Cheap fallback so
640
+ semantic batching works even without sentence-transformers installed."""
641
+ v = [0.0] * dim
642
+ tokens = (text or "").lower().split()
643
+ if not tokens:
644
+ return v
645
+ for tok in tokens:
646
+ h = hashlib.md5(tok.encode("utf-8"), usedforsecurity=False).digest()
647
+ idx = h[0] % dim
648
+ sign = 1.0 if (h[1] & 1) else -1.0
649
+ v[idx] += sign
650
+ n = math.sqrt(sum(x * x for x in v)) or 1.0
651
+ return [x / n for x in v]
652
+
653
+
654
+ def _cos(a: list[float], b: list[float]) -> float:
655
+ if not a or not b or len(a) != len(b):
656
+ return 0.0
657
+ return sum(x * y for x, y in zip(a, b, strict=False))
658
+
659
+
660
+ # ----------------------------------------------------------------------------
661
+ # Consolidator
662
+ # ----------------------------------------------------------------------------
663
+
664
+
665
+ class EvolutionConsolidator:
666
+ """5-stage distillation pipeline. Drop-in replacement for
667
+ ``LLMConsolidator.run``."""
668
+
669
+ def __init__(
670
+ self,
671
+ store: MemoryStore,
672
+ provider: LLMClient,
673
+ config: dict[str, Any] | None = None,
674
+ ) -> None:
675
+ self.store = store
676
+ self.provider = provider
677
+ cfg = dict(config or {})
678
+ self.config = cfg
679
+ # Output locale for wiki synthesis (default ``zh`` — the same
680
+ # default the front-end uses when ``store.lang`` is unset).
681
+ # Honoured by ``wiki_system_prompt(self._lang)`` in Stage 4 and
682
+ # by the expansion retry inside ``_stage4_wiki_synthesis``.
683
+ self._lang: str = normalise_lang(cfg.get("lang"))
684
+ self._cache: dict[str, str] = {}
685
+ self._cache_ttl = 300.0
686
+ self._cache_ts: dict[str, float] = {}
687
+ self._run_id: str | None = None
688
+
689
+ # --- public ----------------------------------------------------------
690
+
691
+ def set_run_id(self, run_id: str | None) -> None:
692
+ self._run_id = run_id
693
+
694
+ def _classify_wiki_scope(
695
+ self,
696
+ *,
697
+ title: str,
698
+ body: str,
699
+ summary: str,
700
+ tags: list[str],
701
+ evidence_ids: list[str],
702
+ existing: dict | None,
703
+ ) -> tuple[str, dict]:
704
+ """Route evolution-generated pages without widening old scopes."""
705
+ from ..wiki.classifier import classify_page
706
+ from ..wiki.scope import (
707
+ auto_scope_config,
708
+ build_scope_audit,
709
+ derive_default_scope,
710
+ )
711
+ scope_cfg = auto_scope_config(self.store)
712
+ enabled = bool(scope_cfg["enabled"])
713
+ mode = str(scope_cfg["mode"])
714
+ try:
715
+ memories = self.store.list_memories(
716
+ ids=list(evidence_ids), limit=max(1, len(evidence_ids))
717
+ )
718
+ except Exception:
719
+ memories = []
720
+ evidence_sources = [
721
+ str(getattr(memory, "source", "") or "").strip()
722
+ for memory in memories
723
+ if getattr(memory, "source", None)
724
+ ]
725
+ classification = classify_page(
726
+ title=title,
727
+ body=body,
728
+ summary=summary,
729
+ tags=tags,
730
+ evidence_sources=evidence_sources,
731
+ mode=mode if enabled else "off",
732
+ )
733
+ stale_default_scope = False
734
+ if existing and existing.get("scope") and not (existing.get("evidence_ids") or []):
735
+ audit = existing.get("auto_classification") or {}
736
+ if isinstance(audit, str):
737
+ try:
738
+ audit = json.loads(audit)
739
+ except (TypeError, ValueError):
740
+ audit = {}
741
+ stale_default_scope = bool(
742
+ isinstance(audit, dict)
743
+ and audit.get("scope_decision") == "default-source"
744
+ and not audit.get("source_hint")
745
+ )
746
+ if existing and existing.get("scope") and not stale_default_scope:
747
+ scope = str(existing["scope"]).strip().lower()
748
+ decision = "preserved-existing"
749
+ elif enabled and classification.auto_global:
750
+ scope = "global"
751
+ decision = "auto-global"
752
+ else:
753
+ scope = derive_default_scope(evidence_ids=evidence_ids, store=self.store)
754
+ decision = "repaired-evidence-source" if stale_default_scope else "default-source"
755
+ return scope, build_scope_audit(
756
+ classification,
757
+ scope=scope,
758
+ decision=decision,
759
+ enabled=enabled,
760
+ mode=mode,
761
+ existing=existing,
762
+ )
763
+
764
+ def run(
765
+ self,
766
+ memories: list[StoredMemory] | None = None,
767
+ progress: Callable[[int, int], None] | None = None,
768
+ limit: int = 300,
769
+ ) -> EvolutionStats:
770
+ t0 = time.time()
771
+ stats = EvolutionStats()
772
+ cfg = self.config
773
+ dry_run = bool(cfg.get("dry_run", False))
774
+
775
+ if memories is None:
776
+ memories = self.store.list_memories(limit=limit)
777
+ memories = list(memories)
778
+ stats.scanned = len(memories)
779
+ if not memories:
780
+ stats.notes.append("no memories")
781
+ stats.elapsed_ms = (time.time() - t0) * 1000
782
+ return stats
783
+
784
+ if progress:
785
+ try:
786
+ progress(0, len(memories))
787
+ except Exception:
788
+ pass
789
+
790
+ # Stage 0: Pre-cluster cleanup. Drop pure-status noise and collapse
791
+ # near-duplicate memories so the cluster LLM sees focused input.
792
+ s0_t = time.time()
793
+ pre_count = len(memories)
794
+ memories = self._stage0_filter_noise(memories)
795
+ memories = self._stage0_dedup_memories(memories, stats)
796
+ s0 = {
797
+ "in": pre_count,
798
+ "out": len(memories),
799
+ "ms": round((time.time() - s0_t) * 1000, 1),
800
+ "note": f"noise-filtered {pre_count - stats.deduped - len(memories) + pre_count} · deduped {stats.deduped}",
801
+ "evidence_ids": [m.id for m in memories[:200]],
802
+ }
803
+ stats.stages["clean"] = s0
804
+ self._record_stage("clean", pre_count, len(memories), s0["note"], s0)
805
+ if not memories:
806
+ stats.notes.append("no memories after cleanup")
807
+ stats.elapsed_ms = (time.time() - t0) * 1000
808
+ return stats
809
+
810
+ # Stage 1: Signal-aware rescoring
811
+ s1_t = time.time()
812
+ stage1_in = len(memories)
813
+ rescored_map = self._stage1_signal_scoring(memories)
814
+ stats.rescored = sum(1 for v in rescored_map.values() if v)
815
+ # After stage 1 we run rescore_all to update the score column.
816
+ # Capture how many actually changed.
817
+ rescore_changed = 0
818
+ try:
819
+ rescore_changed = self.store.rescore_all(half_life_days=30.0)
820
+ except Exception:
821
+ pass
822
+ s1 = {
823
+ "in": stage1_in,
824
+ "out": rescore_changed or stage1_in,
825
+ "ms": round((time.time() - s1_t) * 1000, 1),
826
+ "note": f"rescored {rescore_changed}/{stage1_in} memories",
827
+ "evidence_ids": [m.id for m in memories[:200]],
828
+ }
829
+ stats.stages["score"] = s1
830
+ self._record_stage("score", stage1_in, s1["out"], s1["note"], s1)
831
+ if progress:
832
+ try:
833
+ progress(int(len(memories) * 0.2), len(memories))
834
+ except Exception:
835
+ pass
836
+
837
+ # Stage 2: Semantic batching
838
+ s2_t = time.time()
839
+ clusters = self._stage2_cluster(memories, max_per_cluster=CLUSTER_MAX)
840
+ stats.clusters = len(clusters)
841
+ s2 = {
842
+ "in": stage1_in,
843
+ "out": len(clusters),
844
+ "ms": round((time.time() - s2_t) * 1000, 1),
845
+ "note": f"formed {len(clusters)} clusters",
846
+ }
847
+ stats.stages["cluster"] = s2
848
+ self._record_stage("cluster", stage1_in, len(clusters), s2["note"], s2)
849
+ if progress:
850
+ try:
851
+ progress(int(len(memories) * 0.4), len(memories))
852
+ except Exception:
853
+ pass
854
+
855
+ # Stage 3: Per-cluster distillation
856
+ s3_t = time.time()
857
+ cluster_summaries: list[dict[str, Any]] = []
858
+ kept_ids: set = set()
859
+ dropped_ids: set = set()
860
+ is_rule = self._echo_provider()
861
+ for ci, cluster in enumerate(clusters):
862
+ if is_rule:
863
+ # No LLM -> keep everything as-is. Build a summary from
864
+ # the top important items so Stage 4 still has signal.
865
+ # We attach the cleaned, top-N items directly so Stage 4
866
+ # can build real bullets (instead of one stitched blob).
867
+ ranked = sorted(
868
+ cluster,
869
+ key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
870
+ )
871
+ top = ranked[:7]
872
+ summary_text = " / ".join((m.text or "")[:120] for m in top[:3])[:400]
873
+ kinds = [m.kind for m in cluster if m.kind]
874
+ kind = max(set(kinds), key=kinds.count) if kinds else ""
875
+ all_tags = [t for m in cluster for t in (m.tags or []) if t]
876
+ dom_tag = max(set(all_tags), key=all_tags.count) if all_tags else ""
877
+ avg_imp = sum((m.importance or 0) for m in cluster) / max(1, len(cluster))
878
+ summary = {
879
+ "text": summary_text,
880
+ "size": len(cluster),
881
+ "kept": len(cluster),
882
+ "dropped": 0,
883
+ "evidence_ids": [m.id for m in cluster][:50],
884
+ "kind": kind,
885
+ "dominating_tag": dom_tag,
886
+ "avg_importance": round(avg_imp, 3),
887
+ # Top-ranked items (cleaned) so Stage 4 can build
888
+ # real bullets, not raw concatenation.
889
+ "items": top,
890
+ }
891
+ actions = {m.id: {"keep": True, "importance": m.importance, "distill": "", "tags": list(m.tags or [])} for m in cluster}
892
+ else:
893
+ summary, actions = self._stage3_distill_cluster(cluster, cfg, stats)
894
+ cluster_summaries.append(summary)
895
+ if not dry_run:
896
+ kept = self._apply_actions(cluster, actions, stats)
897
+ kept_ids |= kept
898
+ for m in cluster:
899
+ if m.id not in kept:
900
+ dropped_ids.add(m.id)
901
+ if progress:
902
+ try:
903
+ progress(int(len(memories) * (0.4 + 0.4 * (ci + 1) / max(1, len(clusters)))), len(memories))
904
+ except Exception:
905
+ pass
906
+ s3 = {
907
+ "in": len(clusters),
908
+ "out": len([s for s in cluster_summaries if s.get("text")]),
909
+ "ms": round((time.time() - s3_t) * 1000, 1),
910
+ "note": f"{stats.cluster_calls} LLM calls · {len(clusters)} clusters · {len(kept_ids)} kept / {len(dropped_ids)} dropped",
911
+ "evidence_ids": list(kept_ids)[:200],
912
+ "kept_ids": list(kept_ids)[:200],
913
+ "dropped_ids": list(dropped_ids)[:200],
914
+ }
915
+ stats.stages["distill"] = s3
916
+ self._record_stage("distill", len(clusters), s3["out"], s3["note"], s3)
917
+
918
+ # Stage 4: Hierarchical wiki synthesis
919
+ s4_t = time.time()
920
+ wiki_pages = self._stage4_wiki_synthesis(cluster_summaries, cfg, stats)
921
+ if not dry_run:
922
+ stats.wiki_created = wiki_pages.get("created", 0)
923
+ stats.wiki_updated = wiki_pages.get("updated", 0)
924
+ # Collect evidence ids from the wiki pages so drill-down can list them
925
+ wiki_evidence: list = []
926
+ try:
927
+ for pg in self.store.list_wiki_pages(limit=50):
928
+ eids = pg.get("evidence_ids") or []
929
+ if isinstance(eids, list):
930
+ wiki_evidence.extend([str(x) for x in eids])
931
+ except Exception:
932
+ pass
933
+ s4 = {
934
+ "in": len(cluster_summaries),
935
+ "out": stats.wiki_created + stats.wiki_updated,
936
+ "ms": round((time.time() - s4_t) * 1000, 1),
937
+ "note": f"created={stats.wiki_created} updated={stats.wiki_updated}",
938
+ "evidence_ids": wiki_evidence[:200],
939
+ }
940
+ stats.stages["wiki"] = s4
941
+ self._record_stage("wiki", len(cluster_summaries), s4["out"], s4["note"], s4)
942
+
943
+ # Stage 4.5: Retire noisy wiki pages. Conservative: only touches
944
+ # pages whose title is a raw user prompt, body is glued fragments,
945
+ # or body has no bullets. Well-formed LLM-authored pages never
946
+ # match these heuristics.
947
+ if not dry_run:
948
+ try:
949
+ retired = self._stage4_cleanup_wiki(stats)
950
+ stats.wiki_retired = retired
951
+ except Exception:
952
+ pass
953
+ if progress:
954
+ try:
955
+ progress(len(memories), len(memories))
956
+ except Exception:
957
+ pass
958
+
959
+ # Stage 5: Evolution memo
960
+ s5_t = time.time()
961
+ self._stage5_memo(stats)
962
+ s5 = {
963
+ "in": stats.wiki_created + stats.wiki_updated,
964
+ "out": 1,
965
+ "ms": round((time.time() - s5_t) * 1000, 1),
966
+ "note": "evolution memo updated",
967
+ }
968
+ stats.stages["memo"] = s5
969
+ self._record_stage("memo", s5["in"], 1, s5["note"], s5)
970
+
971
+ # Rescore from new importance
972
+ if not dry_run and stats.rescored:
973
+ try:
974
+ self.store.rescore_all(half_life_days=30.0)
975
+ except Exception:
976
+ pass
977
+
978
+ stats.elapsed_ms = (time.time() - t0) * 1000
979
+ return stats
980
+
981
+ # --- Stage 1: Signal-aware scoring ----------------------------------
982
+
983
+ def _stage1_signal_scoring(
984
+ self, memories: list[StoredMemory]
985
+ ) -> dict[str, bool]:
986
+ """Blend original importance with behavioural signals. We do not
987
+ write back here — the per-cluster LLM pass is what writes the new
988
+ importance. This stage just gives the LLM richer ranking input."""
989
+ rescored: dict[str, bool] = {}
990
+ for m in memories:
991
+ sig = self.store.get_signal(m.id)
992
+ boost = min(W_RECALL, W_RECALL * sig["recall_count"] / RECALL_SATURATION)
993
+ damp = min(W_NEGATIVE, W_NEGATIVE * sig["negative"] / NEGATIVE_SATURATION)
994
+ adj = (m.importance or 0.0) + boost - damp
995
+ adj = max(0.0, min(1.0, adj))
996
+ if abs(adj - (m.importance or 0.0)) > 0.05:
997
+ rescored[m.id] = True
998
+ return rescored
999
+
1000
+ # --- Stage 2: Semantic batching -------------------------------------
1001
+
1002
+ def _stage2_cluster(
1003
+ self,
1004
+ memories: list[StoredMemory],
1005
+ max_per_cluster: int = CLUSTER_MAX,
1006
+ ) -> list[list[StoredMemory]]:
1007
+ """Greedy cosine clustering using a hashed embedding. Memories that
1008
+ lack enough text to embed fall into a 'misc' cluster of their own
1009
+ so we never lose them."""
1010
+ if not memories:
1011
+ return []
1012
+
1013
+ # Sort by adjusted importance desc so high-signal memories seed clusters
1014
+ def _score(m: StoredMemory) -> float:
1015
+ sig = self.store.get_signal(m.id)
1016
+ boost = min(0.1, 0.1 * sig["recall_count"] / RECALL_SATURATION)
1017
+ damp = min(0.15, 0.15 * sig["negative"] / NEGATIVE_SATURATION)
1018
+ return (m.importance or 0.0) + boost - damp
1019
+
1020
+ ranked = sorted(memories, key=_score, reverse=True)
1021
+
1022
+ clusters: list[dict[str, Any]] = [] # {centroid, items}
1023
+ for m in ranked:
1024
+ text = (m.text or "").strip()
1025
+ if len(text) < 8:
1026
+ # super short items go to a misc cluster at the end
1027
+ clusters.append({"centroid": None, "items": [m], "misc": True})
1028
+ continue
1029
+ emb = _hash_embed(text)
1030
+ placed = False
1031
+ for cl in clusters:
1032
+ if cl.get("misc") or len(cl["items"]) >= max_per_cluster:
1033
+ continue
1034
+ sim = _cos(emb, cl["centroid"])
1035
+ if sim >= 0.35: # hashed embeddings are noisier, lower threshold
1036
+ cl["items"].append(m)
1037
+ # update centroid (running mean)
1038
+ n = len(cl["items"])
1039
+ cl["centroid"] = [
1040
+ (cl["centroid"][i] * (n - 1) + emb[i]) / n for i in range(len(emb))
1041
+ ]
1042
+ placed = True
1043
+ break
1044
+ if not placed:
1045
+ clusters.append({"centroid": emb, "items": [m], "misc": False})
1046
+
1047
+ # Second pass: SESSION-AWARE MERGE. The user explicitly asked
1048
+ # that a single conversation not be fragmented into too many
1049
+ # knowledge pieces. After the cosine pass, we look for small
1050
+ # clusters (<= 8 items) that share session_ids with other
1051
+ # clusters and merge them so a single conversation produces
1052
+ # one wiki page, not many. We cap the merge to avoid giant
1053
+ # mixed clusters; only merge into the cluster with the highest
1054
+ # cumulative importance.
1055
+ def _session_counts(items):
1056
+ counts: dict[str, int] = {}
1057
+ for it in items:
1058
+ sid = getattr(it, "session_id", None)
1059
+ if sid:
1060
+ counts[sid] = counts.get(sid, 0) + 1
1061
+ return counts
1062
+
1063
+ # Build a list of (cluster, session_counts) for non-misc clusters
1064
+ non_misc = [c for c in clusters if not c.get("misc")]
1065
+ for c in non_misc:
1066
+ c["_sc"] = _session_counts(c["items"])
1067
+ # Repeat: find the smallest cluster, see if any other cluster
1068
+ # shares a session, and merge into the one with higher total
1069
+ # importance. Bound iterations to keep this O(N) not O(N²).
1070
+ for _ in range(20):
1071
+ merged = False
1072
+ non_misc.sort(key=lambda c: (len(c["items"]), -sum(getattr(it, "importance", 0) or 0 for it in c["items"])))
1073
+ for i, small in enumerate(non_misc):
1074
+ if len(small["items"]) >= 9:
1075
+ continue # only merge small clusters
1076
+ if not small["_sc"]:
1077
+ continue
1078
+ best_j = -1
1079
+ best_overlap = 0
1080
+ for j, big in enumerate(non_misc):
1081
+ if i == j:
1082
+ continue
1083
+ if len(big["items"]) >= max_per_cluster:
1084
+ continue
1085
+ overlap = sum(
1086
+ min(small["_sc"].get(s, 0), big["_sc"].get(s, 0))
1087
+ for s in small["_sc"]
1088
+ )
1089
+ if overlap >= 2 and overlap > best_overlap:
1090
+ best_overlap = overlap
1091
+ best_j = j
1092
+ if best_j >= 0:
1093
+ big = non_misc[best_j]
1094
+ big["items"].extend(small["items"])
1095
+ # Recompute centroid (running mean)
1096
+ n = len(big["items"])
1097
+ if big["centroid"] is not None:
1098
+ # Re-derive centroid from all members (cheap)
1099
+ embs = [_hash_embed(getattr(m, "text", "") or "") for m in big["items"]]
1100
+ dim = len(embs[0]) if embs else 128
1101
+ cent = [0.0] * dim
1102
+ for e in embs:
1103
+ for k in range(dim):
1104
+ cent[k] += e[k]
1105
+ big["centroid"] = [x / n for x in cent]
1106
+ big["_sc"] = _session_counts(big["items"])
1107
+ non_misc.remove(small)
1108
+ merged = True
1109
+ break
1110
+ if not merged:
1111
+ break
1112
+
1113
+ # Final sort: largest first, misc last
1114
+ clusters = non_misc + [c for c in clusters if c.get("misc")]
1115
+ clusters.sort(key=lambda c: (c.get("misc", False), -len(c["items"])))
1116
+ return [c["items"] for c in clusters]
1117
+
1118
+ # --- Stage 3: Per-cluster distillation ------------------------------
1119
+
1120
+ def _stage3_distill_cluster(
1121
+ self,
1122
+ cluster: list[StoredMemory],
1123
+ cfg: dict[str, Any],
1124
+ stats: EvolutionStats,
1125
+ ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
1126
+ """Return (cluster_summary, per-item actions)."""
1127
+ # Build user payload
1128
+ payload = []
1129
+ for m in cluster:
1130
+ sig = self.store.get_signal(m.id)
1131
+ payload.append({
1132
+ "id": m.id,
1133
+ "kind": m.kind,
1134
+ "tags": list(m.tags or []),
1135
+ "importance": round(m.importance or 0.0, 3),
1136
+ "recall_count": sig["recall_count"],
1137
+ "negative": sig["negative"],
1138
+ "text": (m.text or "")[:600],
1139
+ })
1140
+ user_prompt = json.dumps({"items": payload}, ensure_ascii=False)
1141
+ cache_key = hashlib.sha1(
1142
+ (user_prompt + "||" + (getattr(self.provider, "model", "?") or "?")
1143
+ + "||cluster||" + str(float(cfg.get("temperature") or 0.2))).encode()
1144
+ , usedforsecurity=False).hexdigest()
1145
+ reply = self._cached_call(cache_key, _CLUSTER_SYSTEM, user_prompt, cfg, stats, kind="cluster")
1146
+
1147
+ actions: dict[str, dict[str, Any]] = {}
1148
+ parsed = _extract_json(reply or "")
1149
+ if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
1150
+ for it in parsed["items"]:
1151
+ if isinstance(it, dict) and "id" in it:
1152
+ actions[str(it["id"])] = it
1153
+
1154
+ # Build a 1-sentence cluster summary from the LLM reply (or fall
1155
+ # back to a stitched top-3 important items).
1156
+ cluster_text = ""
1157
+ if isinstance(parsed, dict) and isinstance(parsed.get("summary"), str):
1158
+ cluster_text = parsed["summary"].strip()[:400]
1159
+ if not cluster_text:
1160
+ top = sorted(cluster, key=lambda m: -(m.importance or 0))[:3]
1161
+ cluster_text = " / ".join((m.text or "")[:120] for m in top)[:400]
1162
+
1163
+ # Pull memory ids in this cluster so downstream wiki synthesis
1164
+ # can cite them as evidence. Use the kept ones when the LLM
1165
+ # classified them, otherwise all cluster items.
1166
+ kept_set = {m_id for m_id, a in actions.items() if a.get("keep") is True}
1167
+ evidence_ids = [m.id for m in cluster if (not kept_set or m.id in kept_set)]
1168
+ # Most-common kind and tag — used by the rule-based wiki step to
1169
+ # produce a meaningful title when no LLM is involved.
1170
+ kinds = [m.kind for m in cluster if m.kind]
1171
+ kind = max(set(kinds), key=kinds.count) if kinds else ""
1172
+ all_tags = [t for m in cluster for t in (m.tags or []) if t]
1173
+ dom_tag = max(set(all_tags), key=all_tags.count) if all_tags else ""
1174
+ avg_imp = sum((m.importance or 0) for m in cluster) / max(1, len(cluster))
1175
+
1176
+ summary = {
1177
+ "text": cluster_text,
1178
+ "size": len(cluster),
1179
+ "kept": len(evidence_ids) if evidence_ids else sum(1 for a in actions.values() if a.get("keep") is True),
1180
+ "dropped": len(cluster) - (len(evidence_ids) if evidence_ids else sum(1 for a in actions.values() if a.get("keep") is True)),
1181
+ "evidence_ids": evidence_ids[:50],
1182
+ "kind": kind,
1183
+ "dominating_tag": dom_tag,
1184
+ "avg_importance": round(avg_imp, 3),
1185
+ }
1186
+ return summary, actions
1187
+
1188
+ def _apply_actions(
1189
+ self,
1190
+ cluster: list[StoredMemory],
1191
+ actions: dict[str, dict[str, Any]],
1192
+ stats: EvolutionStats,
1193
+ ) -> set:
1194
+ """Apply keep / drop / rewrite actions to the DB. Returns the set of
1195
+ kept memory ids."""
1196
+ kept: set = set()
1197
+ for m in cluster:
1198
+ act = actions.get(m.id)
1199
+ if not act:
1200
+ # no LLM action => keep as-is
1201
+ kept.add(m.id)
1202
+ continue
1203
+ if act.get("keep") is False:
1204
+ try:
1205
+ self.store.delete_memory(m.id)
1206
+ stats.dropped += 1
1207
+ except Exception:
1208
+ kept.add(m.id)
1209
+ continue
1210
+ new_text = (act.get("distill") or "").strip()
1211
+ new_importance = act.get("importance")
1212
+ new_tags = act.get("tags")
1213
+ updates: dict[str, Any] = {}
1214
+ try:
1215
+ if isinstance(new_importance, (int, float)):
1216
+ ni = max(0.0, min(1.0, float(new_importance)))
1217
+ if abs(ni - (m.importance or 0.0)) > 1e-3:
1218
+ updates["importance"] = ni
1219
+ stats.rescored += 1
1220
+ except Exception:
1221
+ pass
1222
+ if new_text and new_text != (m.text or "") and len(new_text) <= 400:
1223
+ updates["text"] = new_text
1224
+ stats.resummarized += 1
1225
+ if isinstance(new_tags, list):
1226
+ tags_clean = [str(t).strip().lower() for t in new_tags if t and str(t).strip()][:6]
1227
+ if tags_clean:
1228
+ updates["tags"] = tags_clean
1229
+ if updates:
1230
+ try:
1231
+ self.store.upsert_memory(
1232
+ id=m.id,
1233
+ kind=m.kind,
1234
+ text=updates.get("text", m.text),
1235
+ importance=updates.get("importance", m.importance),
1236
+ source=m.source,
1237
+ session_id=m.session_id,
1238
+ created_at=m.created_at,
1239
+ updated_at=time.time(),
1240
+ ttl=m.ttl,
1241
+ tags=updates.get("tags", m.tags),
1242
+ embedding=m.embedding,
1243
+ )
1244
+ except Exception:
1245
+ pass
1246
+ kept.add(m.id)
1247
+ return kept
1248
+
1249
+ # --- Stage 3.5: Wiki cleanup ---------------------------------------
1250
+
1251
+ def _stage4_cleanup_wiki(self, stats: EvolutionStats) -> int:
1252
+ """Retire noisy legacy wiki pages produced by older rule-based runs.
1253
+
1254
+ A page is considered noisy when:
1255
+ * its body contains the "/ " separator glue (old fallback signature)
1256
+ * its title is literally a truncated user prompt (>40 chars starting
1257
+ with [codex] / [claude] / User intent / Outcome: / 帮我 / 分析)
1258
+ * its importance is < 0.35 AND it has no evidence ids
1259
+ * it has zero bullets in its body (no real content)
1260
+
1261
+ Returns the number of pages retired. We never retire pages with
1262
+ importance >= 0.5 even if they look messy — the user might still
1263
+ rely on them.
1264
+ """
1265
+ try:
1266
+ pages = self.store.list_wiki_pages(limit=200)
1267
+ except Exception:
1268
+ return 0
1269
+ retired = 0
1270
+ for p in pages:
1271
+ pid = p.get("id")
1272
+ if not pid:
1273
+ continue
1274
+ title = (p.get("title") or "").strip()
1275
+ body = (p.get("body") or "")
1276
+ evidence = p.get("evidence_ids") or []
1277
+ has_bullets = body.count("\n- ") + (1 if body.startswith("- ") else 0)
1278
+ # A title that is literally a session-source prefix followed by a
1279
+ # raw user prompt is ALWAYS a "list of recent user prompts" page
1280
+ # — there is no atomic knowledge here. Bail the early-skip.
1281
+ title_prefix_prompt = title.startswith((
1282
+ "[codex]", "[claude]", "[hermes]", "[openclaw]",
1283
+ "User intent:", "Outcome:", "You are ", "Review ",
1284
+ "帮我", "分析", "请立即", "如何", "How to", "Please ",
1285
+ "请", "你可以干嘛", "User said:",
1286
+ )) and len(title) > 20
1287
+ if title_prefix_prompt:
1288
+ noisy = True
1289
+ else:
1290
+ # Glue signature: lots of "/ " separators (old fallback glued raw text)
1291
+ if body.count(" / ") >= 2:
1292
+ noisy = True
1293
+ # Title is a truncated user prompt
1294
+ if title.startswith(("[codex]", "[claude]", "[hermes]", "[openclaw]",
1295
+ "User intent:", "Outcome:", "帮我", "分析",
1296
+ "How to", "Please", "请", "你可以干嘛",
1297
+ "User said:")) and len(title) > 20:
1298
+ noisy = True
1299
+ # No bullets and no evidence
1300
+ if has_bullets == 0 and not evidence:
1301
+ noisy = True
1302
+ # Body has 0 newlines AND no bullets -> just a single glued blob
1303
+ if body.count("\n") < 2 and has_bullets == 0:
1304
+ noisy = True
1305
+ # Bail only if the page has real bullets AND evidence AND a clean title
1306
+ if has_bullets >= 2 and len(evidence) >= 2:
1307
+ continue
1308
+ if noisy:
1309
+ try:
1310
+ self.store.delete_wiki_page(pid)
1311
+ retired += 1
1312
+ stats.notes.append(f"wiki retired: {title[:40]!r}")
1313
+ except Exception:
1314
+ pass
1315
+ # Second sweep: REWRITE every existing page's bullets through
1316
+ # the new cleaner + raw-prompt filter. The previous run may have
1317
+ # produced bullets that look like raw user prompts (especially
1318
+ # legacy pages from before _looks_like_raw_prompt existed). We
1319
+ # rebuild the body from the surviving bullets, re-extract the
1320
+ # title, and re-summarize. This is the cheapest way to bring
1321
+ # legacy pages up to the new quality bar.
1322
+ for p2 in self.store.list_wiki_pages(limit=200):
1323
+ slug = (p2.get("slug") or "")
1324
+ if not slug:
1325
+ continue
1326
+ body = p2.get("body") or ""
1327
+ # Skip the auto-aggregator pages — handled below.
1328
+ if slug in ("auto-episode", "auto-fact"):
1329
+ continue
1330
+ # Collect surviving bullets after raw-prompt / low-signal filter
1331
+ bullet_lines = [b for b in body.split("\n") if b.startswith("- ")]
1332
+ surviving: list[str] = []
1333
+ seen: set[str] = set()
1334
+ for b in bullet_lines:
1335
+ txt = b[2:].strip()
1336
+ if _looks_like_raw_prompt(txt) or _is_low_signal(txt):
1337
+ continue
1338
+ # Re-run the cleaner for the new max_len (slightly longer)
1339
+ clean = _clean_noise(txt, max_len=200)
1340
+ if not clean or len(clean) < 6:
1341
+ continue
1342
+ key = clean[:60].lower()
1343
+ if key in seen:
1344
+ continue
1345
+ seen.add(key)
1346
+ surviving.append(f"- {clean}")
1347
+ if len(surviving) >= 8:
1348
+ break
1349
+ if not surviving:
1350
+ # All bullets filtered out -> retire the page.
1351
+ try:
1352
+ self.store.delete_wiki_page(p2["id"])
1353
+ retired += 1
1354
+ stats.notes.append(f"wiki retired (empty after re-clean): {slug}")
1355
+ except Exception:
1356
+ pass
1357
+ continue
1358
+ # If we have new surviving bullets, rewrite the body.
1359
+ if len(surviving) != len(bullet_lines):
1360
+ new_body = "\n".join(surviving)
1361
+ evidence = p2.get("evidence_ids") or []
1362
+ if evidence:
1363
+ new_body += f"\n\n_Source: {len(evidence)} memories (rewritten by Stage 4.6)_"
1364
+ # Re-extract title + summary from the new top bullet
1365
+ top = surviving[0].lstrip("- ")
1366
+ new_title = _title_from(top, fallback_kind="", max_len=58)
1367
+ new_summary = top[:200].rstrip(" .,;:")
1368
+ try:
1369
+ scope, auto_classification = self._classify_wiki_scope(
1370
+ title=new_title,
1371
+ body=new_body,
1372
+ summary=new_summary,
1373
+ tags=p2.get("tags", []),
1374
+ evidence_ids=evidence,
1375
+ existing=p2,
1376
+ )
1377
+ self.store.upsert_wiki_page(
1378
+ slug=slug,
1379
+ title=new_title,
1380
+ body=new_body,
1381
+ summary=new_summary,
1382
+ tags=p2.get("tags", []),
1383
+ importance=p2.get("importance", 0.5),
1384
+ evidence_ids=evidence,
1385
+ run_id=self._run_id,
1386
+ scope=scope,
1387
+ auto_classification=auto_classification,
1388
+ )
1389
+ stats.notes.append(f"wiki re-cleaned: {slug} ({len(bullet_lines)}→{len(surviving)} bullets)")
1390
+ except Exception:
1391
+ pass
1392
+ # Third sweep: rewrite auto-aggregator pages (auto-episode /
1393
+ # auto-fact) using fresh bullet bodies if their current body still
1394
+ # contains glued fragments. We never delete these — they back many
1395
+ # cross-session facts — but we DO clean their body.
1396
+ for p2 in self.store.list_wiki_pages(limit=20):
1397
+ slug = (p2.get("slug") or "")
1398
+ if slug not in ("auto-episode", "auto-fact"):
1399
+ continue
1400
+ body = p2.get("body") or ""
1401
+ # If the body has many "/ " separators or many duplicate
1402
+ # bullets, it's a candidate for a re-synthesis pass.
1403
+ glue = body.count(" / ")
1404
+ # Cheap dedupe: count repeated leading tokens.
1405
+ bullets = [b for b in body.split("\n") if b.startswith("- ")]
1406
+ seen = set()
1407
+ unique = []
1408
+ for b in bullets:
1409
+ key = b[:80].lower()
1410
+ if key in seen:
1411
+ continue
1412
+ seen.add(key)
1413
+ unique.append(b)
1414
+ if glue >= 3 or len(unique) < len(bullets) * 0.7:
1415
+ # Replace body with the deduped + cleaned bullets.
1416
+ if unique:
1417
+ new_body = "\n".join(unique[:25])
1418
+ else:
1419
+ continue
1420
+ try:
1421
+ scope, auto_classification = self._classify_wiki_scope(
1422
+ title=p2.get("title", ""),
1423
+ body=new_body,
1424
+ summary=p2.get("summary", ""),
1425
+ tags=p2.get("tags", []),
1426
+ evidence_ids=p2.get("evidence_ids", []),
1427
+ existing=p2,
1428
+ )
1429
+ self.store.upsert_wiki_page(
1430
+ slug=slug,
1431
+ title=p2.get("title", ""),
1432
+ body=new_body,
1433
+ summary=p2.get("summary", ""),
1434
+ tags=p2.get("tags", []),
1435
+ importance=p2.get("importance", 0.5),
1436
+ evidence_ids=p2.get("evidence_ids", []),
1437
+ run_id=self._run_id,
1438
+ scope=scope,
1439
+ auto_classification=auto_classification,
1440
+ )
1441
+ stats.notes.append(f"wiki re-synthesized: {slug}")
1442
+ except Exception:
1443
+ pass
1444
+ return retired
1445
+
1446
+ # --- Stage 4: Wiki synthesis ----------------------------------------
1447
+
1448
+ def _stage4_wiki_synthesis(
1449
+ self,
1450
+ cluster_summaries: list[dict[str, Any]],
1451
+ cfg: dict[str, Any],
1452
+ stats: EvolutionStats,
1453
+ ) -> dict[str, int]:
1454
+ if not cluster_summaries:
1455
+ return {"created": 0, "updated": 0, "calls": 0}
1456
+
1457
+ # Cap input
1458
+ clusters = cluster_summaries[:WIKI_INPUT_CLUSTERS]
1459
+
1460
+ # Rule-based fast path: skip the wiki LLM call (it would just echo
1461
+ # back non-JSON) and synthesize wiki pages deterministically by
1462
+ # clustering by kind. Real wiki text comes from cluster summaries.
1463
+ if self._echo_provider():
1464
+ return self._stage4_rules(clusters, stats)
1465
+
1466
+ existing = self.store.list_wiki_pages(limit=50)
1467
+ existing_payload = [
1468
+ {"slug": p.get("slug", ""), "title": p.get("title", ""),
1469
+ "summary": (p.get("summary") or "")[:200],
1470
+ "tags": list(p.get("tags") or []),
1471
+ "importance": round(p.get("importance") or 0, 2)}
1472
+ for p in existing
1473
+ ]
1474
+ # Evolution memo: last 3 runs
1475
+ memo = self.store.get_setting("evolution_memo", "") or ""
1476
+
1477
+ user_payload = {
1478
+ "profile_dimensions": list(PROFILE_DIMS),
1479
+ "evolution_memo": memo[:1500] if isinstance(memo, str) else "",
1480
+ "existing_wiki": existing_payload,
1481
+ "cluster_summaries": [
1482
+ {
1483
+ "text": cs["text"],
1484
+ "size": cs["size"],
1485
+ "evidence_ids": list(cs.get("evidence_ids") or [])[:50],
1486
+ }
1487
+ for cs in clusters
1488
+ ],
1489
+ }
1490
+ user_prompt = json.dumps(user_payload, ensure_ascii=False)
1491
+ cache_key = hashlib.sha1(
1492
+ (user_prompt + "||" + (getattr(self.provider, "model", "?") or "?")
1493
+ + "||wiki-evo||" + str(float(cfg.get("temperature") or 0.3))).encode()
1494
+ , usedforsecurity=False).hexdigest()
1495
+ wiki_system = wiki_system_prompt(self._lang)
1496
+ reply = self._cached_call(cache_key, wiki_system, user_prompt, cfg, stats, kind="wiki")
1497
+
1498
+ parsed = _extract_json(reply or "")
1499
+ if not isinstance(parsed, dict):
1500
+ # LLM unreachable or returned junk — fall back to rule-based
1501
+ # synthesis so the wiki still grows even without an LLM.
1502
+ stats.notes.append("wiki LLM reply was not valid JSON — falling back to rule-based synthesis")
1503
+ return self._stage4_rules(clusters, stats)
1504
+ pages = parsed.get("pages") or []
1505
+ if not isinstance(pages, list) or len(pages) == 0:
1506
+ # LLM produced no pages — fall back too so the user sees
1507
+ # real wiki content immediately.
1508
+ stats.notes.append("wiki LLM returned 0 pages — falling back to rule-based synthesis")
1509
+ return self._stage4_rules(clusters, stats)
1510
+
1511
+ # -----------------------------------------------------------------
1512
+ # Length-floor recovery (v3). The wiki prompts promise at least
1513
+ # ``MIN_BULLETS_PER_PAGE`` bullets per page and at least
1514
+ # ``MIN_BODY_CHARS`` characters of body. If the LLM shipped a
1515
+ # thinner page anyway (some open-source models follow lengths
1516
+ # inconsistently), we send a locale-aware expansion request and
1517
+ # patch the returned page's body in place. We retry at most
1518
+ # ``MAX_WIKI_PROMPTS_PER_PAGE - 1`` extra times so the LLM
1519
+ # budget for this Stage-4 call stays bounded.
1520
+ # -----------------------------------------------------------------
1521
+ pages = self._expand_under_floor_pages(
1522
+ pages, clusters, user_prompt, wiki_system, cfg, stats
1523
+ )
1524
+
1525
+ created = 0
1526
+ updated = 0
1527
+ recovered_evidence_pages = 0
1528
+ under_floor_slugs: list[str] = []
1529
+ for p in pages:
1530
+ if not isinstance(p, dict):
1531
+ continue
1532
+ slug = (p.get("slug") or "").strip().lower().replace(" ", "-")[:80]
1533
+ title = (p.get("title") or "").strip()
1534
+ body = (p.get("body") or "").strip()
1535
+ if not slug or not title or not body:
1536
+ continue
1537
+ if not meets_body_floor(body):
1538
+ under_floor_slugs.append(slug)
1539
+ tags = p.get("tags") or []
1540
+ if not isinstance(tags, list):
1541
+ tags = []
1542
+ tags = [str(t).strip().lower() for t in tags if t and str(t).strip()][:8]
1543
+ try:
1544
+ importance = max(0.0, min(1.0, float(p.get("importance") or 0.5)))
1545
+ except Exception:
1546
+ importance = 0.5
1547
+ supplied_evidence = p.get("evidence_ids") or []
1548
+ evidence = _recover_page_evidence(p, clusters)
1549
+ if evidence and not supplied_evidence:
1550
+ recovered_evidence_pages += 1
1551
+ summary = (p.get("summary") or "").strip()[:400]
1552
+ existing_p = self.store.get_wiki_page_by_slug(slug)
1553
+ try:
1554
+ scope, auto_classification = self._classify_wiki_scope(
1555
+ title=title,
1556
+ body=body,
1557
+ summary=summary,
1558
+ tags=tags,
1559
+ evidence_ids=evidence,
1560
+ existing=existing_p,
1561
+ )
1562
+ upserted = self.store.upsert_wiki_page(
1563
+ slug=slug, title=title, body=body, summary=summary,
1564
+ tags=tags, importance=importance, evidence_ids=evidence,
1565
+ key_facts=p.get("key_facts") or [],
1566
+ run_id=self._run_id,
1567
+ scope=scope,
1568
+ auto_classification=auto_classification,
1569
+ )
1570
+ except Exception as e:
1571
+ stats.notes.append(f"wiki upsert err: {e}")
1572
+ continue
1573
+ # Post-write hook: scan this page for contradictions
1574
+ # against the rest of the wiki. Cheap (one Jaccard per
1575
+ # candidate) and keeps the "needs review" UI list fresh
1576
+ # without requiring a manual re-scan.
1577
+ try:
1578
+ from .contradiction import detect_for_page, write_contradicting_ids
1579
+ matches = detect_for_page(self.store, upserted["id"], threshold=0.45)
1580
+ if matches:
1581
+ n = write_contradicting_ids(self.store, matches)
1582
+ if n:
1583
+ stats.notes.append(f"contradictions: {n} pages flagged")
1584
+ except Exception as e:
1585
+ stats.notes.append(f"contradiction scan skipped: {e}")
1586
+ if existing_p is None:
1587
+ created += 1
1588
+ else:
1589
+ updated += 1
1590
+ if under_floor_slugs:
1591
+ stats.notes.append(
1592
+ "wiki under floor (" + str(len(under_floor_slugs)) + "): "
1593
+ + ", ".join(under_floor_slugs[:5])
1594
+ + (" ..." if len(under_floor_slugs) > 5 else "")
1595
+ )
1596
+ if recovered_evidence_pages:
1597
+ stats.notes.append(
1598
+ f"wiki evidence recovered for {recovered_evidence_pages} page(s)"
1599
+ )
1600
+ return {"created": created, "updated": updated, "calls": 1}
1601
+
1602
+ # -----------------------------------------------------------------
1603
+ # Length-floor recovery
1604
+ # -----------------------------------------------------------------
1605
+ def _expand_under_floor_pages(
1606
+ self,
1607
+ pages: list[dict[str, Any]],
1608
+ clusters: list[dict[str, Any]],
1609
+ user_prompt: str,
1610
+ wiki_system: str,
1611
+ cfg: dict[str, Any],
1612
+ stats: EvolutionStats,
1613
+ ) -> list[dict[str, Any]]:
1614
+ """Re-prompt the LLM for any page that came back under the body
1615
+ floor. Mutates-and-returns ``pages``.
1616
+
1617
+ The first LLM call is the regular Stage-4 prompt. When this
1618
+ helper sees a page whose body clears neither the bullet count
1619
+ nor the character count, it sends a short locale-aware
1620
+ "please append more bullets, do not rewrite" follow-up and
1621
+ tries to merge the answer into the same page dict. The merge
1622
+ is bullet-only and idempotent: we keep the original bullets
1623
+ in order, then append only NEW bullets whose leading 60 chars
1624
+ are not already present.
1625
+
1626
+ We retry at most ``MAX_WIKI_PROMPTS_PER_PAGE - 1`` extra
1627
+ times so a chatty Stage-4 can't blow the LLM budget for the
1628
+ whole run.
1629
+ """
1630
+ if not pages:
1631
+ return pages
1632
+ try:
1633
+ from ._extract_json import _extract_json as _ej # type: ignore
1634
+ except Exception:
1635
+ _ej = None
1636
+ for attempt in range(MAX_WIKI_PROMPTS_PER_PAGE - 1):
1637
+ weak = []
1638
+ for p in pages:
1639
+ if not isinstance(p, dict):
1640
+ continue
1641
+ body = (p.get("body") or "").strip()
1642
+ if not meets_body_floor(body):
1643
+ weak.append(p)
1644
+ if not weak:
1645
+ return pages
1646
+ try:
1647
+ follow_up = expansion_prompt(self._lang)
1648
+ history_payload = (
1649
+ user_prompt
1650
+ + "\n\n# Pages already returned:\n"
1651
+ + json.dumps(weak, ensure_ascii=False)
1652
+ + "\n\n# Instruction:\n"
1653
+ + follow_up
1654
+ )
1655
+ cache_blob = (
1656
+ history_payload
1657
+ + "||"
1658
+ + (getattr(self.provider, "model", "?") or "?")
1659
+ + "||wiki-evo-expand||"
1660
+ + str(float(cfg.get("temperature") or 0.3))
1661
+ + "||" + self._lang
1662
+ )
1663
+ cache_key = hashlib.sha1(
1664
+ cache_blob.encode(), usedforsecurity=False
1665
+ ).hexdigest()
1666
+ reply = self._cached_call(
1667
+ cache_key, wiki_system, history_payload,
1668
+ cfg, stats, kind="wiki",
1669
+ )
1670
+ except Exception as e:
1671
+ stats.notes.append(f"wiki expansion call error: {e}")
1672
+ return pages
1673
+ extract = _ej or _extract_json
1674
+ parsed = extract(reply or "")
1675
+ if not isinstance(parsed, dict):
1676
+ return pages
1677
+ new_pages = parsed.get("pages") or []
1678
+ if not isinstance(new_pages, list) or not new_pages:
1679
+ return pages
1680
+ by_slug = {str(p.get("slug") or "").strip().lower(): p for p in pages if isinstance(p, dict)}
1681
+ for np in new_pages:
1682
+ if not isinstance(np, dict):
1683
+ continue
1684
+ slug = str(np.get("slug") or "").strip().lower()
1685
+ target = by_slug.get(slug)
1686
+ if target is None:
1687
+ continue
1688
+ target_body = (target.get("body") or "").strip()
1689
+ np_body = (np.get("body") or "").strip()
1690
+ if not np_body:
1691
+ continue
1692
+ merged = _merge_bullet_lists(target_body, np_body)
1693
+ target["body"] = merged
1694
+ # Refresh fields the LLM may have re-written
1695
+ for fld in ("title", "summary", "tags", "key_facts", "importance", "evidence_ids"):
1696
+ v = np.get(fld)
1697
+ if v:
1698
+ target[fld] = v
1699
+ stats.notes.append(
1700
+ f"wiki expansion pass {attempt + 1}/{MAX_WIKI_PROMPTS_PER_PAGE - 1}"
1701
+ )
1702
+ return pages
1703
+
1704
+ def _echo_provider(self) -> bool:
1705
+ return type(self.provider).__name__ == "RuleBasedProvider"
1706
+
1707
+ def _stage4_rules(
1708
+ self,
1709
+ clusters: list[dict[str, Any]],
1710
+ stats: EvolutionStats,
1711
+ ) -> dict[str, int]:
1712
+ """Rule-based wiki synthesis: one page per cluster.
1713
+
1714
+ Produces real, browsable wiki pages even when no LLM is
1715
+ configured (or when the configured LLM is failing). Each page
1716
+ has a real noun-phrase title, a 1-line summary, and a body of
1717
+ dynamic-length atomic bullets — never a glued-up concatenation of
1718
+ raw memory text. This is the fallback that drives the entire wiki
1719
+ when the user has not yet configured an API key.
1720
+
1721
+ Bullet count is **dynamic** (2..8) based on cluster size and
1722
+ quality. We aggressively filter out raw user prompts and pure
1723
+ status pings so the wiki stays dense and useful.
1724
+ """
1725
+ import hashlib as _h
1726
+ created = 0
1727
+ updated = 0
1728
+ for i, cs in enumerate(clusters):
1729
+ memories: list = cs.get("items") or cs.get("memories") or []
1730
+ if not memories:
1731
+ continue
1732
+ ranked = sorted(
1733
+ memories,
1734
+ key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
1735
+ )
1736
+ # Dynamic max bullets: small cluster -> few bullets, big -> more.
1737
+ n = len(ranked)
1738
+ if n <= 2:
1739
+ max_bullets = 2
1740
+ elif n <= 5:
1741
+ max_bullets = 3
1742
+ elif n <= 10:
1743
+ max_bullets = 5
1744
+ else:
1745
+ max_bullets = 7
1746
+ # Hard ceiling so a noisy session never produces a wall of bullets.
1747
+ max_bullets = min(max_bullets, 8)
1748
+ bullets: list[str] = []
1749
+ seen: set[str] = set()
1750
+ skipped_prompts = 0
1751
+ for m in ranked:
1752
+ txt = getattr(m, "text", "") or ""
1753
+ # Drop raw user prompts outright: they add zero atomic value.
1754
+ if _looks_like_raw_prompt(txt):
1755
+ skipped_prompts += 1
1756
+ continue
1757
+ bullet = _clean_noise(txt, max_len=160)
1758
+ if not bullet:
1759
+ continue
1760
+ # Even after cleaning, a memory can still be pure status —
1761
+ # be conservative and skip if it's just a low-signal ping.
1762
+ if _is_low_signal(bullet):
1763
+ continue
1764
+ # Bullet-length sanity: drop bullets that are still
1765
+ # "raw prompt in disguise" (long, imperative, no period).
1766
+ if len(bullet) > 140 and "." not in bullet[:80]:
1767
+ continue
1768
+ key = bullet[:60].lower()
1769
+ if key in seen:
1770
+ continue
1771
+ seen.add(key)
1772
+ if len(bullet) < 6:
1773
+ continue
1774
+ bullets.append(f"- {bullet}")
1775
+ if len(bullets) >= max_bullets:
1776
+ break
1777
+ if not bullets:
1778
+ continue
1779
+ kind_hint = (cs.get("kind") or cs.get("dominating_tag") or "").lower().strip()
1780
+ # Use the FIRST surviving bullet for the topic + title — never
1781
+ # the cluster summary text, which can be a raw user prompt.
1782
+ topic_src = bullets[0].lstrip("- ")
1783
+ # Strip any lingering prompt words from the title.
1784
+ topic_src = re.sub(
1785
+ r"^(帮我|请立即|请|如何|怎么|thoroughly explore\s+the\s+|please\s+)\S*",
1786
+ "",
1787
+ topic_src,
1788
+ flags=re.I,
1789
+ ).strip(" ,;:|/。")
1790
+ if not topic_src:
1791
+ topic_src = bullets[0].lstrip("- ")
1792
+ words = re.findall(r"[a-z0-9一-鿿]+", topic_src.lower())
1793
+ topic = "-".join([w for w in words if len(w) > 1][:4]) or f"cluster-{i+1}"
1794
+ slug_src = f"{kind_hint}-{topic}" if kind_hint else topic
1795
+ slug = re.sub(r"[^a-z0-9一-鿿-]+", "-", slug_src).strip("-").lower()[:60]
1796
+ if not slug:
1797
+ slug = "auto-cluster-" + _h.md5(slug_src.encode("utf-8"), usedforsecurity=False).hexdigest()[:10]
1798
+ title = _title_from(topic_src, fallback_kind=kind_hint, max_len=58)
1799
+ summary = bullets[0].lstrip("- ")[:200].rstrip(" .,;:")
1800
+ if not summary:
1801
+ continue
1802
+ evidence_ids: list[str] = []
1803
+ for m in ranked:
1804
+ mid = getattr(m, "id", None)
1805
+ if mid and str(mid) not in evidence_ids:
1806
+ evidence_ids.append(str(mid))
1807
+ if len(evidence_ids) >= 12:
1808
+ break
1809
+ body_lines = list(bullets)
1810
+ if evidence_ids:
1811
+ body_lines.append(
1812
+ f"\n_Source: {len(evidence_ids)} memories · importance-weighted top-{len(ranked)}_"
1813
+ )
1814
+ body = "\n".join(body_lines)
1815
+ tags = ["auto", "rule-based"]
1816
+ if kind_hint:
1817
+ tags.append(kind_hint)
1818
+ top_imp = [float(getattr(m, "importance", 0.0) or 0.0) for m in ranked[:5]]
1819
+ importance = max(0.35, sum(top_imp) / max(1, len(top_imp)))
1820
+ importance = round(min(1.0, importance), 2)
1821
+ existing_p = self.store.get_wiki_page_by_slug(slug)
1822
+ try:
1823
+ scope, auto_classification = self._classify_wiki_scope(
1824
+ title=title,
1825
+ body=body,
1826
+ summary=summary,
1827
+ tags=tags[:6],
1828
+ evidence_ids=evidence_ids,
1829
+ existing=existing_p,
1830
+ )
1831
+ self.store.upsert_wiki_page(
1832
+ slug=slug, title=title, body=body, summary=summary,
1833
+ tags=tags[:6], importance=importance,
1834
+ evidence_ids=evidence_ids, run_id=self._run_id,
1835
+ scope=scope,
1836
+ auto_classification=auto_classification,
1837
+ )
1838
+ except Exception:
1839
+ continue
1840
+ if existing_p is None:
1841
+ created += 1
1842
+ else:
1843
+ updated += 1
1844
+ if skipped_prompts:
1845
+ stats.notes.append(
1846
+ f"rule-wiki {slug[:30]}: skipped {skipped_prompts} raw prompt(s)"
1847
+ )
1848
+ stats.wiki_calls += 1
1849
+ return {"created": created, "updated": updated, "calls": 1}
1850
+
1851
+
1852
+ # --- Stage 0: Pre-cluster cleanup -----------------------------------
1853
+
1854
+ def _stage0_filter_noise(
1855
+ self, memories: list[StoredMemory]
1856
+ ) -> list[StoredMemory]:
1857
+ """Drop memories that carry no actionable information: short
1858
+ status pings, single-line acknowledgements, raw user prompts, etc.
1859
+ These dilute the cluster LLM and inflate the wiki page count
1860
+ without value. We keep raw user prompts in the *store* (for
1861
+ session replay and contradiction detection) but filter them from
1862
+ the distillation path."""
1863
+ kept: list[StoredMemory] = []
1864
+ for m in memories:
1865
+ text = (m.text or "").strip()
1866
+ if _is_low_signal(text):
1867
+ continue
1868
+ if len(text) < 16:
1869
+ continue
1870
+ # Hard filter: raw user prompts should not pollute the wiki.
1871
+ # We classify them as noise for the distillation pipeline.
1872
+ if _looks_like_raw_prompt(text):
1873
+ continue
1874
+ kept.append(m)
1875
+ return kept
1876
+
1877
+ def _stage0_dedup_memories(
1878
+ self,
1879
+ memories: list[StoredMemory],
1880
+ stats: EvolutionStats,
1881
+ ) -> list[StoredMemory]:
1882
+ """Collapse near-duplicate memories into one. We hash-embed each
1883
+ memory (cheap, deterministic), then group by cosine similarity
1884
+ >= 0.85 within the same kind. Comparison is against EVERY member
1885
+ of the existing group (not just the centroid), so partial matches
1886
+ chain into the right cluster. The highest-importance memory
1887
+ survives; the rest are deleted from the store and the survivor's
1888
+ importance is bumped slightly so the merged fact rises above
1889
+ the noise."""
1890
+ if not memories:
1891
+ return memories
1892
+ ranked = sorted(
1893
+ memories,
1894
+ key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
1895
+ )
1896
+ groups: list[list[StoredMemory]] = []
1897
+ group_embs: list[list[list[float]]] = []
1898
+ for m in ranked:
1899
+ text = (m.text or "").strip()
1900
+ if not text:
1901
+ continue
1902
+ emb = _hash_embed(text)
1903
+ placed = False
1904
+ for gi, grp in enumerate(groups):
1905
+ if grp[0].kind != m.kind:
1906
+ continue
1907
+ # Compare against every existing member's embedding
1908
+ for prev_emb in group_embs[gi]:
1909
+ if _cos(emb, prev_emb) >= 0.85:
1910
+ grp.append(m)
1911
+ group_embs[gi].append(emb)
1912
+ placed = True
1913
+ break
1914
+ if placed:
1915
+ break
1916
+ if not placed:
1917
+ groups.append([m])
1918
+ group_embs.append([emb])
1919
+ merged = 0
1920
+ survivors: set[str] = set()
1921
+ for grp in groups:
1922
+ if not grp:
1923
+ continue
1924
+ head = grp[0]
1925
+ survivors.add(head.id)
1926
+ if len(grp) <= 1:
1927
+ continue
1928
+ head_imp = float(getattr(head, "importance", 0.0) or 0.0)
1929
+ for dup in grp[1:]:
1930
+ head_imp = min(1.0, head_imp + 0.02)
1931
+ try:
1932
+ self.store.delete_memory(dup.id)
1933
+ merged += 1
1934
+ except Exception:
1935
+ pass
1936
+ if head_imp > float(getattr(head, "importance", 0.0) or 0.0):
1937
+ try:
1938
+ self.store.upsert_memory(
1939
+ id=head.id, kind=head.kind,
1940
+ text=head.text, importance=head_imp,
1941
+ source=head.source, session_id=head.session_id,
1942
+ created_at=head.created_at, updated_at=time.time(),
1943
+ ttl=head.ttl, tags=list(head.tags or []),
1944
+ embedding=head.embedding,
1945
+ )
1946
+ except Exception:
1947
+ pass
1948
+ stats.deduped = merged
1949
+ return [m for m in memories if m.id in survivors]
1950
+
1951
+
1952
+ # --- Stage 5: Evolution memo ----------------------------------------
1953
+
1954
+ def _stage5_memo(self, stats: EvolutionStats) -> None:
1955
+ """Persist a short memo describing what this run changed so the next
1956
+ run's wiki prompt can use it as evolution context."""
1957
+ memo = {
1958
+ "ts": time.time(),
1959
+ "rescored": stats.rescored,
1960
+ "dropped": stats.dropped,
1961
+ "resummarized": stats.resummarized,
1962
+ "clusters": stats.clusters,
1963
+ "wiki_created": stats.wiki_created,
1964
+ "wiki_updated": stats.wiki_updated,
1965
+ "notes": stats.notes[:3],
1966
+ }
1967
+ text = json.dumps(memo, ensure_ascii=False)
1968
+ try:
1969
+ self.store.set_setting("evolution_memo", text)
1970
+ except Exception:
1971
+ pass
1972
+
1973
+ # --- utilities -------------------------------------------------------
1974
+
1975
+ def _cached_call(
1976
+ self,
1977
+ cache_key: str,
1978
+ system: str,
1979
+ user_prompt: str,
1980
+ cfg: dict[str, Any],
1981
+ stats: EvolutionStats,
1982
+ *,
1983
+ kind: str = "",
1984
+ ) -> str:
1985
+ now = time.time()
1986
+ cached = self._cache.get(cache_key)
1987
+ if cached is not None and (now - self._cache_ts.get(cache_key, 0)) < self._cache_ttl:
1988
+ return cached
1989
+ history = ChatHistory(system=system, messages=[Message(role="user", content=user_prompt)])
1990
+ try:
1991
+ reply = self.provider.complete(
1992
+ history,
1993
+ temperature=float(cfg.get("temperature") or 0.3),
1994
+ max_tokens=int(cfg.get("max_output_tokens") or 4096),
1995
+ ) or ""
1996
+ except Exception as e:
1997
+ stats.notes.append(f"{kind} llm error: {type(e).__name__}: {e}")
1998
+ return ""
1999
+ self._cache[cache_key] = reply
2000
+ self._cache_ts[cache_key] = now
2001
+ if kind == "cluster":
2002
+ stats.cluster_calls += 1
2003
+ elif kind == "wiki":
2004
+ stats.wiki_calls += 1
2005
+ return reply
2006
+
2007
+ def _record_stage(
2008
+ self,
2009
+ stage: str,
2010
+ in_count: int,
2011
+ out_count: int,
2012
+ note: str,
2013
+ stats: dict[str, Any],
2014
+ ) -> None:
2015
+ try:
2016
+ rid = self.store.start_pipeline_run(stage)
2017
+ self.store.finish_pipeline_run(
2018
+ rid, in_count=in_count, out_count=out_count, note=note, stats=stats,
2019
+ )
2020
+ except Exception:
2021
+ pass