mindforge-memory 6.4.1__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 (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,263 @@
1
+ """
2
+ MindForge v5.0 召回引擎
3
+ 语义检索 + 知识图谱增强 + 上下文优化
4
+ """
5
+
6
+ import copy
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional, Any
10
+
11
+ from mindforge.core.storage import StorageEngine, MemoryEntry
12
+ from mindforge.core.indexer import IndexEngine
13
+ from mindforge.core.query import QueryEngine, MemoryChunk, RecallResult
14
+ from mindforge.core.types import Importance, MemoryLayer
15
+
16
+
17
+ @dataclass
18
+ class RecallConfig:
19
+ """召回配置"""
20
+ max_results: int = 10
21
+ min_relevance: float = 0.3
22
+ include_categories: Optional[List[str]] = None
23
+ exclude_categories: Optional[List[str]] = None
24
+ include_layers: Optional[List[MemoryLayer]] = None
25
+ min_importance: Optional[Any] = None
26
+ max_tokens: int = 4000
27
+ use_knowledge_graph: bool = True
28
+ use_reranking: bool = True
29
+ diversity_weight: float = 0.2
30
+ temporal_bias: float = 0.1
31
+
32
+
33
+ class RecallEngine:
34
+ """召回引擎"""
35
+
36
+ def __init__(self, storage: StorageEngine, index: IndexEngine,
37
+ knowledge_graph=None):
38
+ self.storage = storage
39
+ self.index = index
40
+ self.query_engine = QueryEngine(storage, index)
41
+ self.knowledge_graph = knowledge_graph
42
+
43
+ def recall(self,
44
+ query: str,
45
+ agent_id: str = "",
46
+ session_id: str = "",
47
+ config: Optional[RecallConfig] = None) -> RecallResult:
48
+ """召回记忆"""
49
+ start = time.time()
50
+ config = config or RecallConfig()
51
+
52
+ base_results = self.query_engine.search(
53
+ query=query,
54
+ max_results=config.max_results * 3,
55
+ min_relevance=config.min_relevance * 0.5,
56
+ categories=config.include_categories,
57
+ layers=config.include_layers,
58
+ agent_id=agent_id,
59
+ session_id=session_id,
60
+ )
61
+
62
+ chunks = base_results.chunks
63
+
64
+ if config.use_knowledge_graph and self.knowledge_graph:
65
+ kg_chunks = self._kg_enhanced_search(query, config)
66
+ chunks = self._merge_results(chunks, kg_chunks)
67
+
68
+ if config.exclude_categories or config.min_importance is not None:
69
+ chunks = self._apply_config_filters(chunks, config)
70
+
71
+ if config.use_reranking:
72
+ chunks = self._rerank(chunks, query, config)
73
+
74
+ chunks = chunks[:config.max_results]
75
+
76
+ token_estimate = sum(len(c.content) for c in chunks) // 4
77
+ if config.max_tokens > 0 and token_estimate > config.max_tokens:
78
+ chunks = self._optimize_context_window(chunks, config.max_tokens)
79
+
80
+ elapsed = (time.time() - start) * 1000
81
+ layers_used = list(set(c.layer.value for c in chunks))
82
+
83
+ return RecallResult(
84
+ chunks=chunks,
85
+ total_found=len(chunks),
86
+ query_time_ms=round(elapsed, 2),
87
+ strategy_used="hybrid_semantic_kg",
88
+ token_estimate=sum(len(c.content) for c in chunks) // 4,
89
+ layers_used=layers_used,
90
+ )
91
+
92
+ def _apply_config_filters(self, chunks: List[MemoryChunk],
93
+ config: RecallConfig) -> List[MemoryChunk]:
94
+ """应用 RecallConfig 中声明的排除分类和最低重要度。"""
95
+ minimum = config.min_importance
96
+ if minimum is not None:
97
+ if isinstance(minimum, str):
98
+ try:
99
+ minimum = Importance[minimum.strip().upper()]
100
+ except KeyError as exc:
101
+ raise ValueError(
102
+ "min_importance must be LOW/MEDIUM/HIGH/CRITICAL"
103
+ ) from exc
104
+ elif not isinstance(minimum, Importance):
105
+ if (isinstance(minimum, int) and not isinstance(minimum, bool)
106
+ and 0 <= minimum <= 3):
107
+ minimum = minimum
108
+ else:
109
+ raise ValueError(
110
+ "min_importance must be an Importance, valid name, or rank 0-3"
111
+ )
112
+
113
+ kept = []
114
+ excluded = set(config.exclude_categories or [])
115
+ for chunk in chunks:
116
+ entry = self.storage.get_memory(chunk.memory_id)
117
+ if entry is None or entry.category in excluded:
118
+ continue
119
+ if minimum is not None:
120
+ rank = minimum if isinstance(minimum, int) else minimum.to_int()
121
+ if entry.importance.to_int() < rank:
122
+ continue
123
+ kept.append(chunk)
124
+ return kept
125
+
126
+ def _kg_enhanced_search(self, query: str, config: RecallConfig) -> List[MemoryChunk]:
127
+ """知识图谱增强搜索"""
128
+ chunks = []
129
+ entities = self.knowledge_graph.extract_entities(query)
130
+
131
+ for entity_name, _ in entities:
132
+ related = self.knowledge_graph.get_related_entities(
133
+ entity_name, depth=1, max_results=10
134
+ )
135
+
136
+ for related_name, rel_type, weight in related:
137
+ expanded_query = f"{query} {related_name}"
138
+ result = self.query_engine.search(
139
+ query=expanded_query,
140
+ max_results=3,
141
+ min_relevance=config.min_relevance,
142
+ categories=config.include_categories,
143
+ )
144
+ for chunk in result.chunks:
145
+ chunk.relevance_score *= weight * 0.8
146
+ chunks.append(chunk)
147
+
148
+ return chunks
149
+
150
+ def _merge_results(self, base: List[MemoryChunk],
151
+ enhanced: List[MemoryChunk]) -> List[MemoryChunk]:
152
+ """合并搜索结果"""
153
+ seen = set()
154
+ merged = []
155
+
156
+ for chunk in base:
157
+ if chunk.memory_id not in seen:
158
+ seen.add(chunk.memory_id)
159
+ merged.append(chunk)
160
+
161
+ for chunk in enhanced:
162
+ if chunk.memory_id not in seen:
163
+ seen.add(chunk.memory_id)
164
+ merged.append(chunk)
165
+ else:
166
+ for existing in merged:
167
+ if existing.memory_id == chunk.memory_id:
168
+ existing.relevance_score = max(
169
+ existing.relevance_score, chunk.relevance_score
170
+ )
171
+ break
172
+
173
+ merged.sort(key=lambda x: x.relevance_score, reverse=True)
174
+ return merged
175
+
176
+ def _rerank(self, chunks: List[MemoryChunk], query: str,
177
+ config: RecallConfig) -> List[MemoryChunk]:
178
+ """重排序"""
179
+ if not chunks:
180
+ return chunks
181
+
182
+ max_score = max(c.relevance_score for c in chunks) if chunks else 1.0
183
+
184
+ # v5.4.7 修复 M-6:批量获取记忆,避免 N+1 查询
185
+ memory_ids = [c.memory_id for c in chunks]
186
+ entries_map = {}
187
+ for mid in memory_ids:
188
+ entry = self.storage.get_memory(mid)
189
+ if entry:
190
+ entries_map[mid] = entry
191
+
192
+ for chunk in chunks:
193
+ score = chunk.relevance_score / max_score if max_score > 0 else 0
194
+
195
+ entry = entries_map.get(chunk.memory_id)
196
+ if entry:
197
+ recency = 1.0
198
+ if entry.last_accessed_at:
199
+ import time as _time
200
+ elapsed_hours = (_time.time() - entry.last_accessed_at) / 3600
201
+ recency = 1.0 / (1.0 + elapsed_hours * 0.01)
202
+
203
+ importance_bonus = entry.importance.to_int() / 3.0 * 0.2
204
+
205
+ final_score = (
206
+ score * 0.6
207
+ + recency * config.temporal_bias
208
+ + importance_bonus
209
+ )
210
+
211
+ chunk.relevance_score = final_score
212
+
213
+ chunks.sort(key=lambda x: x.relevance_score, reverse=True)
214
+ return chunks
215
+
216
+ def _optimize_context_window(self, chunks: List[MemoryChunk],
217
+ max_tokens: int) -> List[MemoryChunk]:
218
+ """优化上下文窗口"""
219
+ selected = []
220
+ total_tokens = 0
221
+
222
+ for chunk in chunks:
223
+ chunk_tokens = len(chunk.content) // 4
224
+ if total_tokens + chunk_tokens <= max_tokens:
225
+ selected.append(chunk)
226
+ total_tokens += chunk_tokens
227
+ else:
228
+ remaining = max_tokens - total_tokens
229
+ if remaining > 50:
230
+ truncated = copy.copy(chunk)
231
+ truncated.content = chunk.content[:remaining * 4]
232
+ selected.append(truncated)
233
+ break
234
+
235
+ return selected
236
+
237
+ def get_session_relevant(self, session_id: str,
238
+ current_query: str,
239
+ limit: int = 10) -> List[MemoryEntry]:
240
+ """获取会话相关记忆"""
241
+ session_memories = self.query_engine.get_session_context(session_id, limit * 2)
242
+
243
+ result = self.recall(
244
+ query=current_query,
245
+ session_id=session_id,
246
+ config=RecallConfig(max_results=limit),
247
+ )
248
+
249
+ entries = []
250
+ seen_ids = set()
251
+ for c in result.chunks:
252
+ if c.memory_id not in seen_ids:
253
+ entry = self.storage.get_memory(c.memory_id)
254
+ if entry and entry.source_session in ("", session_id):
255
+ entries.append(entry)
256
+ seen_ids.add(c.memory_id)
257
+ for entry in session_memories:
258
+ if entry.id not in seen_ids:
259
+ entries.append(entry)
260
+ seen_ids.add(entry.id)
261
+ if len(entries) >= limit:
262
+ break
263
+ return entries[:limit]
@@ -0,0 +1,305 @@
1
+ """
2
+ MindForge 会话焦点增强引擎
3
+ ================================
4
+ 输入:一个会话消息序列(每条 {id, role, content, timestamp})
5
+ 输出:
6
+ - 按时间滚动的主题簇(Topic Cluster)
7
+ - 会话焦点摘要(当前 top-K 主题 + 代表词)
8
+ - 焦点漂移检测(与上一窗口相比主题变化率)
9
+ - 焦点增强查询(把当前主题关键词拼接到原始搜索 query,提升召回)
10
+
11
+ 实现:纯文本特征,滑动窗口 + token / 2-gram 并集频率做简单 k-means(k 自动)
12
+ 无外部依赖。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ import time
19
+ from collections import Counter
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # 停用词(中英常见功能词 / 代词)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ STOPWORDS = set("""
29
+ 的 了 和 是 就 都 而 及 与 或 一个 一些 这 那 这个 那个 这些 那些 我 你 他 她 它
30
+ 我们 你们 他们 自己 什么 怎么 如何 为什么 可以 已经 将 要 会 能 有 在 于 对 从
31
+ 到 给 把 被 让 向 比 又 还 只 也 很 更 最 不 没 没有 不是 吧 吗 呢 啊 哦 嗯
32
+ the a an and or but if then of in on at to for with from by is are was were be been
33
+ being have has had do does did i you he she it we they them my your our their this that
34
+ these those what which who how why when where so not no just only also can will would should
35
+ could may might about into out up down over under again further then once here there all any
36
+ each few more most other some such than too very
37
+ """.split())
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # 数据类
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @dataclass
45
+ class TopicCluster:
46
+ cluster_id: int
47
+ keywords: List[Tuple[str, float]] # [(词, 权重)]
48
+ message_ids: List[str] = field(default_factory=list)
49
+ representative: str = "" # 代表消息的正文(最长、关键词密度最高)
50
+ size: int = 0
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ return {
54
+ "cluster_id": self.cluster_id,
55
+ "size": self.size,
56
+ "top_keywords": self.keywords[:12],
57
+ "message_ids": self.message_ids[:200],
58
+ "representative": self.representative[:160],
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class FocusSummary:
64
+ window_start_ts: float
65
+ window_end_ts: float
66
+ clusters: List[TopicCluster] = field(default_factory=list)
67
+ drift_score: float = 0.0 # 0.0 ~ 1.0
68
+ drift_indicators: List[str] = field(default_factory=list)
69
+ focus_keywords: List[Tuple[str, float]] = field(default_factory=list)
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ return {
73
+ "window": {
74
+ "start": self.window_start_ts,
75
+ "end": self.window_end_ts,
76
+ "duration": round(self.window_end_ts - self.window_start_ts, 2),
77
+ },
78
+ "clusters": [c.to_dict() for c in self.clusters],
79
+ "drift_score": round(self.drift_score, 3),
80
+ "drift_indicators": self.drift_indicators,
81
+ "focus_keywords": self.focus_keywords[:20],
82
+ }
83
+
84
+ def enhance_query(self, query: str, top_k: int = 5,
85
+ boost_mode: str = "append") -> str:
86
+ """把焦点关键词拼接到查询。boost_mode: append / replace_empty / weighted_string"""
87
+ if not query:
88
+ return " ".join(w for w, _ in self.focus_keywords[:top_k])
89
+ kws = [w for w, s in self.focus_keywords[:top_k] if w and w not in query.lower()]
90
+ if not kws:
91
+ return query
92
+ if boost_mode == "replace_empty":
93
+ return query
94
+ # 默认 append:保留原 query 并在后面加「关键词」
95
+ extra = " ".join(kws)
96
+ return f"{query} {extra}"
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # 引擎
101
+ # ---------------------------------------------------------------------------
102
+
103
+ class SessionFocus:
104
+ """无外部依赖的会话焦点聚类 + 漂移检测 + 查询增强"""
105
+
106
+ def __init__(self,
107
+ max_messages_per_window: int = 50,
108
+ min_cluster_size: int = 2,
109
+ max_clusters: int = 8,
110
+ similarity_threshold: float = 0.25,
111
+ top_n_keywords: int = 10):
112
+ self.max_w = max_messages_per_window
113
+ self.min_cluster = min_cluster_size
114
+ self.max_clusters = max_clusters
115
+ self.sim_thresh = similarity_threshold
116
+ self.top_n = top_n_keywords
117
+ self._prev_keywords: Counter = Counter()
118
+
119
+ # -- feature extraction ------------------------------------------------
120
+
121
+ @staticmethod
122
+ def _text(msg: Dict[str, Any]) -> str:
123
+ role = str(msg.get("role", "user"))
124
+ content = str(msg.get("content", ""))
125
+ return f"{role}\n{content}"
126
+
127
+ @classmethod
128
+ def _tokens(cls, text: str) -> List[str]:
129
+ cleaned = re.sub(r"[\W_]+", " ", text.lower()).split()
130
+ bag: List[str] = []
131
+ for t in cleaned:
132
+ if not t or t in STOPWORDS or len(t) <= 1:
133
+ continue
134
+ bag.append(t)
135
+ # 中文 2-gram(字符级)
136
+ cn_seg = "".join(cleaned)
137
+ if re.search(r"[\u4e00-\u9fa5]", cn_seg):
138
+ for n in (2, 3):
139
+ for i in range(len(cn_seg) - n + 1):
140
+ chunk = cn_seg[i:i + n]
141
+ if re.search(r"[\u4e00-\u9fa5]", chunk) and chunk not in STOPWORDS:
142
+ bag.append(chunk)
143
+ return bag
144
+
145
+ @classmethod
146
+ def _tfidf_features(cls, texts: Sequence[str]) -> Tuple[List[Counter], Counter]:
147
+ tfs: List[Counter] = []
148
+ df: Counter = Counter()
149
+ for txt in texts:
150
+ tokens = cls._tokens(txt)
151
+ tf = Counter(tokens)
152
+ tfs.append(tf)
153
+ for w in set(tf):
154
+ df[w] += 1
155
+ return tfs, df
156
+
157
+ @staticmethod
158
+ def _cosine(a: Counter, b: Counter) -> float:
159
+ if not a or not b:
160
+ return 0.0
161
+ shared = set(a) & set(b)
162
+ dot = sum(a[w] * b[w] for w in shared)
163
+ if dot == 0:
164
+ return 0.0
165
+ na = math.sqrt(sum(v * v for v in a.values()))
166
+ nb = math.sqrt(sum(v * v for v in b.values()))
167
+ return dot / max(1e-9, na * nb)
168
+
169
+ # -- simple greedy clustering -----------------------------------------
170
+
171
+ def _cluster(self, tfs: Sequence[Counter],
172
+ ids: Sequence[str], texts: Sequence[str]) -> List[TopicCluster]:
173
+ n = len(tfs)
174
+ centers: List[int] = [] # 中心消息的索引
175
+ center_to_cluster: Dict[int, int] = {}
176
+ assignments = [-1] * n
177
+
178
+ for i in range(n):
179
+ best_idx, best_sim = -1, 0.0
180
+ for c_idx, cent in enumerate(centers):
181
+ sim = self._cosine(tfs[i], tfs[cent])
182
+ if sim > best_sim:
183
+ best_sim, best_idx = sim, c_idx
184
+ if best_idx >= 0 and best_sim >= self.sim_thresh:
185
+ assignments[i] = best_idx
186
+ elif len(centers) < self.max_clusters:
187
+ assignments[i] = len(centers)
188
+ center_to_cluster[i] = assignments[i]
189
+ centers.append(i)
190
+ else:
191
+ # 兜底归给最相似的
192
+ assignments[i] = best_idx if best_idx >= 0 else 0
193
+
194
+ # 构建簇
195
+ clusters_by_id: Dict[int, TopicCluster] = {}
196
+ for i, cid in enumerate(assignments):
197
+ if cid < 0:
198
+ continue
199
+ cl = clusters_by_id.setdefault(cid, TopicCluster(
200
+ cluster_id=cid, keywords=[], message_ids=[], representative="", size=0,
201
+ ))
202
+ cl.message_ids.append(ids[i])
203
+ cl.size += 1
204
+ if len(texts[i]) > len(cl.representative):
205
+ cl.representative = texts[i]
206
+
207
+ # 计算每个簇的关键词(簇内tf-idf之和)
208
+ _, df = self._tfidf_features(texts)
209
+ N = max(1, len(texts))
210
+ clusters: List[TopicCluster] = []
211
+ for cid, cl in clusters_by_id.items():
212
+ if cl.size < self.min_cluster:
213
+ continue
214
+ cluster_tf: Counter = Counter()
215
+ for i in range(n):
216
+ if assignments[i] == cid:
217
+ cluster_tf.update(tfs[i])
218
+ scored = []
219
+ for w, f in cluster_tf.items():
220
+ idf = math.log((1 + N) / (1 + df.get(w, 0)) + 1.0)
221
+ scored.append((w, float(f) * idf))
222
+ scored.sort(key=lambda x: -x[1])
223
+ cl.keywords = scored[: self.top_n]
224
+ clusters.append(cl)
225
+ clusters.sort(key=lambda c: -c.size)
226
+ return clusters
227
+
228
+ # -- drift detection ---------------------------------------------------
229
+
230
+ def _detect_drift(self, current_keywords: Counter,
231
+ prev_keywords: Counter) -> Tuple[float, List[str]]:
232
+ if not prev_keywords or not current_keywords:
233
+ return 0.0, []
234
+ top_curr = set(w for w, _ in current_keywords.most_common(15))
235
+ top_prev = set(w for w, _ in prev_keywords.most_common(15))
236
+ new_keys = top_curr - top_prev
237
+ gone_keys = top_prev - top_curr
238
+ shared = top_curr & top_prev
239
+ union = top_curr | top_prev
240
+ ratio = 1.0 - (len(shared) / max(1.0, len(union)))
241
+ indicators: List[str] = []
242
+ if new_keys:
243
+ indicators.append("新增主题词: " + ", ".join(sorted(new_keys)[:6]))
244
+ if gone_keys:
245
+ indicators.append("淡出主题词: " + ", ".join(sorted(gone_keys)[:6]))
246
+ return ratio, indicators
247
+
248
+ # -- main API ----------------------------------------------------------
249
+
250
+ def summarize(self, messages: Sequence[Dict[str, Any]],
251
+ window_size: Optional[int] = None) -> FocusSummary:
252
+ window_size = window_size or self.max_w
253
+ msgs = list(messages)[-window_size:]
254
+ if not msgs:
255
+ return FocusSummary(
256
+ window_start_ts=0.0, window_end_ts=0.0,
257
+ clusters=[], drift_score=0.0, drift_indicators=[], focus_keywords=[],
258
+ )
259
+ texts = [self._text(m) for m in msgs]
260
+ ids = [str(m.get("id", i)) for i, m in enumerate(msgs)]
261
+
262
+ def _ts(m: Dict[str, Any]) -> float:
263
+ t = m.get("timestamp") or m.get("ts") or m.get("time")
264
+ if isinstance(t, (int, float)):
265
+ return float(t)
266
+ return 0.0
267
+
268
+ start_ts = min((_ts(m) for m in msgs), default=0.0)
269
+ end_ts = max((_ts(m) for m in msgs), default=time.time())
270
+ tfs, _ = self._tfidf_features(texts)
271
+ clusters = self._cluster(tfs, ids, texts)
272
+
273
+ # 当前窗口焦点关键词(tf-idf)
274
+ global_tf = Counter()
275
+ for tf in tfs:
276
+ global_tf.update(tf)
277
+ _, df_all = self._tfidf_features(texts)
278
+ N = len(texts)
279
+ scored_all = []
280
+ for w, f in global_tf.items():
281
+ idf = math.log((1 + N) / (1 + df_all.get(w, 0)) + 1.0)
282
+ scored_all.append((w, float(f) * idf))
283
+ scored_all.sort(key=lambda x: -x[1])
284
+ focus_keywords = scored_all[: self.top_n]
285
+
286
+ drift, indicators = self._detect_drift(global_tf, self._prev_keywords)
287
+ summary = FocusSummary(
288
+ window_start_ts=start_ts,
289
+ window_end_ts=end_ts,
290
+ clusters=clusters,
291
+ drift_score=drift,
292
+ drift_indicators=indicators,
293
+ focus_keywords=focus_keywords,
294
+ )
295
+ # 保存供下次 drift 比较
296
+ self._prev_keywords = global_tf
297
+ return summary
298
+
299
+ # -- streaming helpers -------------------------------------------------
300
+
301
+ def reset(self) -> None:
302
+ self._prev_keywords = Counter()
303
+
304
+
305
+ __all__ = ["SessionFocus", "TopicCluster", "FocusSummary"]