claude-memory-agent 2.0.0
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.
- package/.env.example +107 -0
- package/README.md +200 -0
- package/agent_card.py +512 -0
- package/bin/cli.js +181 -0
- package/bin/postinstall.js +216 -0
- package/config.py +104 -0
- package/dashboard.html +2689 -0
- package/hooks/README.md +196 -0
- package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
- package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/hooks/auto-detect-response.py +348 -0
- package/hooks/auto_capture.py +255 -0
- package/hooks/detect-correction.py +173 -0
- package/hooks/grounding-hook.py +348 -0
- package/hooks/log-tool-use.py +234 -0
- package/hooks/log-user-request.py +208 -0
- package/hooks/pre-tool-decision.py +218 -0
- package/hooks/problem-detector.py +343 -0
- package/hooks/session_end.py +192 -0
- package/hooks/session_start.py +227 -0
- package/install.py +887 -0
- package/main.py +2859 -0
- package/manager.py +997 -0
- package/package.json +55 -0
- package/requirements.txt +8 -0
- package/run_server.py +136 -0
- package/services/__init__.py +50 -0
- package/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
- package/services/__pycache__/auth.cpython-312.pyc +0 -0
- package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
- package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
- package/services/__pycache__/confidence.cpython-312.pyc +0 -0
- package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
- package/services/__pycache__/database.cpython-312.pyc +0 -0
- package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
- package/services/__pycache__/insights.cpython-312.pyc +0 -0
- package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
- package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
- package/services/__pycache__/timeline.cpython-312.pyc +0 -0
- package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
- package/services/__pycache__/websocket.cpython-312.pyc +0 -0
- package/services/agent_registry.py +753 -0
- package/services/auth.py +331 -0
- package/services/auto_inject.py +250 -0
- package/services/claude_md_sync.py +275 -0
- package/services/cleanup.py +667 -0
- package/services/compaction_flush.py +447 -0
- package/services/confidence.py +301 -0
- package/services/daily_log.py +333 -0
- package/services/database.py +2485 -0
- package/services/embeddings.py +358 -0
- package/services/insights.py +632 -0
- package/services/llm_analyzer.py +595 -0
- package/services/memory_md_sync.py +409 -0
- package/services/retry_queue.py +453 -0
- package/services/timeline.py +579 -0
- package/services/vector_index.py +398 -0
- package/services/websocket.py +257 -0
- package/skills/__init__.py +6 -0
- package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/__pycache__/admin.cpython-312.pyc +0 -0
- package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
- package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
- package/skills/__pycache__/insights.cpython-312.pyc +0 -0
- package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
- package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
- package/skills/__pycache__/search.cpython-312.pyc +0 -0
- package/skills/__pycache__/state.cpython-312.pyc +0 -0
- package/skills/__pycache__/store.cpython-312.pyc +0 -0
- package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
- package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
- package/skills/__pycache__/verification.cpython-312.pyc +0 -0
- package/skills/admin.py +469 -0
- package/skills/checkpoint.py +198 -0
- package/skills/claude_md.py +363 -0
- package/skills/cleanup.py +241 -0
- package/skills/grounding.py +801 -0
- package/skills/insights.py +231 -0
- package/skills/natural_language.py +277 -0
- package/skills/retrieve.py +67 -0
- package/skills/search.py +213 -0
- package/skills/state.py +182 -0
- package/skills/store.py +179 -0
- package/skills/summarize.py +588 -0
- package/skills/timeline.py +387 -0
- package/skills/verification.py +391 -0
- package/start_daemon.py +155 -0
- package/test_automation.py +221 -0
- package/test_complete.py +338 -0
- package/test_full.py +322 -0
- package/update_system.py +817 -0
- package/verify_db.py +134 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""Grounding skills for anti-hallucination checks with anchor conflict resolution."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, Any, Optional, List
|
|
5
|
+
from services.database import DatabaseService
|
|
6
|
+
from services.embeddings import EmbeddingService
|
|
7
|
+
from services.timeline import TimelineService
|
|
8
|
+
|
|
9
|
+
USE_LLM_ANALYSIS = os.getenv("USE_LLM_ANALYSIS", "true").lower() == "true"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def context_refresh(
|
|
13
|
+
db: DatabaseService,
|
|
14
|
+
embeddings: EmbeddingService,
|
|
15
|
+
session_id: str,
|
|
16
|
+
query: Optional[str] = None,
|
|
17
|
+
include_recent_events: int = 10,
|
|
18
|
+
include_state: bool = True,
|
|
19
|
+
include_checkpoint: bool = True,
|
|
20
|
+
include_relevant_memories: bool = True,
|
|
21
|
+
check_contradictions: bool = True
|
|
22
|
+
) -> Dict[str, Any]:
|
|
23
|
+
"""
|
|
24
|
+
Pre-response grounding check. Call this before complex responses.
|
|
25
|
+
|
|
26
|
+
Provides current context to prevent hallucinations by grounding
|
|
27
|
+
Claude in what has actually happened and been decided.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
db: Database service instance
|
|
31
|
+
embeddings: Embedding service instance
|
|
32
|
+
session_id: The session ID
|
|
33
|
+
query: What Claude is about to respond about (for relevance)
|
|
34
|
+
include_recent_events: Number of recent events to include
|
|
35
|
+
include_state: Include current session state
|
|
36
|
+
include_checkpoint: Include latest checkpoint
|
|
37
|
+
include_relevant_memories: Search for relevant memories
|
|
38
|
+
check_contradictions: Check for potential contradictions
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dict with grounding context
|
|
42
|
+
"""
|
|
43
|
+
timeline = TimelineService(db, embeddings)
|
|
44
|
+
|
|
45
|
+
result = {
|
|
46
|
+
"success": True,
|
|
47
|
+
"session_id": session_id,
|
|
48
|
+
"state": None, # Full state for staleness checks
|
|
49
|
+
"grounding": {
|
|
50
|
+
"current_goal": None,
|
|
51
|
+
"entity_registry": {},
|
|
52
|
+
"recent_events": [],
|
|
53
|
+
"anchors": [],
|
|
54
|
+
"decisions": [],
|
|
55
|
+
"checkpoint_summary": None,
|
|
56
|
+
"relevant_memories": [],
|
|
57
|
+
"contradictions": [],
|
|
58
|
+
"unresolved_conflicts": []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Get current state
|
|
63
|
+
if include_state:
|
|
64
|
+
state = await db.get_or_create_session_state(session_id)
|
|
65
|
+
result["state"] = state # Include full state for staleness checks
|
|
66
|
+
result["grounding"]["current_goal"] = state.get("current_goal")
|
|
67
|
+
result["grounding"]["entity_registry"] = state.get("entity_registry", {})
|
|
68
|
+
result["grounding"]["pending_questions"] = state.get("pending_questions", [])
|
|
69
|
+
|
|
70
|
+
# Get recent events
|
|
71
|
+
if include_recent_events > 0:
|
|
72
|
+
events = await db.get_timeline_events(
|
|
73
|
+
session_id=session_id,
|
|
74
|
+
limit=include_recent_events
|
|
75
|
+
)
|
|
76
|
+
result["grounding"]["recent_events"] = [
|
|
77
|
+
{
|
|
78
|
+
"type": e["event_type"],
|
|
79
|
+
"summary": e["summary"],
|
|
80
|
+
"is_anchor": e.get("is_anchor", False)
|
|
81
|
+
}
|
|
82
|
+
for e in events
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# Extract anchors (verified facts)
|
|
86
|
+
result["grounding"]["anchors"] = [
|
|
87
|
+
e["summary"] for e in events if e.get("is_anchor")
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
# Extract decisions
|
|
91
|
+
result["grounding"]["decisions"] = [
|
|
92
|
+
e["summary"] for e in events if e.get("event_type") == "decision"
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# Get latest checkpoint
|
|
96
|
+
if include_checkpoint:
|
|
97
|
+
checkpoint = await db.get_latest_checkpoint(session_id)
|
|
98
|
+
if checkpoint:
|
|
99
|
+
result["grounding"]["checkpoint_summary"] = checkpoint.get("summary")
|
|
100
|
+
# Add checkpoint's key facts to anchors
|
|
101
|
+
if checkpoint.get("key_facts"):
|
|
102
|
+
result["grounding"]["anchors"].extend(checkpoint["key_facts"])
|
|
103
|
+
|
|
104
|
+
# Search relevant memories
|
|
105
|
+
if include_relevant_memories and query and embeddings:
|
|
106
|
+
embedding = await embeddings.generate_embedding(query)
|
|
107
|
+
if embedding:
|
|
108
|
+
memories = await db.search_similar(
|
|
109
|
+
embedding=embedding,
|
|
110
|
+
limit=5,
|
|
111
|
+
threshold=0.6
|
|
112
|
+
)
|
|
113
|
+
result["grounding"]["relevant_memories"] = [
|
|
114
|
+
{
|
|
115
|
+
"type": m["type"],
|
|
116
|
+
"content": m["content"][:200],
|
|
117
|
+
"similarity": m["similarity"]
|
|
118
|
+
}
|
|
119
|
+
for m in memories
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# Check for contradictions
|
|
123
|
+
if check_contradictions and query:
|
|
124
|
+
contradictions = await _find_contradictions(
|
|
125
|
+
db, embeddings, query, session_id
|
|
126
|
+
)
|
|
127
|
+
result["grounding"]["contradictions"] = contradictions
|
|
128
|
+
|
|
129
|
+
# Check for unresolved anchor conflicts
|
|
130
|
+
conflicts = await get_unresolved_conflicts(db, session_id)
|
|
131
|
+
if conflicts.get("conflicts"):
|
|
132
|
+
result["grounding"]["unresolved_conflicts"] = conflicts["conflicts"]
|
|
133
|
+
|
|
134
|
+
# Generate grounding summary
|
|
135
|
+
result["grounding_summary"] = _generate_grounding_summary(result["grounding"])
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def check_contradictions(
|
|
141
|
+
db: DatabaseService,
|
|
142
|
+
embeddings: EmbeddingService,
|
|
143
|
+
statement: str,
|
|
144
|
+
session_id: Optional[str] = None,
|
|
145
|
+
scope: str = "session"
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Check if a statement contradicts known facts or decisions.
|
|
149
|
+
|
|
150
|
+
Uses LLM-based analysis when available for more accurate detection.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
db: Database service instance
|
|
154
|
+
embeddings: Embedding service instance
|
|
155
|
+
statement: The statement to check
|
|
156
|
+
session_id: Session to check against
|
|
157
|
+
scope: "session" (current only), "project", or "all"
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dict with contradiction analysis
|
|
161
|
+
"""
|
|
162
|
+
# Get anchors for LLM-based checking
|
|
163
|
+
anchors = []
|
|
164
|
+
if session_id:
|
|
165
|
+
events = await db.get_timeline_events(
|
|
166
|
+
session_id=session_id,
|
|
167
|
+
limit=50,
|
|
168
|
+
anchors_only=True
|
|
169
|
+
)
|
|
170
|
+
anchors = [e["summary"] for e in events if e.get("is_anchor")]
|
|
171
|
+
|
|
172
|
+
# Try LLM-based analysis first
|
|
173
|
+
llm_result = None
|
|
174
|
+
if USE_LLM_ANALYSIS and anchors:
|
|
175
|
+
try:
|
|
176
|
+
from services.llm_analyzer import LLMAnalyzer
|
|
177
|
+
analyzer = LLMAnalyzer()
|
|
178
|
+
llm_result = await analyzer.check_statement_against_facts(
|
|
179
|
+
statement, anchors
|
|
180
|
+
)
|
|
181
|
+
except:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Fall back to embedding-based search
|
|
185
|
+
contradictions = await _find_contradictions(
|
|
186
|
+
db, embeddings, statement, session_id, scope
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Merge results
|
|
190
|
+
if llm_result and llm_result.get("has_contradiction"):
|
|
191
|
+
contradictions.insert(0, {
|
|
192
|
+
"type": "llm_analysis",
|
|
193
|
+
"content": llm_result.get("conflicting_fact", "Unknown fact"),
|
|
194
|
+
"reason": llm_result.get("reason", "LLM detected contradiction"),
|
|
195
|
+
"confidence": 0.9 # High confidence for LLM detection
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"success": True,
|
|
200
|
+
"statement": statement,
|
|
201
|
+
"has_contradictions": len(contradictions) > 0,
|
|
202
|
+
"contradictions": contradictions,
|
|
203
|
+
"confidence": 1.0 - (len(contradictions) * 0.2) if contradictions else 1.0,
|
|
204
|
+
"message": f"Found {len(contradictions)} potential contradictions" if contradictions else "No contradictions found",
|
|
205
|
+
"analysis_method": "llm" if llm_result else "embedding"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _find_contradictions(
|
|
210
|
+
db: DatabaseService,
|
|
211
|
+
embeddings: EmbeddingService,
|
|
212
|
+
statement: str,
|
|
213
|
+
session_id: Optional[str] = None,
|
|
214
|
+
scope: str = "session"
|
|
215
|
+
) -> List[Dict[str, Any]]:
|
|
216
|
+
"""Find potential contradictions to a statement."""
|
|
217
|
+
contradictions = []
|
|
218
|
+
|
|
219
|
+
if not embeddings:
|
|
220
|
+
return contradictions
|
|
221
|
+
|
|
222
|
+
# Generate embedding for statement
|
|
223
|
+
embedding = await embeddings.generate_embedding(statement)
|
|
224
|
+
if not embedding:
|
|
225
|
+
return contradictions
|
|
226
|
+
|
|
227
|
+
# Search timeline events (anchors and decisions)
|
|
228
|
+
if session_id:
|
|
229
|
+
events = await db.search_timeline_events(
|
|
230
|
+
embedding=embedding,
|
|
231
|
+
session_id=session_id if scope == "session" else None,
|
|
232
|
+
limit=10,
|
|
233
|
+
threshold=0.7 # High similarity = potentially contradictory
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
for event in events:
|
|
237
|
+
# Check if this might contradict
|
|
238
|
+
if event.get("is_anchor") or event.get("event_type") == "decision":
|
|
239
|
+
# Simple heuristic: high similarity to an anchor/decision
|
|
240
|
+
# might indicate contradiction OR confirmation
|
|
241
|
+
# Flag for human review
|
|
242
|
+
contradictions.append({
|
|
243
|
+
"type": "timeline_event",
|
|
244
|
+
"event_type": event.get("event_type"),
|
|
245
|
+
"content": event.get("summary"),
|
|
246
|
+
"similarity": event.get("similarity"),
|
|
247
|
+
"reason": "High similarity to established fact/decision - verify alignment"
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
# Search memories for contradictions
|
|
251
|
+
memories = await db.search_similar(
|
|
252
|
+
embedding=embedding,
|
|
253
|
+
limit=5,
|
|
254
|
+
memory_type="decision",
|
|
255
|
+
session_id=session_id if scope == "session" else None,
|
|
256
|
+
threshold=0.7
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
for memory in memories:
|
|
260
|
+
contradictions.append({
|
|
261
|
+
"type": "memory",
|
|
262
|
+
"memory_type": memory.get("type"),
|
|
263
|
+
"content": memory.get("content")[:200],
|
|
264
|
+
"similarity": memory.get("similarity"),
|
|
265
|
+
"reason": "Similar decision/fact found in memory - verify consistency"
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
# Deduplicate and limit
|
|
269
|
+
seen = set()
|
|
270
|
+
unique_contradictions = []
|
|
271
|
+
for c in contradictions:
|
|
272
|
+
key = c.get("content", "")[:50]
|
|
273
|
+
if key not in seen:
|
|
274
|
+
seen.add(key)
|
|
275
|
+
unique_contradictions.append(c)
|
|
276
|
+
|
|
277
|
+
return unique_contradictions[:5] # Limit to top 5
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _generate_grounding_summary(grounding: Dict[str, Any]) -> str:
|
|
281
|
+
"""Generate a concise grounding summary."""
|
|
282
|
+
parts = []
|
|
283
|
+
|
|
284
|
+
if grounding.get("current_goal"):
|
|
285
|
+
parts.append(f"Goal: {grounding['current_goal']}")
|
|
286
|
+
|
|
287
|
+
if grounding.get("anchors"):
|
|
288
|
+
parts.append(f"Facts: {len(grounding['anchors'])} verified")
|
|
289
|
+
|
|
290
|
+
if grounding.get("decisions"):
|
|
291
|
+
parts.append(f"Decisions: {len(grounding['decisions'])} made")
|
|
292
|
+
|
|
293
|
+
if grounding.get("entity_registry"):
|
|
294
|
+
entities = list(grounding["entity_registry"].keys())[:3]
|
|
295
|
+
if entities:
|
|
296
|
+
parts.append(f"Entities: {', '.join(entities)}")
|
|
297
|
+
|
|
298
|
+
if grounding.get("contradictions"):
|
|
299
|
+
parts.append(f"WARNINGS: {len(grounding['contradictions'])} potential contradictions")
|
|
300
|
+
|
|
301
|
+
if grounding.get("unresolved_conflicts"):
|
|
302
|
+
parts.append(f"CONFLICTS: {len(grounding['unresolved_conflicts'])} unresolved anchor conflicts")
|
|
303
|
+
|
|
304
|
+
return " | ".join(parts) if parts else "No context loaded"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def verify_entity(
|
|
308
|
+
db: DatabaseService,
|
|
309
|
+
session_id: str,
|
|
310
|
+
entity_key: str,
|
|
311
|
+
entity_type: Optional[str] = None
|
|
312
|
+
) -> Dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Verify an entity reference against the registry.
|
|
315
|
+
|
|
316
|
+
Use this when you're about to reference a file, variable, or other entity
|
|
317
|
+
to ensure you have the correct one.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
db: Database service instance
|
|
321
|
+
session_id: The session ID
|
|
322
|
+
entity_key: The entity key to verify (e.g., "auth_file")
|
|
323
|
+
entity_type: Optional type filter ("file", "function", etc.)
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Dict with verification result
|
|
327
|
+
"""
|
|
328
|
+
state = await db.get_or_create_session_state(session_id)
|
|
329
|
+
registry = state.get("entity_registry", {})
|
|
330
|
+
|
|
331
|
+
if entity_key in registry:
|
|
332
|
+
return {
|
|
333
|
+
"success": True,
|
|
334
|
+
"verified": True,
|
|
335
|
+
"entity_key": entity_key,
|
|
336
|
+
"entity_value": registry[entity_key],
|
|
337
|
+
"message": f"Entity '{entity_key}' verified: {registry[entity_key]}"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# Try to find similar keys
|
|
341
|
+
similar = [k for k in registry.keys() if entity_key.lower() in k.lower() or k.lower() in entity_key.lower()]
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"success": True,
|
|
345
|
+
"verified": False,
|
|
346
|
+
"entity_key": entity_key,
|
|
347
|
+
"entity_value": None,
|
|
348
|
+
"similar_entities": {k: registry[k] for k in similar[:3]},
|
|
349
|
+
"message": f"Entity '{entity_key}' not found in registry. Similar: {similar[:3]}"
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def mark_anchor(
|
|
354
|
+
db: DatabaseService,
|
|
355
|
+
embeddings: EmbeddingService,
|
|
356
|
+
session_id: str,
|
|
357
|
+
fact: str,
|
|
358
|
+
details: Optional[str] = None,
|
|
359
|
+
project_path: Optional[str] = None,
|
|
360
|
+
force: bool = False
|
|
361
|
+
) -> Dict[str, Any]:
|
|
362
|
+
"""
|
|
363
|
+
Mark a statement as a verified anchor fact with conflict detection.
|
|
364
|
+
|
|
365
|
+
Before creating the anchor, checks for potential conflicts with existing
|
|
366
|
+
anchors. If conflicts are found, can optionally proceed with force=True.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
db: Database service instance
|
|
370
|
+
embeddings: Embedding service instance
|
|
371
|
+
session_id: The session ID
|
|
372
|
+
fact: The verified fact
|
|
373
|
+
details: Additional context
|
|
374
|
+
project_path: Project path
|
|
375
|
+
force: If True, create anchor even if conflicts exist
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Dict with anchor info and any detected conflicts
|
|
379
|
+
"""
|
|
380
|
+
timeline = TimelineService(db, embeddings)
|
|
381
|
+
|
|
382
|
+
# Check for conflicts with existing anchors
|
|
383
|
+
conflicts = []
|
|
384
|
+
if embeddings:
|
|
385
|
+
fact_embedding = await embeddings.generate_embedding(fact)
|
|
386
|
+
if fact_embedding:
|
|
387
|
+
# Search existing anchors for high similarity
|
|
388
|
+
existing = await db.search_timeline_events(
|
|
389
|
+
embedding=fact_embedding,
|
|
390
|
+
session_id=session_id,
|
|
391
|
+
limit=10,
|
|
392
|
+
threshold=0.75 # High similarity threshold
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
for event in existing:
|
|
396
|
+
if event.get("is_anchor") and event.get("similarity", 0) > 0.75:
|
|
397
|
+
# Check if it's a potential contradiction or update
|
|
398
|
+
conflict_type = _classify_conflict(fact, event.get("summary", ""))
|
|
399
|
+
if conflict_type != "identical":
|
|
400
|
+
conflicts.append({
|
|
401
|
+
"anchor_id": event.get("id"),
|
|
402
|
+
"summary": event.get("summary"),
|
|
403
|
+
"similarity": event.get("similarity"),
|
|
404
|
+
"conflict_type": conflict_type
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
# If conflicts exist and not forcing, return without creating
|
|
408
|
+
if conflicts and not force:
|
|
409
|
+
return {
|
|
410
|
+
"success": False,
|
|
411
|
+
"has_conflicts": True,
|
|
412
|
+
"conflicts": conflicts,
|
|
413
|
+
"fact": fact,
|
|
414
|
+
"message": f"Found {len(conflicts)} potential conflicts with existing anchors. "
|
|
415
|
+
f"Use force=True to create anyway, or resolve conflicts first."
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
# Create the anchor event
|
|
419
|
+
event_id = await timeline.log_event(
|
|
420
|
+
session_id=session_id,
|
|
421
|
+
event_type="anchor",
|
|
422
|
+
summary=fact,
|
|
423
|
+
details=details,
|
|
424
|
+
project_path=project_path,
|
|
425
|
+
confidence=1.0,
|
|
426
|
+
is_anchor=True
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# If there were conflicts and we're forcing, record them
|
|
430
|
+
if conflicts:
|
|
431
|
+
for conflict in conflicts:
|
|
432
|
+
await _record_conflict(
|
|
433
|
+
db=db,
|
|
434
|
+
session_id=session_id,
|
|
435
|
+
project_path=project_path,
|
|
436
|
+
anchor1_id=conflict["anchor_id"],
|
|
437
|
+
anchor2_id=event_id,
|
|
438
|
+
anchor1_summary=conflict["summary"],
|
|
439
|
+
anchor2_summary=fact,
|
|
440
|
+
conflict_type=conflict["conflict_type"],
|
|
441
|
+
similarity=conflict.get("similarity", 0)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Log anchor history
|
|
445
|
+
await _log_anchor_history(
|
|
446
|
+
db=db,
|
|
447
|
+
anchor_id=event_id,
|
|
448
|
+
session_id=session_id,
|
|
449
|
+
project_path=project_path,
|
|
450
|
+
action="created",
|
|
451
|
+
new_summary=fact,
|
|
452
|
+
reason="Manual anchor creation"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
"success": True,
|
|
457
|
+
"event_id": event_id,
|
|
458
|
+
"fact": fact,
|
|
459
|
+
"conflicts_recorded": len(conflicts) if conflicts else 0,
|
|
460
|
+
"message": f"Anchor fact established: {fact[:50]}..." +
|
|
461
|
+
(f" (with {len(conflicts)} conflicts recorded)" if conflicts else "")
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _classify_conflict(new_fact: str, existing_fact: str) -> str:
|
|
466
|
+
"""Classify the type of conflict between two facts."""
|
|
467
|
+
new_lower = new_fact.lower()
|
|
468
|
+
existing_lower = existing_fact.lower()
|
|
469
|
+
|
|
470
|
+
# Check for negation patterns
|
|
471
|
+
negation_words = ["not", "don't", "doesn't", "isn't", "aren't", "wasn't", "weren't", "no longer", "never"]
|
|
472
|
+
new_has_negation = any(word in new_lower for word in negation_words)
|
|
473
|
+
existing_has_negation = any(word in existing_lower for word in negation_words)
|
|
474
|
+
|
|
475
|
+
if new_has_negation != existing_has_negation:
|
|
476
|
+
return "contradiction"
|
|
477
|
+
|
|
478
|
+
# Check if it's likely an update (same subject, different details)
|
|
479
|
+
# Simple heuristic: first 5 words similar
|
|
480
|
+
new_words = new_lower.split()[:5]
|
|
481
|
+
existing_words = existing_lower.split()[:5]
|
|
482
|
+
common_words = len(set(new_words) & set(existing_words))
|
|
483
|
+
|
|
484
|
+
if common_words >= 3:
|
|
485
|
+
return "update"
|
|
486
|
+
|
|
487
|
+
if new_lower == existing_lower:
|
|
488
|
+
return "identical"
|
|
489
|
+
|
|
490
|
+
return "potential_conflict"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def _record_conflict(
|
|
494
|
+
db: DatabaseService,
|
|
495
|
+
session_id: str,
|
|
496
|
+
project_path: Optional[str],
|
|
497
|
+
anchor1_id: int,
|
|
498
|
+
anchor2_id: int,
|
|
499
|
+
anchor1_summary: str,
|
|
500
|
+
anchor2_summary: str,
|
|
501
|
+
conflict_type: str,
|
|
502
|
+
similarity: float
|
|
503
|
+
):
|
|
504
|
+
"""Record an anchor conflict for later resolution."""
|
|
505
|
+
cursor = db.conn.cursor()
|
|
506
|
+
cursor.execute(
|
|
507
|
+
"""
|
|
508
|
+
INSERT INTO anchor_conflicts (
|
|
509
|
+
session_id, project_path, anchor1_id, anchor2_id,
|
|
510
|
+
anchor1_summary, anchor2_summary, conflict_type, similarity_score
|
|
511
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
512
|
+
""",
|
|
513
|
+
(session_id, project_path, anchor1_id, anchor2_id,
|
|
514
|
+
anchor1_summary, anchor2_summary, conflict_type, similarity)
|
|
515
|
+
)
|
|
516
|
+
db.conn.commit()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
async def _log_anchor_history(
|
|
520
|
+
db: DatabaseService,
|
|
521
|
+
anchor_id: int,
|
|
522
|
+
session_id: str,
|
|
523
|
+
project_path: Optional[str],
|
|
524
|
+
action: str,
|
|
525
|
+
previous_summary: Optional[str] = None,
|
|
526
|
+
new_summary: Optional[str] = None,
|
|
527
|
+
superseded_by: Optional[int] = None,
|
|
528
|
+
reason: Optional[str] = None,
|
|
529
|
+
confidence: float = 1.0
|
|
530
|
+
):
|
|
531
|
+
"""Log anchor history for tracking fact evolution."""
|
|
532
|
+
cursor = db.conn.cursor()
|
|
533
|
+
cursor.execute(
|
|
534
|
+
"""
|
|
535
|
+
INSERT INTO anchor_history (
|
|
536
|
+
anchor_id, session_id, project_path, action,
|
|
537
|
+
previous_summary, new_summary, superseded_by,
|
|
538
|
+
reason, confidence
|
|
539
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
540
|
+
""",
|
|
541
|
+
(anchor_id, session_id, project_path, action,
|
|
542
|
+
previous_summary, new_summary, superseded_by, reason, confidence)
|
|
543
|
+
)
|
|
544
|
+
db.conn.commit()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def get_unresolved_conflicts(
|
|
548
|
+
db: DatabaseService,
|
|
549
|
+
session_id: Optional[str] = None,
|
|
550
|
+
project_path: Optional[str] = None,
|
|
551
|
+
limit: int = 20
|
|
552
|
+
) -> Dict[str, Any]:
|
|
553
|
+
"""Get unresolved anchor conflicts."""
|
|
554
|
+
cursor = db.conn.cursor()
|
|
555
|
+
|
|
556
|
+
query = "SELECT * FROM anchor_conflicts WHERE status = 'unresolved'"
|
|
557
|
+
params = []
|
|
558
|
+
|
|
559
|
+
if session_id:
|
|
560
|
+
query += " AND session_id = ?"
|
|
561
|
+
params.append(session_id)
|
|
562
|
+
|
|
563
|
+
if project_path:
|
|
564
|
+
query += " AND project_path = ?"
|
|
565
|
+
params.append(project_path)
|
|
566
|
+
|
|
567
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
568
|
+
params.append(limit)
|
|
569
|
+
|
|
570
|
+
cursor.execute(query, tuple(params))
|
|
571
|
+
rows = cursor.fetchall()
|
|
572
|
+
|
|
573
|
+
conflicts = [dict(row) for row in rows] if rows else []
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
"success": True,
|
|
577
|
+
"conflicts": conflicts,
|
|
578
|
+
"count": len(conflicts)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def resolve_conflict(
|
|
583
|
+
db: DatabaseService,
|
|
584
|
+
embeddings: EmbeddingService,
|
|
585
|
+
conflict_id: int,
|
|
586
|
+
resolution: str,
|
|
587
|
+
keep_anchor_id: Optional[int] = None,
|
|
588
|
+
resolved_by: str = "user"
|
|
589
|
+
) -> Dict[str, Any]:
|
|
590
|
+
"""
|
|
591
|
+
Resolve an anchor conflict.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
db: Database service
|
|
595
|
+
embeddings: Embeddings service
|
|
596
|
+
conflict_id: ID of the conflict to resolve
|
|
597
|
+
resolution: One of "keep_first", "keep_second", "keep_both", "supersede"
|
|
598
|
+
keep_anchor_id: For "supersede", which anchor supersedes the other
|
|
599
|
+
resolved_by: Who resolved it (user, auto, etc.)
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Resolution result
|
|
603
|
+
"""
|
|
604
|
+
cursor = db.conn.cursor()
|
|
605
|
+
|
|
606
|
+
# Get the conflict
|
|
607
|
+
cursor.execute("SELECT * FROM anchor_conflicts WHERE id = ?", (conflict_id,))
|
|
608
|
+
conflict = cursor.fetchone()
|
|
609
|
+
|
|
610
|
+
if not conflict:
|
|
611
|
+
return {"success": False, "error": "Conflict not found"}
|
|
612
|
+
|
|
613
|
+
conflict = dict(conflict)
|
|
614
|
+
anchor1_id = conflict["anchor1_id"]
|
|
615
|
+
anchor2_id = conflict["anchor2_id"]
|
|
616
|
+
|
|
617
|
+
# Handle resolution
|
|
618
|
+
resolved_anchor_id = None
|
|
619
|
+
|
|
620
|
+
if resolution == "keep_first":
|
|
621
|
+
# Mark second anchor as superseded
|
|
622
|
+
resolved_anchor_id = anchor1_id
|
|
623
|
+
await _log_anchor_history(
|
|
624
|
+
db=db,
|
|
625
|
+
anchor_id=anchor2_id,
|
|
626
|
+
session_id=conflict.get("session_id"),
|
|
627
|
+
project_path=conflict.get("project_path"),
|
|
628
|
+
action="superseded",
|
|
629
|
+
superseded_by=anchor1_id,
|
|
630
|
+
reason=f"Conflict resolution: kept anchor {anchor1_id}"
|
|
631
|
+
)
|
|
632
|
+
# Optionally mark the timeline event as non-anchor
|
|
633
|
+
cursor.execute(
|
|
634
|
+
"UPDATE timeline_events SET is_anchor = 0 WHERE id = ?",
|
|
635
|
+
(anchor2_id,)
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
elif resolution == "keep_second":
|
|
639
|
+
resolved_anchor_id = anchor2_id
|
|
640
|
+
await _log_anchor_history(
|
|
641
|
+
db=db,
|
|
642
|
+
anchor_id=anchor1_id,
|
|
643
|
+
session_id=conflict.get("session_id"),
|
|
644
|
+
project_path=conflict.get("project_path"),
|
|
645
|
+
action="superseded",
|
|
646
|
+
superseded_by=anchor2_id,
|
|
647
|
+
reason=f"Conflict resolution: kept anchor {anchor2_id}"
|
|
648
|
+
)
|
|
649
|
+
cursor.execute(
|
|
650
|
+
"UPDATE timeline_events SET is_anchor = 0 WHERE id = ?",
|
|
651
|
+
(anchor1_id,)
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
elif resolution == "keep_both":
|
|
655
|
+
# Both remain as anchors, just mark conflict as acknowledged
|
|
656
|
+
resolved_anchor_id = None
|
|
657
|
+
await _log_anchor_history(
|
|
658
|
+
db=db,
|
|
659
|
+
anchor_id=anchor1_id,
|
|
660
|
+
session_id=conflict.get("session_id"),
|
|
661
|
+
project_path=conflict.get("project_path"),
|
|
662
|
+
action="conflict_acknowledged",
|
|
663
|
+
reason="Both anchors kept despite potential conflict"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
elif resolution == "supersede" and keep_anchor_id:
|
|
667
|
+
resolved_anchor_id = keep_anchor_id
|
|
668
|
+
superseded_id = anchor2_id if keep_anchor_id == anchor1_id else anchor1_id
|
|
669
|
+
await _log_anchor_history(
|
|
670
|
+
db=db,
|
|
671
|
+
anchor_id=superseded_id,
|
|
672
|
+
session_id=conflict.get("session_id"),
|
|
673
|
+
project_path=conflict.get("project_path"),
|
|
674
|
+
action="superseded",
|
|
675
|
+
superseded_by=keep_anchor_id,
|
|
676
|
+
reason=f"Manual supersession by anchor {keep_anchor_id}"
|
|
677
|
+
)
|
|
678
|
+
cursor.execute(
|
|
679
|
+
"UPDATE timeline_events SET is_anchor = 0 WHERE id = ?",
|
|
680
|
+
(superseded_id,)
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# Update conflict status
|
|
684
|
+
cursor.execute(
|
|
685
|
+
"""
|
|
686
|
+
UPDATE anchor_conflicts
|
|
687
|
+
SET status = 'resolved',
|
|
688
|
+
resolution = ?,
|
|
689
|
+
resolved_anchor_id = ?,
|
|
690
|
+
resolved_at = datetime('now'),
|
|
691
|
+
resolved_by = ?
|
|
692
|
+
WHERE id = ?
|
|
693
|
+
""",
|
|
694
|
+
(resolution, resolved_anchor_id, resolved_by, conflict_id)
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
db.conn.commit()
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
"success": True,
|
|
701
|
+
"conflict_id": conflict_id,
|
|
702
|
+
"resolution": resolution,
|
|
703
|
+
"resolved_anchor_id": resolved_anchor_id,
|
|
704
|
+
"message": f"Conflict resolved: {resolution}"
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
async def get_anchor_history(
|
|
709
|
+
db: DatabaseService,
|
|
710
|
+
anchor_id: Optional[int] = None,
|
|
711
|
+
session_id: Optional[str] = None,
|
|
712
|
+
limit: int = 50
|
|
713
|
+
) -> Dict[str, Any]:
|
|
714
|
+
"""Get anchor history for tracking fact evolution."""
|
|
715
|
+
cursor = db.conn.cursor()
|
|
716
|
+
|
|
717
|
+
query = "SELECT * FROM anchor_history WHERE 1=1"
|
|
718
|
+
params = []
|
|
719
|
+
|
|
720
|
+
if anchor_id:
|
|
721
|
+
query += " AND anchor_id = ?"
|
|
722
|
+
params.append(anchor_id)
|
|
723
|
+
|
|
724
|
+
if session_id:
|
|
725
|
+
query += " AND session_id = ?"
|
|
726
|
+
params.append(session_id)
|
|
727
|
+
|
|
728
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
729
|
+
params.append(limit)
|
|
730
|
+
|
|
731
|
+
cursor.execute(query, tuple(params))
|
|
732
|
+
rows = cursor.fetchall()
|
|
733
|
+
|
|
734
|
+
history = [dict(row) for row in rows] if rows else []
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
"success": True,
|
|
738
|
+
"history": history,
|
|
739
|
+
"count": len(history)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
async def auto_resolve_conflicts(
|
|
744
|
+
db: DatabaseService,
|
|
745
|
+
embeddings: EmbeddingService,
|
|
746
|
+
session_id: Optional[str] = None
|
|
747
|
+
) -> Dict[str, Any]:
|
|
748
|
+
"""
|
|
749
|
+
Attempt automatic resolution of simple conflicts.
|
|
750
|
+
|
|
751
|
+
Auto-resolves:
|
|
752
|
+
- Identical duplicates (keep newer)
|
|
753
|
+
- Clear updates (same subject, newer timestamp wins)
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
db: Database service
|
|
757
|
+
embeddings: Embeddings service
|
|
758
|
+
session_id: Filter to specific session
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
Resolution results
|
|
762
|
+
"""
|
|
763
|
+
conflicts = await get_unresolved_conflicts(db, session_id)
|
|
764
|
+
resolved = 0
|
|
765
|
+
skipped = 0
|
|
766
|
+
|
|
767
|
+
for conflict in conflicts.get("conflicts", []):
|
|
768
|
+
conflict_type = conflict.get("conflict_type", "")
|
|
769
|
+
|
|
770
|
+
# Auto-resolve identical duplicates
|
|
771
|
+
if conflict_type == "identical":
|
|
772
|
+
await resolve_conflict(
|
|
773
|
+
db=db,
|
|
774
|
+
embeddings=embeddings,
|
|
775
|
+
conflict_id=conflict["id"],
|
|
776
|
+
resolution="keep_second", # Keep newer
|
|
777
|
+
resolved_by="auto"
|
|
778
|
+
)
|
|
779
|
+
resolved += 1
|
|
780
|
+
|
|
781
|
+
# Auto-resolve clear updates
|
|
782
|
+
elif conflict_type == "update":
|
|
783
|
+
await resolve_conflict(
|
|
784
|
+
db=db,
|
|
785
|
+
embeddings=embeddings,
|
|
786
|
+
conflict_id=conflict["id"],
|
|
787
|
+
resolution="keep_second", # Keep newer (update)
|
|
788
|
+
resolved_by="auto"
|
|
789
|
+
)
|
|
790
|
+
resolved += 1
|
|
791
|
+
|
|
792
|
+
else:
|
|
793
|
+
# Skip conflicts that need human review
|
|
794
|
+
skipped += 1
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
"success": True,
|
|
798
|
+
"resolved": resolved,
|
|
799
|
+
"skipped": skipped,
|
|
800
|
+
"message": f"Auto-resolved {resolved} conflicts, {skipped} need manual review"
|
|
801
|
+
}
|