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.
@@ -0,0 +1,256 @@
1
+ """
2
+ PromptSynthesizer — TRACE's Multi-Path RAG Prompt Builder
3
+ ==========================================================
4
+ Assembles the enriched system prompt that is injected before every LLM
5
+ call, weaving together:
6
+
7
+ 1. **Surgical Memory Context** — Cosine-searched topic summaries from the
8
+ B+Tree. All nodes above the base similarity threshold are collected,
9
+ ancestors are deduplicated, then the top-3 highest-scoring paths are
10
+ surfaced for multi-path cross-branch retrieval.
11
+ 2. **Cross-Thread Conversation Recall** — Semantically similar past
12
+ messages retrieved from the conversation vector table.
13
+ 3. **Verbatim Recent Log** — The last N raw messages for immediate context.
14
+
15
+ Usage
16
+ -----
17
+ from trace_memory import CTree, VectorDatabase, PromptSynthesizer
18
+
19
+ tree = CTree(api_key="sk-...", model="gpt-4o-mini")
20
+ vdb = VectorDatabase("session.db")
21
+ synth = PromptSynthesizer(tree, vdb)
22
+
23
+ system_prompt = synth.synthesize_prompt(
24
+ user_query = "How do neutron stars form?",
25
+ query_vector = embed("How do neutron stars form?"),
26
+ active_node = tree.current_node,
27
+ recent_messages = tree.conversation[-6:],
28
+ )
29
+ # Pass system_prompt as the system message to your LLM.
30
+ """
31
+
32
+ import time
33
+ from datetime import datetime
34
+
35
+ from .ctree import CTree, TopicNode, MessageNode
36
+ from .vector_db import VectorDatabase, ConversationVector
37
+
38
+
39
+ class PromptSynthesizer:
40
+ """
41
+ Builds an enriched, RAG-grounded system prompt for every LLM turn.
42
+
43
+ Parameters
44
+ ----------
45
+ ctree : A live ``CTree`` instance.
46
+ vector_db : The ``VectorDatabase`` associated with the session.
47
+ """
48
+
49
+ def __init__(self, ctree: CTree, vector_db: VectorDatabase):
50
+ self.tree = ctree
51
+ self.vdb = vector_db
52
+
53
+ # ── Internal: full-tree narrative (fallback) ──────────────────────────────
54
+
55
+ def _traverse(self, node, depth: int = 0):
56
+ lines = []
57
+ indent = " " * depth
58
+ if isinstance(node, TopicNode) and node.topic_name != "ROOT":
59
+ summary = (node.summary or "Active discussion in progress.").strip()
60
+ lines.append(f"{indent}• {node.topic_name} [msgs {node.start_index}–{node.end_index}]")
61
+ lines.append(f"{indent} ↳ {summary}")
62
+ for child in node.children:
63
+ if isinstance(child, TopicNode):
64
+ lines.extend(self._traverse(child, depth + 1))
65
+ return lines
66
+
67
+ def _get_active_narrative_context(self, active_node) -> str:
68
+ blocks = ["── GLOBAL CONVERSATION MEMORY INDEX ──"]
69
+ summaries = self._traverse(self.tree.root)
70
+ blocks.extend(summaries if summaries else [" (No topics indexed yet)"])
71
+ blocks.append("\n── ACTIVE THEMATIC CONTEXT PATH ──")
72
+ ancestors = self.tree.get_ancestors(active_node, include_self=True, exclude_root=True)
73
+ if ancestors:
74
+ path = " → ".join(n.topic_name for n in ancestors)
75
+ blocks.append(f"Thread: {path}")
76
+ for n in ancestors:
77
+ s = (n.summary or "Expanding context…").strip()
78
+ blocks.append(f" • {n.topic_name}: {s}")
79
+ else:
80
+ blocks.append(" Thread: ROOT (first topic in progress)")
81
+ return "\n".join(blocks)
82
+
83
+ # ── Internal: surgical multi-path retrieval ───────────────────────────────
84
+
85
+ def _build_node_id_map(self) -> dict:
86
+ result = {}
87
+ def _walk(node):
88
+ if isinstance(node, TopicNode) and hasattr(node, "node_id"):
89
+ result[node.node_id] = node
90
+ for child in node.children:
91
+ _walk(child)
92
+ _walk(self.tree.root)
93
+ return result
94
+
95
+ def _get_surgical_context(self, query_vector) -> str:
96
+ """
97
+ Multi-path surgical retrieval (the heart of TRACE):
98
+
99
+ 1. Cosine-search the VDB for ALL topic summaries above the base threshold.
100
+ 2. Walk full ancestry for every qualifying node.
101
+ 3. Deduplicate shared ancestor nodes across all paths.
102
+ 4. Rank deduplicated paths by similarity and take the top-3.
103
+ 5. Format a compact merged multi-path context block.
104
+
105
+ Falls back to full-tree narrative if no hits qualify.
106
+ """
107
+ if not query_vector or self.vdb is None:
108
+ return self._get_active_narrative_context(self.tree.current_node)
109
+ try:
110
+ # Fetch all nodes above the base threshold (large top_k to capture everything)
111
+ hits = self.vdb.search_topic_summaries(query_vector, top_k=50, min_similarity=0.30)
112
+ except Exception:
113
+ return self._get_active_narrative_context(self.tree.current_node)
114
+ if not hits:
115
+ return self._get_active_narrative_context(self.tree.current_node)
116
+
117
+ node_map = self._build_node_id_map()
118
+ all_ancestor_sets = [] # [(similarity, [TopicNode, ...])]
119
+ for hit in hits:
120
+ node = node_map.get(hit["node_id"])
121
+ if node is None:
122
+ continue
123
+ ancestors = self.tree.get_ancestors(node, include_self=True, exclude_root=True)
124
+ if ancestors:
125
+ all_ancestor_sets.append((hit["similarity"], ancestors))
126
+
127
+ if not all_ancestor_sets:
128
+ return self._get_active_narrative_context(self.tree.current_node)
129
+
130
+ # Deduplicate shared ancestors across ALL qualifying paths
131
+ seen_ids = set()
132
+ unique_nodes_ordered = []
133
+ for similarity, ancestors in all_ancestor_sets:
134
+ for node in ancestors:
135
+ nid = getattr(node, "node_id", id(node))
136
+ if nid not in seen_ids:
137
+ seen_ids.add(nid)
138
+ unique_nodes_ordered.append((node, similarity))
139
+
140
+ # Now rank paths by similarity and take the top-3
141
+ all_ancestor_sets_ranked = sorted(all_ancestor_sets, key=lambda x: x[0], reverse=True)[:3]
142
+
143
+ blocks = ["── SURGICAL MEMORY CONTEXT (top matched topics + ancestry) ──"]
144
+ for i, (similarity, ancestors) in enumerate(all_ancestor_sets_ranked, 1):
145
+ path = " → ".join(n.topic_name for n in ancestors)
146
+ blocks.append(f"Match {i} (confidence {similarity * 100:.0f}%): {path}")
147
+ blocks.append("")
148
+ blocks.append("── ANCESTRY DETAIL ──")
149
+ for node, _ in unique_nodes_ordered:
150
+ s = (node.summary or "Context expanding…").strip()
151
+ blocks.append(f" • {node.topic_name}: {s}")
152
+ return "\n".join(blocks)
153
+
154
+ # ── Public: synthesize_prompt ─────────────────────────────────────────────
155
+
156
+ def synthesize_prompt(
157
+ self,
158
+ user_query: str,
159
+ query_vector: list,
160
+ active_node,
161
+ recent_messages: list,
162
+ top_k_history: int = 2,
163
+ min_history_similarity: float = 0.50,
164
+ ) -> str:
165
+ """
166
+ Assemble the full enriched system prompt for the next LLM turn.
167
+
168
+ Parameters
169
+ ----------
170
+ user_query : The raw text of the user's current message.
171
+ query_vector : Embedding of *user_query*.
172
+ active_node : ``tree.current_node``.
173
+ recent_messages : Tail of ``tree.conversation`` (e.g. last 6 msgs).
174
+ top_k_history : Max past conversation messages to recall.
175
+ min_history_similarity : Minimum cosine score for conversation recall.
176
+
177
+ Returns
178
+ -------
179
+ str — A ready-to-use system prompt string. Pass this as the
180
+ ``system`` role message to your LLM.
181
+
182
+ Example
183
+ -------
184
+ prompt = synth.synthesize_prompt(
185
+ user_query = "Is the cake safe for Sarah?",
186
+ query_vector = embed("Is the cake safe for Sarah?"),
187
+ active_node = tree.current_node,
188
+ recent_messages = tree.conversation[-6:],
189
+ )
190
+ response = openai_client.chat.completions.create(
191
+ model = "gpt-4o",
192
+ messages = [
193
+ {"role": "system", "content": prompt},
194
+ {"role": "user", "content": "Is the cake safe for Sarah?"},
195
+ ],
196
+ )
197
+ """
198
+ # 1. Surgical multi-path memory context
199
+ narrative_block = self._get_surgical_context(query_vector)
200
+
201
+ # 2. Cross-thread conversation recall
202
+ history_matches = self.vdb.search_conversation(
203
+ query_vector = query_vector,
204
+ top_k = top_k_history,
205
+ min_similarity = min_history_similarity,
206
+ )
207
+ recall_block = ""
208
+ valid_recalls = [h for h in history_matches if h.text.strip() != user_query.strip()]
209
+ if valid_recalls:
210
+ recall_block = "\n── SUBCONSCIOUS CROSS-THREAD CONVERSATION RECALL ──\n"
211
+ for msg in valid_recalls:
212
+ time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg.timestamp))
213
+ recall_block += (
214
+ f"Branch Path: {msg.thread_path} (Recall Strength: {msg.similarity * 100:.1f}%)\n"
215
+ f"Context [Role: {msg.role.upper()} | Time: {time_str}]:\n"
216
+ f"\"{msg.text.strip()}\"\n"
217
+ + "─" * 50 + "\n"
218
+ )
219
+
220
+ # 3. Verbatim recent log
221
+ recent_block = "── RECENT CONVERSATION LOGS (VERBATIM) ──\n"
222
+ if recent_messages:
223
+ for msg in recent_messages:
224
+ role = msg.get("role", "unknown").upper()
225
+ content = msg.get("content", "")
226
+ if isinstance(content, str) and len(content) > 600:
227
+ content = content[:600] + "… [truncated]"
228
+ elif isinstance(content, list):
229
+ content = "[multimodal message]"
230
+ recent_block += f"[{role}]: {content}\n"
231
+ else:
232
+ recent_block += " (No messages yet)\n"
233
+
234
+ # Assemble final prompt
235
+ now_str = datetime.now().strftime("%A, %d %B %Y, %I:%M %p")
236
+ base = (
237
+ f"Today's date and time: {now_str}\n"
238
+ "Your training data may be outdated — always treat today's date above as ground truth.\n"
239
+ "If web search results are provided, treat them as the most current and accurate source.\n\n"
240
+ "You are a sharp, knowledgeable AI assistant. "
241
+ "Answer the user's current message directly and helpfully.\n\n"
242
+ "STRICT RULES (never break these):\n"
243
+ "- NEVER mention topics, branches, threads, memory trees, or any internal system.\n"
244
+ "- NEVER ask the user if they want to switch topics or continue a previous topic.\n"
245
+ "- If the user asks about something new, just answer it.\n"
246
+ "- Use the context below ONLY as silent background knowledge to stay coherent.\n"
247
+ "- Stay on what the user actually asked. No preamble.\n"
248
+ "- Provide detailed, comprehensive answers.\n\n"
249
+ )
250
+ return (
251
+ base
252
+ + narrative_block + "\n\n"
253
+ + recall_block
254
+ + recent_block + "\n"
255
+ + "Now answer the user's latest message directly, as a knowledgeable friend would."
256
+ )
@@ -0,0 +1,191 @@
1
+ import sqlite3
2
+ import struct
3
+ import json
4
+ import time
5
+ import numpy as np
6
+
7
+
8
+
9
+ class ConversationVector:
10
+
11
+ def __init__(self, message_id, message_index, role, text, embedding, timestamp, thread_path, similarity=0.0):
12
+ self.message_id = message_id
13
+ self.message_index = message_index
14
+ self.role = role
15
+ self.text = text
16
+ self.embedding = embedding
17
+ self.timestamp = timestamp
18
+ self.thread_path = thread_path
19
+ self.similarity = similarity
20
+
21
+ def pack_float_vector(vector):
22
+ if not vector:
23
+ return b''
24
+ format_string = '<' + str(len(vector)) + 'f'
25
+ return struct.pack(format_string, *vector)
26
+
27
+ def unpack_float_vector(blob):
28
+ if not blob:
29
+ return []
30
+ num_floats = len(blob) // 4
31
+ format_string = '<' + str(num_floats) + 'f'
32
+ unpacked = struct.unpack(format_string, blob)
33
+ result = []
34
+ for val in unpacked:
35
+ result.append(val)
36
+ return result
37
+
38
+ def cosine_similarity(v1, v2):
39
+ if not v1 or not v2 or len(v1) != len(v2):
40
+ return 0.0
41
+ vec1 = np.array(v1, dtype=np.float32)
42
+ vec2 = np.array(v2, dtype=np.float32)
43
+ norm_v1 = float(np.linalg.norm(vec1))
44
+ norm_v2 = float(np.linalg.norm(vec2))
45
+ if norm_v1 == 0.0 or norm_v2 == 0.0:
46
+ return 0.0
47
+ return float(np.dot(vec1, vec2) / (norm_v1 * norm_v2))
48
+
49
+ class VectorDatabase:
50
+
51
+ def __init__(self, db_path):
52
+ self.db_path = db_path
53
+ self._initialize_database()
54
+
55
+ def _initialize_database(self):
56
+ conn = sqlite3.connect(self.db_path)
57
+ cursor = conn.cursor()
58
+
59
+ cursor.execute("""
60
+ CREATE TABLE IF NOT EXISTS conversation_vectors (
61
+ message_id TEXT PRIMARY KEY,
62
+ message_index INTEGER NOT NULL,
63
+ role TEXT NOT NULL,
64
+ content TEXT NOT NULL,
65
+ embedding BLOB NOT NULL,
66
+ timestamp REAL NOT NULL,
67
+ thread_path TEXT NOT NULL
68
+ )
69
+ """)
70
+ cursor.execute("""
71
+ CREATE TABLE IF NOT EXISTS topic_summaries (
72
+ node_id TEXT PRIMARY KEY,
73
+ topic_name TEXT NOT NULL,
74
+ summary TEXT NOT NULL,
75
+ embedding BLOB NOT NULL,
76
+ start_index INTEGER,
77
+ end_index INTEGER,
78
+ depth INTEGER
79
+ )
80
+ """)
81
+ conn.commit()
82
+ conn.close()
83
+
84
+
85
+
86
+
87
+ def add_conversation_message(self, msg):
88
+ packed_vector = pack_float_vector(msg.embedding)
89
+ conn = sqlite3.connect(self.db_path)
90
+ cursor = conn.cursor()
91
+ cursor.execute('\n INSERT OR REPLACE INTO conversation_vectors (\n message_id, message_index, role, content, embedding, timestamp, thread_path\n ) VALUES (?, ?, ?, ?, ?, ?, ?)\n ', (msg.message_id, msg.message_index, msg.role, msg.text, packed_vector, msg.timestamp, msg.thread_path))
92
+ conn.commit()
93
+ conn.close()
94
+
95
+
96
+
97
+ def search_conversation(self, query_vector, top_k=2, min_similarity=0.5):
98
+ if not query_vector:
99
+ return []
100
+ conn = sqlite3.connect(self.db_path)
101
+ cursor = conn.cursor()
102
+ cursor.execute('\n SELECT message_id, message_index, role, content, embedding, timestamp, thread_path \n FROM conversation_vectors\n ')
103
+ rows = cursor.fetchall()
104
+ conn.close()
105
+ scored_msgs = []
106
+ for row in rows:
107
+ msg_id = row[0]
108
+ msg_idx = row[1]
109
+ role = row[2]
110
+ content = row[3]
111
+ raw_embed = row[4]
112
+ timestamp = row[5]
113
+ thread_path = row[6]
114
+ stored_vector = unpack_float_vector(raw_embed)
115
+ sim = cosine_similarity(query_vector, stored_vector)
116
+ if sim >= min_similarity:
117
+ data_tuple = (msg_id, msg_idx, role, content, timestamp, thread_path)
118
+ scored_msgs.append((sim, data_tuple))
119
+ scored_msgs.sort(key=lambda x: x[0], reverse=True)
120
+ top_matches = []
121
+ for i in range(min(top_k, len(scored_msgs))):
122
+ top_matches.append(scored_msgs[i])
123
+ results = []
124
+ for match in top_matches:
125
+ sim = match[0]
126
+ data = match[1]
127
+ msg_id = data[0]
128
+ msg_idx = data[1]
129
+ role = data[2]
130
+ content = data[3]
131
+ timestamp = data[4]
132
+ thread_path = data[5]
133
+ msg = ConversationVector(message_id=msg_id, message_index=msg_idx, role=role, text=content, embedding=[], timestamp=timestamp, thread_path=thread_path, similarity=sim)
134
+ results.append(msg)
135
+ return results
136
+
137
+ # ── Topic Summary Methods (Change 1: Surgical Retrieval) ─────────────────
138
+
139
+ def upsert_topic_summary(self, node_id, topic_name, summary, embedding,
140
+ start_index=None, end_index=None, depth=None):
141
+ """Insert or replace a topic embedding row. Called when a node is frozen & summarised."""
142
+ packed = pack_float_vector(embedding)
143
+ conn = sqlite3.connect(self.db_path)
144
+ cursor = conn.cursor()
145
+ cursor.execute(
146
+ 'INSERT OR REPLACE INTO topic_summaries '
147
+ '(node_id, topic_name, summary, embedding, start_index, end_index, depth) '
148
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
149
+ (node_id, topic_name, summary, packed, start_index, end_index, depth)
150
+ )
151
+ conn.commit()
152
+ conn.close()
153
+
154
+ def search_topic_summaries(self, query_vector, top_k=3, min_similarity=0.40):
155
+ """Cosine-search the topic_summaries table.
156
+ Returns list of dicts: [{node_id, topic_name, summary, similarity}]
157
+ """
158
+ if not query_vector:
159
+ return []
160
+ conn = sqlite3.connect(self.db_path)
161
+ cursor = conn.cursor()
162
+ cursor.execute(
163
+ 'SELECT node_id, topic_name, summary, embedding FROM topic_summaries'
164
+ )
165
+ rows = cursor.fetchall()
166
+ conn.close()
167
+ scored = []
168
+ for row in rows:
169
+ node_id, topic_name, summary, raw_embed = row
170
+ stored_vec = unpack_float_vector(raw_embed)
171
+ sim = cosine_similarity(query_vector, stored_vec)
172
+ if sim >= min_similarity:
173
+ scored.append((sim, node_id, topic_name, summary))
174
+ scored.sort(key=lambda x: x[0], reverse=True)
175
+ results = []
176
+ for sim, node_id, topic_name, summary in scored[:top_k]:
177
+ results.append({
178
+ 'node_id': node_id,
179
+ 'topic_name': topic_name,
180
+ 'summary': summary,
181
+ 'similarity': sim,
182
+ })
183
+ return results
184
+
185
+ def delete_topic_summary(self, node_id):
186
+ """Remove a topic summary row by node_id (used by reorganizer on merge)."""
187
+ conn = sqlite3.connect(self.db_path)
188
+ cursor = conn.cursor()
189
+ cursor.execute('DELETE FROM topic_summaries WHERE node_id = ?', (node_id,))
190
+ conn.commit()
191
+ conn.close()