trace-memory 1.0.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.
trace_memory/ctree.py ADDED
@@ -0,0 +1,1187 @@
1
+ """
2
+ CTree — Hierarchical B+Tree Conversation Memory
3
+ ================================================
4
+ The core data structure of TRACE. Organises every LLM conversation
5
+ exchange into a tree of named topic branches, with:
6
+
7
+ • Automatic LLM-driven topic detection and branch assignment.
8
+ • Lazy summary generation for frozen (inactive) branches.
9
+ • Vector-DB integration for surgical multi-path retrieval.
10
+ • A four-rule-guarded self-healing reorganization pass.
11
+ • Soft archival of trivial leaf messages.
12
+ • Full JSON serialisation / deserialisation.
13
+
14
+ Typical usage
15
+ -------------
16
+ from trace import CTree, VectorDatabase, PromptSynthesizer
17
+
18
+ # 1. Boot the engine
19
+ tree = CTree(api_key="sk-...", model="gpt-4o-mini")
20
+ vdb = VectorDatabase("my_session.db")
21
+ tree.vdb = vdb
22
+
23
+ # 2. Wire up the embed function (any callable: text -> List[float])
24
+ tree.embed_fn = my_embed_function
25
+
26
+ # 3. Add exchanges after getting a response from your LLM
27
+ tree.add([
28
+ {"role": "user", "content": "Tell me about black holes."},
29
+ {"role": "assistant", "content": "Black holes are regions ..."},
30
+ ])
31
+
32
+ # 4. Build the enriched system prompt for the next turn
33
+ synth = PromptSynthesizer(tree, vdb)
34
+ prompt = synth.synthesize_prompt(
35
+ user_query = "How do Hawking radiation work?",
36
+ query_vector = my_embed_function("How do Hawking radiation work?"),
37
+ active_node = tree.current_node,
38
+ recent_messages= tree.conversation[-6:],
39
+ )
40
+
41
+ # 5. Persist
42
+ tree.save("my_session.json", save_conversation=True)
43
+
44
+ # 6. Reload later
45
+ tree = CTree.load("my_session.json", api_key="sk-...")
46
+ """
47
+
48
+ import json
49
+ import uuid
50
+ import time
51
+ import os
52
+
53
+ from typing import List, Dict, Optional, Tuple, Any, Callable
54
+ from dataclasses import dataclass, field
55
+
56
+ from ._llm_utils import ChatGPT_API, extract_json
57
+
58
+ # ── Helpers ───────────────────────────────────────────────────────────────────
59
+
60
+ _fallback_embedder = None
61
+ def _default_embed_fn(text: str) -> List[float]:
62
+ global _fallback_embedder
63
+ try:
64
+ from sentence_transformers import SentenceTransformer
65
+ except ImportError:
66
+ raise RuntimeError("Embedding function not provided, and 'sentence-transformers' is not installed for fallback. Please install it (e.g., pip install sentence-transformers).")
67
+
68
+ if _fallback_embedder is None:
69
+ import sys
70
+ print("\n[TRACE] Loading default local embedding model (BAAI/bge-base-en-v1.5)...", file=sys.stderr)
71
+ _fallback_embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
72
+
73
+ return _fallback_embedder.encode(text).tolist()
74
+
75
+
76
+ def _safe_int(value, fallback):
77
+ if value is None:
78
+ return fallback
79
+ if isinstance(value, str) and (value.strip().upper() == "N/A" or not value.strip()):
80
+ return fallback
81
+ try:
82
+ return int(value)
83
+ except (TypeError, ValueError):
84
+ return fallback
85
+
86
+
87
+ # ── Node base ─────────────────────────────────────────────────────────────────
88
+
89
+ class Node:
90
+ """
91
+ Abstract base for all tree nodes.
92
+
93
+ Attributes
94
+ ----------
95
+ children : Direct child nodes.
96
+ parent : Parent node (None for root).
97
+ sub_node_count : Cached count of direct children.
98
+ created_at : Unix timestamp of node birth (set once, never changed).
99
+ """
100
+
101
+ def __init__(self, children=None, parent=None, sub_node_count=0):
102
+ self.children: List["Node"] = children or []
103
+ self.parent: Optional["Node"] = parent
104
+ self.sub_node_count: int = sub_node_count
105
+ self.created_at: float = time.time()
106
+
107
+ def is_leaf(self) -> bool:
108
+ return len(self.children) == 0
109
+
110
+ def to_dict(self) -> dict:
111
+ raise NotImplementedError("Subclasses must implement to_dict()")
112
+
113
+
114
+ # ── MessageNode ───────────────────────────────────────────────────────────────
115
+
116
+ class MessageNode(Node):
117
+ """
118
+ A leaf node holding one user/assistant exchange (optionally a system
119
+ message that preceded it).
120
+
121
+ Attributes
122
+ ----------
123
+ user_message : {"role": "user", "content": "..."}
124
+ assistant_message : {"role": "assistant", "content": "..."}
125
+ system_message : {"role": "system", "content": "..."} or None
126
+ message_index : Index of the user message inside ``CTree.conversation``.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ user_message=None,
132
+ assistant_message=None,
133
+ system_message=None,
134
+ message_index: int = 0,
135
+ parent=None,
136
+ ):
137
+ super().__init__(parent=parent)
138
+ self.user_message: dict = user_message or {}
139
+ self.assistant_message: dict = assistant_message or {}
140
+ self.system_message: Optional[dict] = system_message
141
+ self.message_index: int = message_index
142
+
143
+ def to_dict(self) -> dict:
144
+ result = {
145
+ "type": "message",
146
+ "message_index": self.message_index,
147
+ "user": self.user_message.get("content", "")[:200],
148
+ "assistant": self.assistant_message.get("content", "")[:200],
149
+ "sub_node_count": self.sub_node_count,
150
+ }
151
+ if self.system_message is not None:
152
+ result["system"] = self.system_message.get("content", "")[:200]
153
+ return result
154
+
155
+ def __repr__(self):
156
+ u = self.user_message.get("content", "")[:40]
157
+ a = self.assistant_message.get("content", "")[:40]
158
+ return f"MessageNode([{self.message_index}]: U:{u!r}… A:{a!r}…)"
159
+
160
+
161
+ # ── TopicNode ─────────────────────────────────────────────────────────────────
162
+
163
+ class TopicNode(Node):
164
+ """
165
+ An internal branch node representing a coherent conversation topic.
166
+
167
+ Attributes
168
+ ----------
169
+ topic_name : Short human-readable label (e.g. "Hawking Radiation").
170
+ summary : LLM-generated 1–2 sentence summary (filled lazily on freeze).
171
+ start_index : First index in ``CTree.conversation`` that belongs to this topic.
172
+ end_index : Exclusive last index.
173
+ node_id : Stable UUID — generated once, persisted across save/load cycles.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ topic_name: str = "",
179
+ summary: str = "",
180
+ start_index: int = 0,
181
+ end_index: int = 0,
182
+ parent=None,
183
+ ):
184
+ super().__init__(parent=parent)
185
+ self.topic_name: str = topic_name
186
+ self.summary: str = summary
187
+ self.start_index: int = start_index
188
+ self.end_index: int = end_index
189
+ self.node_id: str = str(uuid.uuid4())
190
+
191
+ def get_message_count(self) -> int:
192
+ if self.is_leaf():
193
+ return self.end_index - self.start_index
194
+ count = 0
195
+ for child in self.children:
196
+ if isinstance(child, MessageNode):
197
+ count += 1
198
+ elif isinstance(child, TopicNode):
199
+ count += child.get_message_count()
200
+ return count
201
+
202
+ def to_dict(self) -> dict:
203
+ return {
204
+ "type": "topic",
205
+ "node_id": self.node_id,
206
+ "topic_name": self.topic_name,
207
+ "summary": self.summary,
208
+ "start_index": self.start_index,
209
+ "end_index": self.end_index,
210
+ "sub_node_count": self.sub_node_count,
211
+ "created_at": self.created_at,
212
+ "children": [c.to_dict() for c in self.children],
213
+ }
214
+
215
+ def __repr__(self):
216
+ return (
217
+ f"TopicNode({self.topic_name!r}, "
218
+ f"[{self.start_index}:{self.end_index}], "
219
+ f"{self.sub_node_count} children)"
220
+ )
221
+
222
+
223
+ # ── CTree ─────────────────────────────────────────────────────────────────────
224
+
225
+ class CTree:
226
+ """
227
+ The TRACE hierarchical conversation memory tree.
228
+
229
+ Parameters
230
+ ----------
231
+ max_children : Max child nodes before a node is split (default 5).
232
+ api_key : LLM API key. Falls back to OPENAI_API_KEY env var.
233
+ model : LLM model ID used for topic detection, summarisation, etc.
234
+ auto_save_path : If set, the tree auto-saves to this path after every
235
+ ``add()`` call.
236
+
237
+ Injectable dependencies
238
+ -----------------------
239
+ tree.vdb : A ``VectorDatabase`` instance for embedding-backed recall.
240
+ tree.embed_fn : Callable[str, List[float]] — your embedding function.
241
+
242
+ Example
243
+ -------
244
+ import openai
245
+
246
+ client = openai.OpenAI(api_key="sk-...", base_url="http://...")
247
+
248
+ def embed(text):
249
+ r = client.embeddings.create(input=[text], model="nomic-embed-text")
250
+ return r.data[0].embedding
251
+
252
+ tree = CTree(api_key="sk-...", model="gpt-4o-mini")
253
+ tree.embed_fn = embed
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ max_children: int = 5,
259
+ api_key: str = None,
260
+ model: str = "gpt-4o-mini",
261
+ auto_save_path: str = None,
262
+ embed_fn: Optional[Callable[[str], List[float]]] = None,
263
+ ):
264
+ self.max_children = max_children
265
+ self.model = model
266
+ self.conversation: List[dict] = []
267
+ self.auto_save_path: Optional[str] = auto_save_path
268
+ self._archived_nodes: List[MessageNode] = []
269
+ self.vdb = None # inject: VectorDatabase instance
270
+ self.embed_fn = embed_fn or _default_embed_fn # inject: callable(text) -> List[float]
271
+
272
+ # API key resolution
273
+ if api_key:
274
+ self.api_key = api_key
275
+ elif os.getenv("OPENAI_API_KEY"):
276
+ self.api_key = os.getenv("OPENAI_API_KEY")
277
+ else:
278
+ raise ValueError(
279
+ "API key must be provided via api_key= or the "
280
+ "OPENAI_API_KEY environment variable."
281
+ )
282
+
283
+ self.root: TopicNode = TopicNode(
284
+ topic_name="ROOT",
285
+ summary="Virtual root node of the conversation tree",
286
+ start_index=0,
287
+ end_index=0,
288
+ )
289
+ self.current_node: TopicNode = self.root
290
+
291
+ # ── Ancestry helpers ──────────────────────────────────────────────────────
292
+
293
+ def get_ancestors(
294
+ self,
295
+ node,
296
+ include_self: bool = True,
297
+ exclude_root: bool = False,
298
+ ) -> List[TopicNode]:
299
+ """
300
+ Return the ordered list of ancestor ``TopicNode`` objects from root
301
+ down to *node*.
302
+
303
+ Parameters
304
+ ----------
305
+ node : Starting node.
306
+ include_self : If True, include *node* itself in the result.
307
+ exclude_root : If True, omit the virtual ROOT node.
308
+
309
+ Returns
310
+ -------
311
+ List[TopicNode] in root-to-leaf order.
312
+ """
313
+ ancestors = []
314
+ current = node if include_self else node.parent
315
+ while current is not None:
316
+ ancestors.insert(0, current)
317
+ current = current.parent
318
+ if exclude_root:
319
+ ancestors = [n for n in ancestors if n.topic_name != "ROOT"]
320
+ return ancestors
321
+
322
+ def _is_frozen_node(self, node) -> bool:
323
+ """
324
+ A node is *frozen* when neither it nor any of its ancestors is the
325
+ currently active node. Frozen nodes are safe to summarise and merge.
326
+ """
327
+ if node is self.current_node:
328
+ return False
329
+ ancestors = self.get_ancestors(self.current_node, include_self=False)
330
+ return node not in ancestors
331
+
332
+ # ── Summarisation & VDB upsert ────────────────────────────────────────────
333
+
334
+ def _generate_summaries_for_frozen_nodes(self, node=None):
335
+ """
336
+ Walk the tree and LLM-summarise every frozen TopicNode that has no
337
+ summary yet. Also embeds each new summary into the VDB.
338
+ """
339
+ if node is None:
340
+ node = self.root
341
+ if isinstance(node, TopicNode):
342
+ if node is not self.root and self._is_frozen_node(node):
343
+ if not node.summary or not node.summary.strip():
344
+ messages = self.conversation[node.start_index : node.end_index]
345
+ if messages:
346
+ node.summary = self._llm_summarize(messages, node.topic_name)
347
+ self._upsert_node_to_vdb(node)
348
+ if hasattr(node, "children"):
349
+ for child in node.children:
350
+ self._generate_summaries_for_frozen_nodes(child)
351
+
352
+ def _upsert_node_to_vdb(self, node, depth=None):
353
+ """Embed a TopicNode summary and upsert it into the VDB topic table."""
354
+ if self.vdb is None or self.embed_fn is None:
355
+ return
356
+ if not node.summary or not node.node_id:
357
+ return
358
+ try:
359
+ embedding = self.embed_fn(node.summary)
360
+ self.vdb.upsert_topic_summary(
361
+ node_id = node.node_id,
362
+ topic_name = node.topic_name,
363
+ summary = node.summary,
364
+ embedding = embedding,
365
+ start_index = node.start_index,
366
+ end_index = node.end_index,
367
+ depth = depth,
368
+ )
369
+ except Exception:
370
+ pass # embedding failures must never crash the tree
371
+
372
+ # ── Public: add exchange ──────────────────────────────────────────────────
373
+
374
+ def add(self, messages: List[dict]):
375
+ """
376
+ Ingest one completed user/assistant exchange (and optional system
377
+ message) into the tree.
378
+
379
+ Parameters
380
+ ----------
381
+ messages : A list of dicts, each with ``"role"`` and ``"content"``.
382
+ Must include at least one ``"user"`` and one
383
+ ``"assistant"`` message.
384
+
385
+ Raises
386
+ ------
387
+ ValueError : If the exchange is missing a user or assistant message.
388
+
389
+ Example
390
+ -------
391
+ tree.add([
392
+ {"role": "user", "content": "What is a neutron star?"},
393
+ {"role": "assistant", "content": "A neutron star is ..."},
394
+ ])
395
+
396
+ # With a system message (e.g. tool-call context)
397
+ tree.add([
398
+ {"role": "system", "content": "Search result: ..."},
399
+ {"role": "user", "content": "Summarise that."},
400
+ {"role": "assistant", "content": "Here is a summary: ..."},
401
+ ])
402
+ """
403
+ system_msg = assistant_msg = user_msg = None
404
+ for m in messages:
405
+ role = m.get("role")
406
+ if role == "system":
407
+ system_msg = m
408
+ elif role == "user":
409
+ user_msg = m
410
+ elif role == "assistant":
411
+ assistant_msg = m
412
+
413
+ if not user_msg or not assistant_msg:
414
+ raise ValueError("Each exchange must contain a 'user' and 'assistant' message.")
415
+
416
+ msg_dict = {
417
+ "system": system_msg,
418
+ "user": user_msg,
419
+ "assistant": assistant_msg,
420
+ "index": len(self.conversation),
421
+ }
422
+
423
+ for msg in messages:
424
+ self.conversation.append(msg)
425
+
426
+ if len(self.root.children) == 0:
427
+ self._initialize_first_topic_with_message(msg_dict)
428
+ else:
429
+ self._add_message(msg_dict)
430
+
431
+ if self.auto_save_path:
432
+ self.save(self.auto_save_path, save_conversation=True)
433
+
434
+ # ── Internal: topic routing ───────────────────────────────────────────────
435
+
436
+ def _initialize_first_topic_with_message(self, msg_dict):
437
+ topic_name = self._llm_generate_topic_from_message(
438
+ msg_dict["user"], msg_dict["assistant"], system_msg=msg_dict["system"]
439
+ )
440
+ msg_count = 3 if msg_dict["system"] else 2
441
+ first_topic = TopicNode(
442
+ topic_name=topic_name,
443
+ start_index=0,
444
+ end_index=msg_count,
445
+ parent=self.root,
446
+ )
447
+ msg_node = MessageNode(
448
+ user_message = msg_dict["user"],
449
+ assistant_message = msg_dict["assistant"],
450
+ system_message = msg_dict["system"],
451
+ message_index = msg_dict["index"],
452
+ parent = first_topic,
453
+ )
454
+ first_topic.children.append(msg_node)
455
+ self.root.children.append(first_topic)
456
+ self.root.end_index = msg_count
457
+ self.current_node = first_topic
458
+
459
+ def _add_message(self, msg_dict):
460
+ target_topic = self._assign_topic_for_message(
461
+ msg_dict["user"], msg_dict["assistant"], msg_dict["system"]
462
+ )
463
+ msg_node = MessageNode(
464
+ user_message = msg_dict["user"],
465
+ assistant_message = msg_dict["assistant"],
466
+ system_message = msg_dict["system"],
467
+ message_index = msg_dict["index"],
468
+ parent = target_topic,
469
+ )
470
+ target_topic.children.append(msg_node)
471
+ target_topic.end_index = len(self.conversation)
472
+ self.current_node = target_topic
473
+
474
+ def _assign_topic_for_message(self, user_msg, assistant_msg, system_msg=None):
475
+ candidate_nodes = self.get_ancestors(
476
+ self.current_node, include_self=True, exclude_root=False
477
+ )
478
+ if not candidate_nodes:
479
+ return self._create_new_topic_for_message(
480
+ user_msg, assistant_msg, self.root, "", system_msg
481
+ )
482
+ classification = self._llm_classify_message_exchange(
483
+ user_msg, assistant_msg, candidate_nodes, system_msg
484
+ )
485
+ if classification["belongs_to_current"]:
486
+ return candidate_nodes[-1]
487
+ parent_index = classification.get("new_topic_parent_index", len(candidate_nodes) - 1)
488
+ parent_node = candidate_nodes[parent_index]
489
+ topic_name = classification.get("new_topic_name", "")
490
+ return self._create_new_topic_for_message(
491
+ user_msg, assistant_msg, parent_node, topic_name, system_msg
492
+ )
493
+
494
+ def _has_topic_children(self, node) -> bool:
495
+ return any(isinstance(child, TopicNode) for child in node.children)
496
+
497
+ def _create_new_topic_for_message(
498
+ self, user_msg, assistant_msg, parent, topic_name="", system_msg=None
499
+ ):
500
+ if not topic_name or not topic_name.strip():
501
+ topic_name = self._llm_generate_topic_from_message(
502
+ user_msg, assistant_msg, parent=parent, system_msg=system_msg
503
+ )
504
+ msg_count = 3 if system_msg else 2
505
+ new_topic = TopicNode(
506
+ topic_name = topic_name,
507
+ start_index = len(self.conversation) - msg_count,
508
+ end_index = len(self.conversation),
509
+ parent = parent,
510
+ )
511
+ parent.children.append(new_topic)
512
+ return new_topic
513
+
514
+ def _create_new_node(self, message, start_index, parent, topic_name=""):
515
+ if not topic_name or not topic_name.strip():
516
+ topic_name = self._llm_generate_topic(message, parent)
517
+ new_topic = TopicNode(
518
+ topic_name=topic_name,
519
+ start_index=start_index,
520
+ end_index=start_index,
521
+ parent=parent,
522
+ )
523
+ parent.children.append(new_topic)
524
+ return new_topic
525
+
526
+ # ── LLM helpers ───────────────────────────────────────────────────────────
527
+
528
+ def _llm_generate_topic(self, message, parent=None) -> str:
529
+ content = message.get("content", "")
530
+ context = f"\nParent topic: {parent.topic_name}" if (parent and parent.topic_name != "ROOT") else ""
531
+ prompt = (
532
+ f"Given the following message, generate a concise topic name "
533
+ f"(2-5 words) that captures its main subject.{context}\n\n"
534
+ f"Message: {content}\n\nRespond with ONLY the topic name, nothing else."
535
+ )
536
+ try:
537
+ return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
538
+ except Exception as e:
539
+ print(f"LLM error in topic generation: {e}")
540
+ return f"Topic at index {len(self.conversation)}"
541
+
542
+ def _llm_generate_topic_from_message(
543
+ self, user_msg, assistant_msg, parent=None, system_msg=None
544
+ ) -> str:
545
+ user_content = user_msg.get("content", "")
546
+ assistant_content = assistant_msg.get("content", "")
547
+ context = f"\nParent topic: {parent.topic_name}" if (parent and parent.topic_name != "ROOT") else ""
548
+ system_context = f"\nSystem: {system_msg.get('content', '')}\n" if system_msg else ""
549
+ prompt = (
550
+ f"Given the following conversation exchange, generate a concise topic name "
551
+ f"(2-5 words) that captures its main subject.{context}\n{system_context}\n"
552
+ f"User: {user_content}\nAssistant: {assistant_content}\n\n"
553
+ f"Respond with ONLY the topic name, nothing else."
554
+ )
555
+ try:
556
+ return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
557
+ except Exception as e:
558
+ print(f"LLM error in topic generation from message: {e}")
559
+ return f"Topic at message {len(self.conversation) // 2}"
560
+
561
+ def _llm_summarize(self, messages: List[dict], topic_name: str) -> str:
562
+ content = "\n\n".join(
563
+ [f"{m.get('role','unknown')}: {m.get('content','')}" for m in messages[:5]]
564
+ )
565
+ prompt = (
566
+ f"Summarize the following conversation segment about \"{topic_name}\" "
567
+ f"in 1-2 sentences.\n\n{content}\n\nSummary:"
568
+ )
569
+ try:
570
+ return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=100).strip()
571
+ except Exception as e:
572
+ print(f"LLM error in summarization: {e}")
573
+ return f"Discussion about {topic_name}"
574
+
575
+ def _llm_classify_message_exchange(
576
+ self, user_msg, assistant_msg, candidate_nodes, system_msg=None
577
+ ) -> dict:
578
+ user_content = user_msg.get("content", "")
579
+ assistant_content = assistant_msg.get("content", "")
580
+ current_node = candidate_nodes[-1]
581
+ recent_messages = self.conversation[max(0, current_node.end_index - 6) : current_node.end_index]
582
+ recent_content = "\n".join(
583
+ [f"- {m.get('role','unknown')}: {m.get('content','')[:150]}" for m in recent_messages[-6:]]
584
+ )
585
+ ancestor_list = "\n".join(
586
+ [f"{i}. {node.topic_name} - {node.summary[:100]}" for i, node in enumerate(candidate_nodes[:-1])]
587
+ )
588
+ system_context = f"System: {system_msg.get('content', '')[:200]}\n" if system_msg else ""
589
+ prompt = (
590
+ f"Decide if this new conversation exchange belongs to the current topic "
591
+ f"or starts a completely new one.\n\n"
592
+ f"**Current Topic:** {current_node.topic_name}\n"
593
+ f"**Topic Summary:** {current_node.summary}\n\n"
594
+ f"**Recent exchanges in current topic:**\n{recent_content}\n\n"
595
+ f"**New exchange:**\n{system_context}"
596
+ f"User: {user_content[:400]}\nAssistant: {assistant_content[:400]}\n\n"
597
+ f"**Available parent nodes (choose one as parent if creating a new topic):**\n{ancestor_list}\n\n"
598
+ f"RULES:\n"
599
+ f"1. If the new exchange is a natural continuation of the current topic, set belongs_to_current=true.\n"
600
+ f"2. If the new exchange introduces a completely different subject, set belongs_to_current=false.\n"
601
+ f"3. CRITICAL: When choosing new_topic_parent_index — if the new topic is completely unrelated "
602
+ f"to all listed ancestors, you MUST choose index 0 (the root/top-level). Only choose a deeper "
603
+ f"ancestor if the new topic is a genuine sub-topic of that ancestor.\n"
604
+ f"4. Do NOT nest an unrelated topic under the current topic just because it is the most recent.\n\n"
605
+ f"Respond ONLY with valid JSON:\n"
606
+ f"{{\"reasoning\": \"brief explanation\", \"belongs_to_current\": true|false, "
607
+ f"\"new_topic_name\": \"name (or N/A)\", \"new_topic_parent_index\": <index or N/A>}}\n\n"
608
+ f"Output ONLY the JSON, no other text."
609
+ )
610
+ try:
611
+ response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.1, max_tokens=200)
612
+ result = extract_json(response)
613
+ if "new_topic_parent_index" in result:
614
+ raw = result["new_topic_parent_index"]
615
+ is_na = raw is None or (isinstance(raw, str) and raw.strip().upper() == "N/A")
616
+ if not is_na:
617
+ result["new_topic_parent_index"] = min(
618
+ max(0, _safe_int(raw, len(candidate_nodes) - 1)),
619
+ len(candidate_nodes) - 1,
620
+ )
621
+ if "belongs_to_current" not in result:
622
+ result["belongs_to_current"] = True
623
+ return result
624
+ except Exception as e:
625
+ print(f"LLM error in message classification: {e}")
626
+ return {
627
+ "reasoning": "Error in classification, defaulting to current topic",
628
+ "belongs_to_current": True,
629
+ "new_topic_name": "",
630
+ "new_topic_parent_index": len(candidate_nodes) - 1,
631
+ }
632
+
633
+ def _llm_split_subtopics(self, messages: List[dict], node) -> list:
634
+ message_summary = [
635
+ f"[{i}] {m.get('role','unknown')}: {m.get('content','')[:200]}"
636
+ for i, m in enumerate(messages)
637
+ ]
638
+ messages_text = "\n".join(message_summary)
639
+ prompt = (
640
+ f'Analyze the following conversation segment about "{node.topic_name}" '
641
+ f"and identify distinct subtopics.\nGroup consecutive messages into 2 coherent subtopics.\n\n"
642
+ f"Messages (indices 0–{len(messages) - 1}):\n{messages_text}\n\n"
643
+ f"Respond in JSON format with an array of subtopics:\n"
644
+ f'{{"subtopics": [{{"topic_name": "...", "summary": "...", '
645
+ f'"start_offset": <int>, "end_offset": <int>}}, ...]}}\n\n'
646
+ f"Ensure: subtopics cover all messages; no gaps/overlaps; each subtopic ≥ 2 messages.\n"
647
+ f"Output ONLY the JSON, no other text."
648
+ )
649
+ try:
650
+ response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=500)
651
+ result = extract_json(response)
652
+ if isinstance(result, list):
653
+ return result
654
+ if "subtopics" in result:
655
+ return result["subtopics"]
656
+ except Exception as e:
657
+ print(f"LLM error in splitting: {e}")
658
+ mid = len(messages) // 2
659
+ return [
660
+ {"topic_name": f"{node.topic_name} – Part 1", "summary": "First part", "start_offset": 0, "end_offset": mid},
661
+ {"topic_name": f"{node.topic_name} – Part 2", "summary": "Second part", "start_offset": mid, "end_offset": len(messages)},
662
+ ]
663
+
664
+ def _llm_find_split_point(self, topic_children, parent_node) -> int:
665
+ children_text = "\n".join(
666
+ [f"[{i}] {c.topic_name} – {c.summary[:100]}" for i, c in enumerate(topic_children)]
667
+ )
668
+ prompt = (
669
+ f'You are analyzing topic "{parent_node.topic_name}" with {len(topic_children)} subtopics.\n'
670
+ f"Find the BEST SPLIT POINT (natural topic shift).\n\nSubtopics:\n{children_text}\n\n"
671
+ f'Respond with ONLY: {{"reasoning": "...", "split_index": <int 1–{len(topic_children)-1}>}}'
672
+ )
673
+ try:
674
+ response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.2, max_tokens=200)
675
+ result = extract_json(response)
676
+ split_index = _safe_int(result.get("split_index"), len(topic_children) // 2)
677
+ return max(1, min(split_index, len(topic_children) - 1))
678
+ except Exception:
679
+ return len(topic_children) // 2
680
+
681
+ def _llm_generate_topic_from_children(self, topic_children, parent_node) -> str:
682
+ names = [c.topic_name for c in topic_children[:5]]
683
+ text = ", ".join(names) + (f", and {len(topic_children) - 5} more" if len(topic_children) > 5 else "")
684
+ prompt = (
685
+ f"Generate a concise topic name (2-6 words) that captures the common "
686
+ f"theme of these subtopics:\n\nParent topic: {parent_node.topic_name}\n"
687
+ f"Subtopics: {text}\n\nRespond with ONLY the topic name."
688
+ )
689
+ try:
690
+ name = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
691
+ return name.strip("\"'")
692
+ except Exception:
693
+ return f"{parent_node.topic_name} – Part"
694
+
695
+ def _llm_group_topics_into_subtopics(self, topic_children, parent_node) -> list:
696
+ num_topics = len(topic_children)
697
+ num_groups = max(2, (num_topics + self.max_children - 1) // self.max_children)
698
+ topics_text = "\n".join(
699
+ [f"[{i}] {c.topic_name} – {c.summary[:100]}" for i, c in enumerate(topic_children)]
700
+ )
701
+ prompt = (
702
+ f"Analyze these {num_topics} topics and group them into {num_groups} logical categories "
703
+ f"by theme, respecting chronological flow.\n\nTopics:\n{topics_text}\n\n"
704
+ f'Respond with ONLY: {{"groups": [{{"topic_name": "...", "summary": "...", '
705
+ f'"topic_indices": [...]}}]}}'
706
+ )
707
+ try:
708
+ response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=800)
709
+ result = extract_json(response)
710
+ if isinstance(result, dict) and "groups" in result:
711
+ groups = result["groups"]
712
+ elif isinstance(result, list):
713
+ groups = result
714
+ else:
715
+ return self._create_fallback_groups(topic_children, num_groups)
716
+ all_indices = set()
717
+ for g in groups:
718
+ all_indices.update(g.get("topic_indices", []))
719
+ if len(all_indices) != num_topics:
720
+ return self._create_fallback_groups(topic_children, num_groups)
721
+ return groups
722
+ except Exception:
723
+ return self._create_fallback_groups(topic_children, num_groups)
724
+
725
+ def _create_fallback_groups(self, topic_children, num_groups) -> list:
726
+ groups, tpg = [], len(topic_children) // num_groups
727
+ for i in range(num_groups):
728
+ s = i * tpg
729
+ e = s + tpg if i < num_groups - 1 else len(topic_children)
730
+ idx = list(range(s, e))
731
+ gt = [topic_children[j] for j in idx]
732
+ groups.append({
733
+ "topic_name": f"Topic Group {i + 1}",
734
+ "summary": f"Group from {gt[0].topic_name} to {gt[-1].topic_name}",
735
+ "topic_indices": idx,
736
+ })
737
+ return groups
738
+
739
+ # ── Node expansion / splitting ────────────────────────────────────────────
740
+
741
+ def _split_node(self, node):
742
+ if node is self.root:
743
+ self._expand_root_into_subtopics(node)
744
+ return
745
+ parent = node.parent
746
+ if parent is None:
747
+ return
748
+ topic_children = [c for c in node.children if isinstance(c, TopicNode)]
749
+ if len(topic_children) < 2:
750
+ return
751
+ split_point = self._llm_find_split_point(topic_children, node)
752
+ first_half = topic_children[:split_point]
753
+ second_half = topic_children[split_point:]
754
+ first_start = first_half[0].start_index
755
+ first_end = first_half[-1].end_index
756
+ second_start = second_half[0].start_index
757
+ second_end = second_half[-1].end_index
758
+ first_name = self._llm_generate_topic_from_children(first_half, node)
759
+ second_name = self._llm_generate_topic_from_children(second_half, node)
760
+ first_node = TopicNode(topic_name=first_name, start_index=first_start, end_index=first_end, parent=parent)
761
+ second_node = TopicNode(topic_name=second_name, start_index=second_start, end_index=second_end, parent=parent)
762
+ for t in first_half:
763
+ t.parent = first_node; first_node.children.append(t)
764
+ for t in second_half:
765
+ t.parent = second_node; second_node.children.append(t)
766
+ parent.children.remove(node)
767
+ parent.children.extend([first_node, second_node])
768
+ if self.current_node is node:
769
+ self.current_node = second_node
770
+
771
+ def _expand_root_into_subtopics(self, root_node):
772
+ topic_children = [c for c in root_node.children if isinstance(c, TopicNode)]
773
+ if len(topic_children) < 2:
774
+ return
775
+ groups = self._llm_group_topics_into_subtopics(topic_children, root_node)
776
+ root_node.children.clear()
777
+ last_subtopic_node = None
778
+ for group in groups:
779
+ indices = group["topic_indices"]
780
+ group_topics = [topic_children[i] for i in indices if i < len(topic_children)]
781
+ if not group_topics:
782
+ continue
783
+ g_start = min(t.start_index for t in group_topics)
784
+ g_end = max(t.end_index for t in group_topics)
785
+ sn = TopicNode(
786
+ topic_name = group["topic_name"],
787
+ summary = group.get("summary", ""),
788
+ start_index = g_start,
789
+ end_index = g_end,
790
+ parent = root_node,
791
+ )
792
+ for t in group_topics:
793
+ t.parent = sn
794
+ sn.children.append(t)
795
+ root_node.children.append(sn)
796
+ last_subtopic_node = sn
797
+ if last_subtopic_node:
798
+ def _in_subtree(n, target):
799
+ if n is target: return True
800
+ return any(_in_subtree(c, target) for c in n.children)
801
+ for sn in root_node.children:
802
+ if isinstance(sn, TopicNode) and _in_subtree(sn, self.current_node):
803
+ break
804
+ else:
805
+ self.current_node = last_subtopic_node
806
+
807
+ # ── Public utilities ──────────────────────────────────────────────────────
808
+
809
+ def generate_summaries(self):
810
+ """
811
+ Manually trigger lazy summarisation of all frozen branches.
812
+ Called automatically during ``save()``.
813
+ """
814
+ self._generate_summaries_for_frozen_nodes()
815
+
816
+ def print_tree(self, node=None, indent: int = 0, show_messages: bool = False):
817
+ """
818
+ Pretty-print the conversation tree to stdout.
819
+
820
+ Parameters
821
+ ----------
822
+ node : Start node (defaults to root).
823
+ indent : Current indentation level (used internally).
824
+ show_messages : If True, also render MessageNode contents.
825
+ """
826
+ if node is None:
827
+ node = self.root
828
+ prefix = " " * indent
829
+ if isinstance(node, TopicNode):
830
+ msg_count = node.get_message_count()
831
+ if node is self.root:
832
+ print(f"{prefix}ROOT (sub-nodes: {node.sub_node_count})")
833
+ else:
834
+ print(f"{prefix}├─ {node.topic_name} [{node.start_index}:{node.end_index}] ({msg_count} msgs)")
835
+ if indent < 3:
836
+ print(f"{prefix} {node.summary[:100]}")
837
+ elif isinstance(node, MessageNode) and show_messages:
838
+ if node.system_message:
839
+ print(f"{prefix} └─ Message[{node.message_index}] (3 msgs):")
840
+ print(f"{prefix} System: {node.system_message.get('content','')[:50]}…")
841
+ else:
842
+ print(f"{prefix} └─ Message[{node.message_index}] (2 msgs):")
843
+ print(f"{prefix} User: {node.user_message.get('content','')[:50]}…")
844
+ print(f"{prefix} Assistant: {node.assistant_message.get('content','')[:50]}…")
845
+ for child in node.children:
846
+ if isinstance(child, TopicNode) or (isinstance(child, MessageNode) and show_messages):
847
+ self.print_tree(child, indent + 1, show_messages)
848
+
849
+ # ── Serialisation ─────────────────────────────────────────────────────────
850
+
851
+ def to_dict(self) -> dict:
852
+ return {
853
+ "max_children": self.max_children,
854
+ "total_messages": len(self.conversation),
855
+ "tree": self.root.to_dict(),
856
+ }
857
+
858
+ def save(self, filepath: str, save_conversation: bool = False):
859
+ """
860
+ Persist the tree to a JSON file.
861
+
862
+ Parameters
863
+ ----------
864
+ filepath : Output path (e.g. "sessions/chat_001.json").
865
+ save_conversation : If True, embed the raw ``conversation`` list in
866
+ the file so it can be fully restored.
867
+
868
+ Example
869
+ -------
870
+ tree.save("my_session.json", save_conversation=True)
871
+ """
872
+ self._generate_summaries_for_frozen_nodes()
873
+ data = self.to_dict()
874
+ if save_conversation:
875
+ data["conversation"] = self.conversation
876
+ if self._archived_nodes:
877
+ data["archived"] = [n.to_dict() for n in self._archived_nodes]
878
+ with open(filepath, "w", encoding="utf-8") as f:
879
+ json.dump(data, f, indent=2, ensure_ascii=False)
880
+
881
+ @classmethod
882
+ def load(cls, filepath: str, api_key: str = None, model: str = None, embed_fn: Optional[Callable[[str], List[float]]] = None) -> "CTree":
883
+ """
884
+ Reconstruct a ``CTree`` from a previously saved JSON file.
885
+
886
+ Parameters
887
+ ----------
888
+ filepath : Path to the JSON file produced by ``save()``.
889
+ api_key : LLM API key.
890
+ model : Override model ID (defaults to "gpt-4o-mini").
891
+
892
+ Returns
893
+ -------
894
+ CTree — fully hydrated tree with ``conversation``, all nodes, and
895
+ any archived messages restored.
896
+
897
+ Example
898
+ -------
899
+ tree = CTree.load("my_session.json", api_key="sk-...")
900
+ """
901
+ with open(filepath, "r", encoding="utf-8") as f:
902
+ data = json.load(f)
903
+ tree = cls(
904
+ max_children = data.get("max_children", 5),
905
+ api_key = api_key,
906
+ model = model or "gpt-4o-mini",
907
+ embed_fn = embed_fn,
908
+ )
909
+ tree.conversation = data.get("conversation", [])
910
+ tree.root = tree._reconstruct_node(data["tree"], parent=None)
911
+ tree.current_node = tree._find_current_node(tree.root)
912
+ for arch_data in data.get("archived", []):
913
+ try:
914
+ tree._archived_nodes.append(tree._reconstruct_node(arch_data, parent=None))
915
+ except Exception:
916
+ pass
917
+ return tree
918
+
919
+ def _reconstruct_node(self, node_data: dict, parent):
920
+ node_type = node_data.get("type", "topic")
921
+ if node_type == "message":
922
+ msg_start_idx = node_data.get("message_index", node_data.get("pair_index", 0) * 2)
923
+ user_msg = assistant_msg = {}
924
+ system_msg = None
925
+ for i in range(max(0, msg_start_idx - 1), min(len(self.conversation), msg_start_idx + 4)):
926
+ if i < len(self.conversation) and self.conversation[i].get("role") == "user":
927
+ if i > 0 and self.conversation[i - 1].get("role") == "system":
928
+ system_msg = self.conversation[i - 1]
929
+ user_msg = self.conversation[i]
930
+ if i + 1 < len(self.conversation) and self.conversation[i + 1].get("role") == "assistant":
931
+ assistant_msg = self.conversation[i + 1]
932
+ break
933
+ node = MessageNode(
934
+ user_message = user_msg,
935
+ assistant_message = assistant_msg,
936
+ system_message = system_msg,
937
+ message_index = msg_start_idx,
938
+ parent = parent,
939
+ )
940
+ else:
941
+ node = TopicNode(
942
+ topic_name = node_data.get("topic_name", ""),
943
+ summary = node_data.get("summary", ""),
944
+ start_index = node_data.get("start_index", 0),
945
+ end_index = node_data.get("end_index", 0),
946
+ parent = parent,
947
+ )
948
+ if "node_id" in node_data:
949
+ node.node_id = node_data["node_id"]
950
+ node.created_at = node_data.get("created_at", node.created_at)
951
+ for child_data in node_data.get("children", []):
952
+ node.children.append(self._reconstruct_node(child_data, parent=node))
953
+ node.sub_node_count = node_data.get("sub_node_count", 0)
954
+ return node
955
+
956
+ def _find_current_node(self, node):
957
+ if isinstance(node, MessageNode):
958
+ return node.parent if isinstance(node.parent, TopicNode) else self.root
959
+ if not isinstance(node, TopicNode):
960
+ return self.root
961
+ if node.children:
962
+ last = node.children[-1]
963
+ if isinstance(last, MessageNode):
964
+ return node
965
+ if isinstance(last, TopicNode):
966
+ return self._find_current_node(last)
967
+ return node
968
+
969
+ # ── Self-healing reorganizer ──────────────────────────────────────────────
970
+
971
+ def _build_flat_topic_list(self) -> list:
972
+ """Post-order traversal: returns every non-root TopicNode."""
973
+ result = []
974
+ def _post(node):
975
+ for c in node.children:
976
+ _post(c)
977
+ if isinstance(node, TopicNode) and node is not self.root:
978
+ result.append(node)
979
+ _post(self.root)
980
+ return result
981
+
982
+ def _llm_check_relatedness(self, node_a, node_b) -> dict:
983
+ """Ask the LLM whether two frozen branches are related enough to merge."""
984
+ hint = "a_into_b means B is older" if node_a.created_at <= node_b.created_at else "b_into_a means A is older"
985
+ prompt = (
986
+ f'Topic A: "{node_a.topic_name}" — {(node_a.summary or "(no summary)")[:200]}\n'
987
+ f'Topic B: "{node_b.topic_name}" — {(node_b.summary or "(no summary)")[:200]}\n\n'
988
+ f"Are these two topics related enough to be grouped under the same parent?\n"
989
+ f"The cosine similarity score already pre-filtered these — lean toward yes unless they are "
990
+ f"clearly different domains (e.g. cooking vs programming).\n"
991
+ f"If yes, merge_direction: \"a_into_b\" = A becomes child of B, \"b_into_a\" = B becomes child of A.\n"
992
+ f"({hint})\n\n"
993
+ f'Reply ONLY with JSON. Keep "reason" under 10 words:\n'
994
+ f'{{"related": true|false, "reason": "short reason", "merge_direction": "a_into_b"|"b_into_a"|"N/A"}}'
995
+ )
996
+ try:
997
+ response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.1, max_tokens=300)
998
+ result = extract_json(response)
999
+ result.setdefault("related", False)
1000
+ result.setdefault("merge_direction", "N/A")
1001
+ return result
1002
+ except Exception:
1003
+ return {"related": False, "reason": "LLM error", "merge_direction": "N/A"}
1004
+
1005
+ def _merge_node_into(self, source: "TopicNode", target: "TopicNode"):
1006
+ """
1007
+ Move *source* to become a child of *target*.
1008
+ No MessageNodes are deleted. VDB entry is cleared for re-indexing.
1009
+ """
1010
+ old_parent = source.parent
1011
+ if old_parent is None or old_parent is target:
1012
+ return
1013
+ try:
1014
+ old_parent.children.remove(source)
1015
+ except ValueError:
1016
+ return
1017
+ source.parent = target
1018
+ target.children.append(source)
1019
+ if self.vdb is not None:
1020
+ try:
1021
+ self.vdb.delete_topic_summary(source.node_id)
1022
+ except Exception:
1023
+ pass
1024
+
1025
+ def _is_ancestor_of(self, potential_ancestor, node) -> bool:
1026
+ current = node.parent
1027
+ while current is not None:
1028
+ if current is potential_ancestor:
1029
+ return True
1030
+ current = current.parent
1031
+ return False
1032
+
1033
+ def reorganize(
1034
+ self,
1035
+ embed_fn=None,
1036
+ similarity_threshold: float = 0.55,
1037
+ prune_trivial_leaves: bool = False,
1038
+ ) -> dict:
1039
+ """
1040
+ Run one conservative self-healing reorganization pass over the tree.
1041
+
1042
+ This is the core of TRACE's "Axiomatic Reorganization" feature.
1043
+ It evaluates all *frozen* (inactive) branches for semantic similarity
1044
+ and, when four strict axioms are satisfied, merges related branches
1045
+ under a common parent — mimicking how the human brain consolidates
1046
+ memories during sleep.
1047
+
1048
+ The four axioms
1049
+ ---------------
1050
+ 1. **Chronological Guard**: The older node absorbs the newer one —
1051
+ never the reverse.
1052
+ 2. **Frozen State**: Only nodes outside the currently active ancestry
1053
+ path may be merged. The live conversation thread is never touched.
1054
+ 3. **Sim Threshold**: Cosine similarity between branch embeddings must
1055
+ exceed *similarity_threshold* (default 0.55).
1056
+ 4. **LLM Veto**: The LLM independently confirms the merge makes
1057
+ semantic sense. If it disagrees, the merge is aborted.
1058
+
1059
+ Parameters
1060
+ ----------
1061
+ embed_fn : Optional embedding function to override
1062
+ ``tree.embed_fn``. Must be callable:
1063
+ ``(text: str) -> List[float]``.
1064
+ similarity_threshold : Minimum cosine similarity for a pair to be
1065
+ considered as merge candidates. (default 0.55)
1066
+ prune_trivial_leaves : If True, MessageNodes with < 20 words in both
1067
+ sides are soft-archived (moved to
1068
+ ``tree._archived_nodes``). Default False.
1069
+
1070
+ Returns
1071
+ -------
1072
+ dict with keys:
1073
+ merged (int) — number of branches merged.
1074
+ pruned (int) — number of trivial messages archived.
1075
+ skipped (int) — pairs skipped due to axiom violations.
1076
+ duration_secs (float) — wall-clock time for the pass.
1077
+
1078
+ Example
1079
+ -------
1080
+ stats = tree.reorganize(
1081
+ embed_fn=my_embed,
1082
+ similarity_threshold=0.60,
1083
+ prune_trivial_leaves=True,
1084
+ )
1085
+ print(f"Merged {stats['merged']} branches in {stats['duration_secs']:.1f}s")
1086
+ """
1087
+ import time as _time
1088
+ from .vector_db import cosine_similarity as _cos
1089
+
1090
+ t0 = _time.time()
1091
+ merged = pruned = skipped = 0
1092
+
1093
+ # Phase 1 — collect frozen candidates
1094
+ all_topics = self._build_flat_topic_list()
1095
+ live_ancestors = set(self.get_ancestors(self.current_node, include_self=True))
1096
+ candidates = [n for n in all_topics if n not in live_ancestors and self._is_frozen_node(n)]
1097
+
1098
+ if len(candidates) < 2:
1099
+ return {"merged": 0, "pruned": 0, "skipped": 0, "duration_secs": _time.time() - t0}
1100
+
1101
+ # Phase 2 — embed candidates
1102
+ self._generate_summaries_for_frozen_nodes()
1103
+ _embed = embed_fn or self.embed_fn
1104
+ embeddings = {}
1105
+ if _embed:
1106
+ for node in candidates:
1107
+ text = (node.summary or node.topic_name).strip()
1108
+ if not text:
1109
+ for child in node.children:
1110
+ if isinstance(child, MessageNode):
1111
+ u = (child.user_message.get("content") or "").strip()
1112
+ a = (child.assistant_message.get("content") or "").strip()
1113
+ raw = f"{u} {a}".strip()
1114
+ if raw:
1115
+ text = raw[:300]
1116
+ if not node.topic_name:
1117
+ node.topic_name = self._llm_generate_topic_from_message(
1118
+ child.user_message, child.assistant_message
1119
+ )
1120
+ break
1121
+ if not text:
1122
+ continue
1123
+ try:
1124
+ embeddings[node.node_id] = _embed(text)
1125
+ except Exception as e:
1126
+ print(f" ⚠ Embed error for [{node.topic_name}]: {e}")
1127
+
1128
+ # Phase 3 — find high-similarity pairs and apply axioms
1129
+ candidate_pairs = []
1130
+ for i in range(len(candidates)):
1131
+ for j in range(i + 1, len(candidates)):
1132
+ na, nb = candidates[i], candidates[j]
1133
+ if na.node_id not in embeddings or nb.node_id not in embeddings:
1134
+ skipped += 1
1135
+ continue
1136
+ sim = _cos(embeddings[na.node_id], embeddings[nb.node_id])
1137
+ if sim < similarity_threshold:
1138
+ continue
1139
+ older, newer = (na, nb) if na.created_at <= nb.created_at else (nb, na)
1140
+ if self._is_ancestor_of(older, newer) or self._is_ancestor_of(newer, older):
1141
+ skipped += 1
1142
+ continue
1143
+ candidate_pairs.append((sim, older, newer))
1144
+
1145
+ candidate_pairs.sort(key=lambda x: x[0], reverse=True)
1146
+
1147
+ already_moved = set()
1148
+ for sim, older, newer in candidate_pairs:
1149
+ if older.node_id in already_moved or newer.node_id in already_moved:
1150
+ skipped += 1
1151
+ continue
1152
+ if not self._is_frozen_node(older) or not self._is_frozen_node(newer):
1153
+ skipped += 1
1154
+ continue
1155
+ decision = self._llm_check_relatedness(older, newer)
1156
+ if not decision.get("related", False):
1157
+ skipped += 1
1158
+ continue
1159
+ self._merge_node_into(source=newer, target=older)
1160
+ already_moved.add(newer.node_id)
1161
+ merged += 1
1162
+
1163
+ # Phase 4 — optional leaf pruning
1164
+ if prune_trivial_leaves:
1165
+ for node in self._build_flat_topic_list():
1166
+ if node not in live_ancestors and self._is_frozen_node(node):
1167
+ msg_children = [c for c in node.children if isinstance(c, MessageNode)]
1168
+ topic_children = [c for c in node.children if isinstance(c, TopicNode)]
1169
+ if topic_children:
1170
+ continue
1171
+ parent = node.parent
1172
+ if parent is None or not (parent.summary and parent.summary.strip()):
1173
+ continue
1174
+ for mc in msg_children:
1175
+ u_words = len((mc.user_message.get("content", "") or "").split())
1176
+ a_words = len((mc.assistant_message.get("content", "") or "").split())
1177
+ if u_words < 20 and a_words < 20:
1178
+ self._archived_nodes.append(mc)
1179
+ node.children.remove(mc)
1180
+ pruned += 1
1181
+
1182
+ return {
1183
+ "merged": merged,
1184
+ "pruned": pruned,
1185
+ "skipped": skipped,
1186
+ "duration_secs": _time.time() - t0,
1187
+ }