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.
Files changed (100) hide show
  1. package/.env.example +107 -0
  2. package/README.md +200 -0
  3. package/agent_card.py +512 -0
  4. package/bin/cli.js +181 -0
  5. package/bin/postinstall.js +216 -0
  6. package/config.py +104 -0
  7. package/dashboard.html +2689 -0
  8. package/hooks/README.md +196 -0
  9. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  10. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  11. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  12. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  13. package/hooks/auto-detect-response.py +348 -0
  14. package/hooks/auto_capture.py +255 -0
  15. package/hooks/detect-correction.py +173 -0
  16. package/hooks/grounding-hook.py +348 -0
  17. package/hooks/log-tool-use.py +234 -0
  18. package/hooks/log-user-request.py +208 -0
  19. package/hooks/pre-tool-decision.py +218 -0
  20. package/hooks/problem-detector.py +343 -0
  21. package/hooks/session_end.py +192 -0
  22. package/hooks/session_start.py +227 -0
  23. package/install.py +887 -0
  24. package/main.py +2859 -0
  25. package/manager.py +997 -0
  26. package/package.json +55 -0
  27. package/requirements.txt +8 -0
  28. package/run_server.py +136 -0
  29. package/services/__init__.py +50 -0
  30. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  32. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  33. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  34. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  35. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  36. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  37. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  38. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  39. package/services/__pycache__/database.cpython-312.pyc +0 -0
  40. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  41. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  42. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  43. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  44. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  45. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  46. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  47. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  48. package/services/agent_registry.py +753 -0
  49. package/services/auth.py +331 -0
  50. package/services/auto_inject.py +250 -0
  51. package/services/claude_md_sync.py +275 -0
  52. package/services/cleanup.py +667 -0
  53. package/services/compaction_flush.py +447 -0
  54. package/services/confidence.py +301 -0
  55. package/services/daily_log.py +333 -0
  56. package/services/database.py +2485 -0
  57. package/services/embeddings.py +358 -0
  58. package/services/insights.py +632 -0
  59. package/services/llm_analyzer.py +595 -0
  60. package/services/memory_md_sync.py +409 -0
  61. package/services/retry_queue.py +453 -0
  62. package/services/timeline.py +579 -0
  63. package/services/vector_index.py +398 -0
  64. package/services/websocket.py +257 -0
  65. package/skills/__init__.py +6 -0
  66. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  67. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  68. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  69. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  70. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  71. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  72. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  73. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  74. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  75. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  76. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  77. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  78. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  81. package/skills/admin.py +469 -0
  82. package/skills/checkpoint.py +198 -0
  83. package/skills/claude_md.py +363 -0
  84. package/skills/cleanup.py +241 -0
  85. package/skills/grounding.py +801 -0
  86. package/skills/insights.py +231 -0
  87. package/skills/natural_language.py +277 -0
  88. package/skills/retrieve.py +67 -0
  89. package/skills/search.py +213 -0
  90. package/skills/state.py +182 -0
  91. package/skills/store.py +179 -0
  92. package/skills/summarize.py +588 -0
  93. package/skills/timeline.py +387 -0
  94. package/skills/verification.py +391 -0
  95. package/start_daemon.py +155 -0
  96. package/test_automation.py +221 -0
  97. package/test_complete.py +338 -0
  98. package/test_full.py +322 -0
  99. package/update_system.py +817 -0
  100. 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
+ }