481 lines
18 KiB
Python
481 lines
18 KiB
Python
|
|
"""
|
||
|
|
AP Knowledge Base - Father Sync
|
||
|
|
|
||
|
|
Sync with father's knowledge:
|
||
|
|
- Export memories for father review
|
||
|
|
- Import father teachings
|
||
|
|
- Conflict resolution
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import hashlib
|
||
|
|
from pathlib import Path
|
||
|
|
from datetime import datetime
|
||
|
|
from typing import List, Dict, Optional, Tuple, Set
|
||
|
|
from dataclasses import dataclass, asdict
|
||
|
|
|
||
|
|
from memory_types import MemoryEntry, MemoryScope, SyncConflict, MemoryType
|
||
|
|
from memdir import MemoryDirectory
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class SyncManifest:
|
||
|
|
"""Manifest for a sync operation."""
|
||
|
|
export_timestamp: str
|
||
|
|
source: str # 'child' or 'father'
|
||
|
|
memory_count: int
|
||
|
|
version: str = "1.0"
|
||
|
|
|
||
|
|
def to_dict(self) -> Dict:
|
||
|
|
return asdict(self)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_dict(cls, data: Dict) -> "SyncManifest":
|
||
|
|
return cls(**data)
|
||
|
|
|
||
|
|
|
||
|
|
class FatherSync:
|
||
|
|
"""
|
||
|
|
Handles synchronization between AP (child) and father's knowledge base.
|
||
|
|
|
||
|
|
Key concepts:
|
||
|
|
- Export: Package memories for father's review
|
||
|
|
- Import: Incorporate father's teachings
|
||
|
|
- Conflict resolution: Handle divergent updates
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, memdir: MemoryDirectory, father_inbox: Optional[Path] = None):
|
||
|
|
self.memdir = memdir
|
||
|
|
self.base_dir = memdir.base_dir
|
||
|
|
|
||
|
|
# Father inbox/outbox directories
|
||
|
|
self.sync_dir = self.base_dir / "sync"
|
||
|
|
self.sync_dir.mkdir(exist_ok=True)
|
||
|
|
|
||
|
|
self.outbox = self.sync_dir / "outbox"
|
||
|
|
self.outbox.mkdir(exist_ok=True)
|
||
|
|
|
||
|
|
self.inbox = father_inbox or (self.sync_dir / "inbox")
|
||
|
|
self.inbox.mkdir(exist_ok=True)
|
||
|
|
|
||
|
|
self.processed = self.sync_dir / "processed"
|
||
|
|
self.processed.mkdir(exist_ok=True)
|
||
|
|
|
||
|
|
# Sync state tracking
|
||
|
|
self.sync_state_path = self.sync_dir / "sync_state.json"
|
||
|
|
self.sync_state = self._load_sync_state()
|
||
|
|
|
||
|
|
def _load_sync_state(self) -> Dict:
|
||
|
|
"""Load sync state from disk."""
|
||
|
|
if self.sync_state_path.exists():
|
||
|
|
try:
|
||
|
|
return json.loads(self.sync_state_path.read_text())
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Warning: Failed to load sync state: {e}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
'last_export': None,
|
||
|
|
'last_import': None,
|
||
|
|
'exported_memories': {}, # memory_id -> export_timestamp
|
||
|
|
'imported_memories': {}, # memory_id -> import_timestamp
|
||
|
|
'conflicts': [] # List of unresolved conflicts
|
||
|
|
}
|
||
|
|
|
||
|
|
def _save_sync_state(self):
|
||
|
|
"""Save sync state to disk."""
|
||
|
|
self.sync_state_path.write_text(json.dumps(self.sync_state, indent=2), encoding='utf-8')
|
||
|
|
|
||
|
|
def _hash_memory(self, entry: MemoryEntry) -> str:
|
||
|
|
"""Generate a hash of memory content for change detection."""
|
||
|
|
content = f"{entry.name}:{entry.description}:{entry.content}:{entry.modified_at.isoformat()}"
|
||
|
|
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||
|
|
|
||
|
|
def export_for_father(
|
||
|
|
self,
|
||
|
|
scope: Optional[MemoryScope] = None,
|
||
|
|
types: Optional[List[MemoryType]] = None,
|
||
|
|
tags: Optional[List[str]] = None,
|
||
|
|
since_last_export: bool = True,
|
||
|
|
include_archived: bool = False
|
||
|
|
) -> Path:
|
||
|
|
"""
|
||
|
|
Export memories for father's review.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
scope: Filter by scope (private memories usually not shared)
|
||
|
|
types: Filter by memory types
|
||
|
|
tags: Filter by tags
|
||
|
|
since_last_export: Only export memories modified since last export
|
||
|
|
include_archived: Include archived memories
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Path to the exported file
|
||
|
|
"""
|
||
|
|
# Collect memories to export
|
||
|
|
to_export = []
|
||
|
|
|
||
|
|
for entry in self.memdir._memories.values():
|
||
|
|
# Skip private memories by default
|
||
|
|
if scope and entry.scope != scope:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Filter by type
|
||
|
|
if types and entry.type not in types:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Filter by tags
|
||
|
|
if tags:
|
||
|
|
entry_tags = set(t.lower() for t in entry.tags)
|
||
|
|
if not any(t.lower() in entry_tags for t in tags):
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Skip archived unless requested
|
||
|
|
if entry.metadata.get('archived') and not include_archived:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Skip if already exported and not modified
|
||
|
|
if since_last_export and self.sync_state['last_export']:
|
||
|
|
last_export = datetime.fromisoformat(self.sync_state['last_export'])
|
||
|
|
if entry.modified_at <= last_export:
|
||
|
|
# Check if hash matches
|
||
|
|
current_hash = self._hash_memory(entry)
|
||
|
|
if self.sync_state['exported_memories'].get(entry.id) == current_hash:
|
||
|
|
continue
|
||
|
|
|
||
|
|
to_export.append(entry)
|
||
|
|
|
||
|
|
# Build export package
|
||
|
|
export_data = {
|
||
|
|
'manifest': SyncManifest(
|
||
|
|
export_timestamp=datetime.utcnow().isoformat(),
|
||
|
|
source='child',
|
||
|
|
memory_count=len(to_export)
|
||
|
|
).to_dict(),
|
||
|
|
'memories': [entry.to_frontmatter() for entry in to_export],
|
||
|
|
'metadata': {
|
||
|
|
'export_reason': 'father_review',
|
||
|
|
'includes_private': scope == MemoryScope.PRIVATE,
|
||
|
|
'filters': {
|
||
|
|
'scope': scope.value if scope else None,
|
||
|
|
'types': [t.value for t in types] if types else None,
|
||
|
|
'tags': tags
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Write to outbox
|
||
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||
|
|
export_path = self.outbox / f"export_{timestamp}.json"
|
||
|
|
export_path.write_text(json.dumps(export_data, indent=2), encoding='utf-8')
|
||
|
|
|
||
|
|
# Update sync state
|
||
|
|
self.sync_state['last_export'] = datetime.utcnow().isoformat()
|
||
|
|
for entry in to_export:
|
||
|
|
self.sync_state['exported_memories'][entry.id] = self._hash_memory(entry)
|
||
|
|
self._save_sync_state()
|
||
|
|
|
||
|
|
return export_path
|
||
|
|
|
||
|
|
def import_from_father(self, auto_resolve: bool = False) -> Tuple[int, int, List[SyncConflict]]:
|
||
|
|
"""
|
||
|
|
Import teachings from father.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
auto_resolve: Automatically resolve simple conflicts
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
(imported_count, skipped_count, conflicts)
|
||
|
|
"""
|
||
|
|
imported = 0
|
||
|
|
skipped = 0
|
||
|
|
conflicts = []
|
||
|
|
|
||
|
|
# Process all files in inbox
|
||
|
|
for import_file in self.inbox.glob("import_*.json"):
|
||
|
|
try:
|
||
|
|
data = json.loads(import_file.read_text())
|
||
|
|
|
||
|
|
# Validate manifest
|
||
|
|
manifest = SyncManifest.from_dict(data.get('manifest', {}))
|
||
|
|
|
||
|
|
if manifest.source != 'father':
|
||
|
|
print(f"Warning: Skipping import from unknown source: {manifest.source}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Process each memory
|
||
|
|
for mem_text in data.get('memories', []):
|
||
|
|
entry = MemoryEntry.from_frontmatter(mem_text)
|
||
|
|
|
||
|
|
# Check for existing memory
|
||
|
|
existing = self.memdir.get(entry.id)
|
||
|
|
|
||
|
|
if existing:
|
||
|
|
# Check for conflicts
|
||
|
|
if self._has_conflict(existing, entry):
|
||
|
|
conflict = SyncConflict(
|
||
|
|
local_entry=existing,
|
||
|
|
remote_entry=entry,
|
||
|
|
conflict_type='content'
|
||
|
|
)
|
||
|
|
|
||
|
|
if auto_resolve:
|
||
|
|
resolution = self._auto_resolve(conflict)
|
||
|
|
if resolution:
|
||
|
|
conflict.resolution = resolution
|
||
|
|
if resolution == 'keep_remote':
|
||
|
|
self._apply_import(entry)
|
||
|
|
imported += 1
|
||
|
|
elif resolution == 'merge':
|
||
|
|
merged = self._merge_memories(existing, entry)
|
||
|
|
self.memdir.save(merged)
|
||
|
|
imported += 1
|
||
|
|
else: # keep_local
|
||
|
|
skipped += 1
|
||
|
|
else:
|
||
|
|
conflicts.append(conflict)
|
||
|
|
else:
|
||
|
|
conflicts.append(conflict)
|
||
|
|
skipped += 1
|
||
|
|
else:
|
||
|
|
# No conflict, skip (already have it)
|
||
|
|
skipped += 1
|
||
|
|
else:
|
||
|
|
# New memory from father
|
||
|
|
entry.source = 'father'
|
||
|
|
self._apply_import(entry)
|
||
|
|
imported += 1
|
||
|
|
|
||
|
|
# Move to processed
|
||
|
|
processed_path = self.processed / import_file.name
|
||
|
|
import_file.rename(processed_path)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Error processing {import_file}: {e}")
|
||
|
|
skipped += 1
|
||
|
|
|
||
|
|
# Update sync state
|
||
|
|
self.sync_state['last_import'] = datetime.utcnow().isoformat()
|
||
|
|
for conflict in conflicts:
|
||
|
|
self.sync_state['conflicts'].append({
|
||
|
|
'local_id': conflict.local_entry.id,
|
||
|
|
'remote_id': conflict.remote_entry.id,
|
||
|
|
'type': conflict.conflict_type,
|
||
|
|
'detected_at': datetime.utcnow().isoformat()
|
||
|
|
})
|
||
|
|
self._save_sync_state()
|
||
|
|
|
||
|
|
return imported, skipped, conflicts
|
||
|
|
|
||
|
|
def _has_conflict(self, local: MemoryEntry, remote: MemoryEntry) -> bool:
|
||
|
|
"""Check if there's a conflict between local and remote."""
|
||
|
|
# Different content = potential conflict
|
||
|
|
if local.content != remote.content:
|
||
|
|
return True
|
||
|
|
if local.description != remote.description:
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _auto_resolve(self, conflict: SyncConflict) -> Optional[str]:
|
||
|
|
"""
|
||
|
|
Attempt to auto-resolve a conflict.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Resolution strategy or None if manual resolution needed
|
||
|
|
"""
|
||
|
|
local = conflict.local_entry
|
||
|
|
remote = conflict.remote_entry
|
||
|
|
|
||
|
|
# Father's version is newer
|
||
|
|
if remote.modified_at > local.modified_at:
|
||
|
|
# If father has higher confidence, accept his
|
||
|
|
if remote.confidence > local.confidence:
|
||
|
|
return 'keep_remote'
|
||
|
|
|
||
|
|
# If local has been accessed recently, prefer local
|
||
|
|
if local.accessed_at:
|
||
|
|
days_since_access = (datetime.utcnow() - local.accessed_at).days
|
||
|
|
if days_since_access < 7:
|
||
|
|
return 'keep_local'
|
||
|
|
|
||
|
|
# If content can be merged
|
||
|
|
if self._can_merge(local, remote):
|
||
|
|
return 'merge'
|
||
|
|
|
||
|
|
# Default: no auto-resolution
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _can_merge(self, a: MemoryEntry, b: MemoryEntry) -> bool:
|
||
|
|
"""Check if two memories can be automatically merged."""
|
||
|
|
# Simple check: non-overlapping content additions
|
||
|
|
a_lines = set(a.content.split('\n'))
|
||
|
|
b_lines = set(b.content.split('\n'))
|
||
|
|
|
||
|
|
# If one is subset of other, can merge
|
||
|
|
if a_lines <= b_lines or b_lines <= a_lines:
|
||
|
|
return True
|
||
|
|
|
||
|
|
# If small overlap, probably can merge
|
||
|
|
overlap = len(a_lines & b_lines)
|
||
|
|
total = len(a_lines | b_lines)
|
||
|
|
|
||
|
|
return overlap / total > 0.5 if total > 0 else True
|
||
|
|
|
||
|
|
def _merge_memories(self, local: MemoryEntry, remote: MemoryEntry) -> MemoryEntry:
|
||
|
|
"""Merge two memories into one."""
|
||
|
|
# Combine content
|
||
|
|
local_lines = set(local.content.split('\n'))
|
||
|
|
remote_lines = set(remote.content.split('\n'))
|
||
|
|
merged_content = '\n'.join(sorted(local_lines | remote_lines))
|
||
|
|
|
||
|
|
# Prefer father's description if longer
|
||
|
|
description = remote.description if len(remote.description) > len(local.description) else local.description
|
||
|
|
|
||
|
|
# Merge tags
|
||
|
|
tags = list(set(local.tags + remote.tags))
|
||
|
|
|
||
|
|
# Create merged entry
|
||
|
|
merged = MemoryEntry(
|
||
|
|
id=local.id, # Keep local ID
|
||
|
|
name=local.name,
|
||
|
|
description=description,
|
||
|
|
type=local.type,
|
||
|
|
scope=local.scope,
|
||
|
|
content=merged_content,
|
||
|
|
tags=tags,
|
||
|
|
created_at=local.created_at,
|
||
|
|
modified_at=datetime.utcnow(),
|
||
|
|
source='merged',
|
||
|
|
confidence=max(local.confidence, remote.confidence),
|
||
|
|
file_path=local.file_path,
|
||
|
|
metadata={
|
||
|
|
**local.metadata,
|
||
|
|
'merged_from': remote.id,
|
||
|
|
'merged_at': datetime.utcnow().isoformat(),
|
||
|
|
'father_version': remote.modified_at.isoformat()
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
return merged
|
||
|
|
|
||
|
|
def _apply_import(self, entry: MemoryEntry):
|
||
|
|
"""Apply an imported memory."""
|
||
|
|
entry.modified_at = datetime.utcnow()
|
||
|
|
self.memdir.save(entry)
|
||
|
|
|
||
|
|
# Track in sync state
|
||
|
|
self.sync_state['imported_memories'][entry.id] = {
|
||
|
|
'imported_at': datetime.utcnow().isoformat(),
|
||
|
|
'original_source': entry.source
|
||
|
|
}
|
||
|
|
|
||
|
|
def resolve_conflict(
|
||
|
|
self,
|
||
|
|
conflict: SyncConflict,
|
||
|
|
resolution: str, # 'keep_local', 'keep_remote', 'merge', 'custom'
|
||
|
|
custom_content: Optional[str] = None
|
||
|
|
) -> MemoryEntry:
|
||
|
|
"""
|
||
|
|
Manually resolve a conflict.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
conflict: The conflict to resolve
|
||
|
|
resolution: Resolution strategy
|
||
|
|
custom_content: Custom content if resolution is 'custom'
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The resolved memory entry
|
||
|
|
"""
|
||
|
|
if resolution == 'keep_local':
|
||
|
|
result = conflict.local_entry
|
||
|
|
elif resolution == 'keep_remote':
|
||
|
|
result = conflict.remote_entry
|
||
|
|
result.id = conflict.local_entry.id
|
||
|
|
result.file_path = conflict.local_entry.file_path
|
||
|
|
elif resolution == 'merge':
|
||
|
|
result = self._merge_memories(conflict.local_entry, conflict.remote_entry)
|
||
|
|
elif resolution == 'custom' and custom_content:
|
||
|
|
result = conflict.local_entry
|
||
|
|
result.content = custom_content
|
||
|
|
result.modified_at = datetime.utcnow()
|
||
|
|
else:
|
||
|
|
raise ValueError(f"Invalid resolution: {resolution}")
|
||
|
|
|
||
|
|
# Save and track
|
||
|
|
result.metadata['conflict_resolved'] = True
|
||
|
|
result.metadata['resolution_strategy'] = resolution
|
||
|
|
result.metadata['resolved_at'] = datetime.utcnow().isoformat()
|
||
|
|
|
||
|
|
self.memdir.save(result)
|
||
|
|
|
||
|
|
# Remove from unresolved conflicts
|
||
|
|
self.sync_state['conflicts'] = [
|
||
|
|
c for c in self.sync_state['conflicts']
|
||
|
|
if not (c['local_id'] == conflict.local_entry.id and
|
||
|
|
c['remote_id'] == conflict.remote_entry.id)
|
||
|
|
]
|
||
|
|
self._save_sync_state()
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
def get_pending_exports(self) -> List[Path]:
|
||
|
|
"""Get list of pending export files."""
|
||
|
|
return sorted(self.outbox.glob("export_*.json"))
|
||
|
|
|
||
|
|
def get_pending_imports(self) -> List[Path]:
|
||
|
|
"""Get list of pending import files."""
|
||
|
|
return sorted(self.inbox.glob("import_*.json"))
|
||
|
|
|
||
|
|
def get_unresolved_conflicts(self) -> List[SyncConflict]:
|
||
|
|
"""Get all unresolved conflicts."""
|
||
|
|
conflicts = []
|
||
|
|
|
||
|
|
for conf_data in self.sync_state.get('conflicts', []):
|
||
|
|
local = self.memdir.get(conf_data['local_id'])
|
||
|
|
# Remote entry would need to be reconstructed from import file
|
||
|
|
# For now, just track that there's a conflict
|
||
|
|
if local:
|
||
|
|
conflicts.append(SyncConflict(
|
||
|
|
local_entry=local,
|
||
|
|
remote_entry=MemoryEntry(id=conf_data['remote_id'], name="unknown"),
|
||
|
|
conflict_type=conf_data['type']
|
||
|
|
))
|
||
|
|
|
||
|
|
return conflicts
|
||
|
|
|
||
|
|
def create_father_package(self, notes: str = "") -> Path:
|
||
|
|
"""
|
||
|
|
Create a complete package for father's review.
|
||
|
|
|
||
|
|
This includes:
|
||
|
|
- All team-scope memories
|
||
|
|
- Statistics and metadata
|
||
|
|
- Child's questions or notes
|
||
|
|
"""
|
||
|
|
# Export team memories
|
||
|
|
export_path = self.export_for_father(
|
||
|
|
scope=MemoryScope.TEAM,
|
||
|
|
since_last_export=False
|
||
|
|
)
|
||
|
|
|
||
|
|
# Add notes
|
||
|
|
data = json.loads(export_path.read_text())
|
||
|
|
data['notes'] = notes
|
||
|
|
data['questions'] = [] # Child can ask father questions
|
||
|
|
data['stats'] = self.memdir.get_stats()
|
||
|
|
|
||
|
|
export_path.write_text(json.dumps(data, indent=2), encoding='utf-8')
|
||
|
|
|
||
|
|
return export_path
|
||
|
|
|
||
|
|
def sync_status(self) -> Dict:
|
||
|
|
"""Get current sync status."""
|
||
|
|
return {
|
||
|
|
'last_export': self.sync_state['last_export'],
|
||
|
|
'last_import': self.sync_state['last_import'],
|
||
|
|
'pending_exports': len(self.get_pending_exports()),
|
||
|
|
'pending_imports': len(self.get_pending_imports()),
|
||
|
|
'unresolved_conflicts': len(self.get_unresolved_conflicts()),
|
||
|
|
'total_exported': len(self.sync_state['exported_memories']),
|
||
|
|
'total_imported': len(self.sync_state['imported_memories'])
|
||
|
|
}
|