memorytrace 0.1.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.
Files changed (54) hide show
  1. engram/__init__.py +8 -0
  2. engram/__main__.py +6 -0
  3. engram/cli/__init__.py +1 -0
  4. engram/cli/app.py +291 -0
  5. engram/cli/formatters.py +90 -0
  6. engram/cli/simple.py +267 -0
  7. engram/config.py +72 -0
  8. engram/engine.py +612 -0
  9. engram/exceptions.py +41 -0
  10. engram/extraction/__init__.py +6 -0
  11. engram/extraction/base.py +20 -0
  12. engram/extraction/llm_extractor.py +197 -0
  13. engram/extraction/ner/__init__.py +7 -0
  14. engram/extraction/ner/cjk.py +63 -0
  15. engram/extraction/ner/english.py +109 -0
  16. engram/extraction/ner/korean.py +106 -0
  17. engram/extraction/regex_extractor.py +188 -0
  18. engram/integrations/__init__.py +1 -0
  19. engram/integrations/mcp_server.py +213 -0
  20. engram/integrations/sdk.py +194 -0
  21. engram/models/__init__.py +19 -0
  22. engram/models/entity.py +72 -0
  23. engram/models/fact.py +58 -0
  24. engram/models/quality.py +61 -0
  25. engram/models/relation.py +26 -0
  26. engram/models/search.py +96 -0
  27. engram/models/session.py +53 -0
  28. engram/models/source.py +73 -0
  29. engram/quality/__init__.py +8 -0
  30. engram/quality/confidence.py +38 -0
  31. engram/quality/conflict.py +79 -0
  32. engram/quality/decay.py +28 -0
  33. engram/quality/gate.py +120 -0
  34. engram/quality/pii.py +80 -0
  35. engram/search/__init__.py +13 -0
  36. engram/search/base.py +20 -0
  37. engram/search/fts5_search.py +210 -0
  38. engram/search/hybrid.py +99 -0
  39. engram/search/semantic.py +186 -0
  40. engram/search/tokenizer.py +85 -0
  41. engram/session/__init__.py +6 -0
  42. engram/session/context.py +87 -0
  43. engram/session/manager.py +152 -0
  44. engram/session/working_memory.py +57 -0
  45. engram/storage/__init__.py +6 -0
  46. engram/storage/base.py +63 -0
  47. engram/storage/markdown_export.py +144 -0
  48. engram/storage/migrations.py +30 -0
  49. engram/storage/sqlite_store.py +615 -0
  50. memorytrace-0.1.0.dist-info/METADATA +138 -0
  51. memorytrace-0.1.0.dist-info/RECORD +54 -0
  52. memorytrace-0.1.0.dist-info/WHEEL +4 -0
  53. memorytrace-0.1.0.dist-info/entry_points.txt +3 -0
  54. memorytrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ """Session management for Engram."""
2
+
3
+ from engram.session.manager import SessionManager
4
+ from engram.session.working_memory import WorkingMemory
5
+
6
+ __all__ = ["SessionManager", "WorkingMemory"]
@@ -0,0 +1,87 @@
1
+ """Context carryover between sessions — resolves IDs to names, aggregates history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from typing import Optional
7
+
8
+ from engram.storage.base import StorageBackend
9
+
10
+
11
+ def build_session_context(storage: StorageBackend, agent_id: str, session_limit: int = 5) -> dict:
12
+ """Build a rich context summary for the start of a new session.
13
+
14
+ Looks at the last N sessions (not just 1) to aggregate knowledge.
15
+ Resolves entity IDs to human-readable names.
16
+ """
17
+ recent = storage.get_recent_sessions(agent_id, limit=session_limit)
18
+
19
+ context: dict = {
20
+ "entity_count": storage.entity_count(),
21
+ "fact_count": storage.fact_count(),
22
+ "pending_conflicts": len(storage.get_pending_conflicts()),
23
+ }
24
+
25
+ if not recent:
26
+ context["previous_summary"] = ""
27
+ context["previous_entities"] = []
28
+ context["recent_topics"] = []
29
+ context["sessions_reviewed"] = 0
30
+ return context
31
+
32
+ # Last completed session's summary
33
+ last_completed = None
34
+ for s in recent:
35
+ if s.ended_at:
36
+ last_completed = s
37
+ break
38
+
39
+ context["previous_summary"] = last_completed.summary if last_completed and last_completed.summary else ""
40
+
41
+ # Aggregate entity access across recent sessions — resolve IDs to names
42
+ entity_access_count: Counter = Counter()
43
+ for s in recent:
44
+ if s.ended_at:
45
+ for eid in s.entities_accessed:
46
+ entity_access_count[eid] += 1
47
+
48
+ # Resolve top entity IDs to names
49
+ top_entity_ids = [eid for eid, _ in entity_access_count.most_common(15)]
50
+ resolved_entities: list[dict] = []
51
+ for eid in top_entity_ids:
52
+ entity = storage.get_entity(eid)
53
+ if entity:
54
+ resolved_entities.append({
55
+ "name": entity.name,
56
+ "type": entity.entity_type,
57
+ "access_count": entity_access_count[eid],
58
+ })
59
+
60
+ context["previous_entities"] = [e["name"] for e in resolved_entities]
61
+ context["recent_entity_details"] = resolved_entities
62
+
63
+ # Include top facts for the most important entities (actual knowledge, not just names)
64
+ key_facts: list[dict] = []
65
+ for entity_info in resolved_entities[:5]:
66
+ eid = next((eid for eid, _ in entity_access_count.most_common(15)
67
+ if storage.get_entity(eid) and storage.get_entity(eid).name == entity_info["name"]), None)
68
+ if eid:
69
+ facts = storage.get_current_facts(eid)
70
+ top_facts = sorted(facts, key=lambda f: f.confidence, reverse=True)[:3]
71
+ for f in top_facts:
72
+ key_facts.append({
73
+ "entity": entity_info["name"],
74
+ "fact": f.raw_text,
75
+ "confidence": f.confidence,
76
+ })
77
+ context["key_facts"] = key_facts
78
+
79
+ # Collect recent topics from session summaries
80
+ recent_topics: list[str] = []
81
+ for s in recent:
82
+ if s.ended_at and s.summary:
83
+ recent_topics.append(s.summary)
84
+ context["recent_topics"] = recent_topics[:5]
85
+ context["sessions_reviewed"] = len([s for s in recent if s.ended_at])
86
+
87
+ return context
@@ -0,0 +1,152 @@
1
+ """Session lifecycle management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ from engram.exceptions import SessionError
10
+ from engram.models.session import Session, SessionEvent
11
+ from engram.storage.base import StorageBackend
12
+
13
+
14
+ class SessionManager:
15
+ """Manages session lifecycle: start, track events, end, and context carryover."""
16
+
17
+ def __init__(self, storage: StorageBackend):
18
+ self.storage = storage
19
+ self._active: dict[str, Session] = {}
20
+
21
+ def start_session(self, agent_id: str = "default") -> Session:
22
+ """Start a new session, linking to the previous one."""
23
+ previous = self.storage.get_recent_sessions(agent_id, limit=1)
24
+ session = Session(
25
+ session_id=str(uuid.uuid4()),
26
+ agent_id=agent_id,
27
+ started_at=datetime.now(),
28
+ parent_session_id=previous[0].session_id if previous else None,
29
+ )
30
+ self.storage.save_session(session)
31
+ self._active[session.session_id] = session
32
+ return session
33
+
34
+ def end_session(self, session_id: str, summary: Optional[str] = None) -> Session:
35
+ """End a session and persist it. Auto-generates summary if not provided."""
36
+ session = self._active.pop(session_id, None)
37
+ if session is None:
38
+ session = self.storage.get_session(session_id)
39
+ if session is None:
40
+ raise SessionError(f"Session {session_id} not found")
41
+
42
+ session.ended_at = datetime.now()
43
+ if summary:
44
+ session.summary = summary
45
+ elif not session.summary:
46
+ session.summary = self._auto_summary(session)
47
+ self.storage.save_session(session)
48
+ return session
49
+
50
+ def _auto_summary(self, session: Session) -> str:
51
+ """Auto-generate summary from session tracking data."""
52
+ parts: list[str] = []
53
+ if session.entities_modified:
54
+ names: list[str] = []
55
+ for eid in session.entities_modified[:5]:
56
+ entity = self.storage.get_entity(eid)
57
+ if entity:
58
+ names.append(entity.name)
59
+ if names:
60
+ parts.append(f"Modified: {', '.join(names)}")
61
+ if session.entities_accessed:
62
+ accessed_names: list[str] = []
63
+ for eid in session.entities_accessed[:5]:
64
+ if eid not in (session.entities_modified or []):
65
+ entity = self.storage.get_entity(eid)
66
+ if entity:
67
+ accessed_names.append(entity.name)
68
+ if accessed_names:
69
+ parts.append(f"Discussed: {', '.join(accessed_names)}")
70
+ if session.facts_added:
71
+ parts.append(f"{len(session.facts_added)} facts added")
72
+ return ". ".join(parts) if parts else "Session with no significant changes"
73
+
74
+ def get_active_session(self, session_id: str) -> Optional[Session]:
75
+ """Get an active session by ID."""
76
+ return self._active.get(session_id)
77
+
78
+ def record_entity_access(self, session_id: str, entity_id: str) -> None:
79
+ """Record that an entity was accessed in this session."""
80
+ session = self._active.get(session_id)
81
+ if session and entity_id not in session.entities_accessed:
82
+ session.entities_accessed.append(entity_id)
83
+
84
+ def record_entity_modified(self, session_id: str, entity_id: str) -> None:
85
+ """Record that an entity was modified in this session."""
86
+ session = self._active.get(session_id)
87
+ if session:
88
+ if entity_id not in session.entities_modified:
89
+ session.entities_modified.append(entity_id)
90
+ if entity_id not in session.entities_accessed:
91
+ session.entities_accessed.append(entity_id)
92
+
93
+ def record_fact_added(self, session_id: str, fact_id: str) -> None:
94
+ """Record that a fact was added in this session."""
95
+ session = self._active.get(session_id)
96
+ if session:
97
+ session.facts_added.append(fact_id)
98
+
99
+ def log_event(self, session_id: str, event_type: str, target: str = "", detail: Optional[dict] = None) -> None:
100
+ """Log an auditable event within a session."""
101
+ event = SessionEvent(
102
+ session_id=session_id,
103
+ event_type=event_type,
104
+ target=target,
105
+ detail=detail or {},
106
+ timestamp=datetime.now(),
107
+ )
108
+ self.storage.add_session_event(event)
109
+
110
+ def get_context_carryover(self, agent_id: str) -> str:
111
+ """Generate context string from recent sessions for system prompt injection.
112
+
113
+ Resolves entity IDs to names for agent readability.
114
+ Looks at last 3 completed sessions, not just 1.
115
+ """
116
+ recent = self.storage.get_recent_sessions(agent_id, limit=5)
117
+ completed = [s for s in recent if s.ended_at]
118
+ if not completed:
119
+ return ""
120
+
121
+ parts = []
122
+
123
+ # Last session summary
124
+ if completed[0].summary:
125
+ parts.append(f"Previous session: {completed[0].summary}")
126
+
127
+ # Resolve entity IDs to names across recent sessions
128
+ all_entity_ids: list[str] = []
129
+ for s in completed[:3]:
130
+ all_entity_ids.extend(s.entities_accessed)
131
+ # Deduplicate preserving order
132
+ seen: set[str] = set()
133
+ unique_ids: list[str] = []
134
+ for eid in all_entity_ids:
135
+ if eid not in seen:
136
+ seen.add(eid)
137
+ unique_ids.append(eid)
138
+
139
+ entity_names: list[str] = []
140
+ for eid in unique_ids[:10]:
141
+ entity = self.storage.get_entity(eid)
142
+ if entity:
143
+ entity_names.append(entity.name)
144
+
145
+ if entity_names:
146
+ parts.append(f"Recently discussed: {', '.join(entity_names)}")
147
+
148
+ total_facts = sum(len(s.facts_added) for s in completed[:3])
149
+ if total_facts:
150
+ parts.append(f"Facts added recently: {total_facts}")
151
+
152
+ return " | ".join(parts) if parts else ""
@@ -0,0 +1,57 @@
1
+ """Working memory — short-term context within a single session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from engram.models.session import Session
6
+ from engram.storage.base import StorageBackend
7
+
8
+
9
+ class WorkingMemory:
10
+ """Short-term memory within a single session.
11
+
12
+ Tracks active entities and recent queries to provide
13
+ context-aware search boosting and reference resolution.
14
+ """
15
+
16
+ def __init__(self, session: Session):
17
+ self.session = session
18
+ self.active_entities: list[str] = [] # entity IDs, most recent first
19
+ self.recent_queries: list[str] = []
20
+ self.facts_added_count: int = 0
21
+
22
+ def touch_entity(self, entity_id: str) -> None:
23
+ """Mark entity as active in this session (most recent first)."""
24
+ if entity_id in self.active_entities:
25
+ self.active_entities.remove(entity_id)
26
+ self.active_entities.insert(0, entity_id)
27
+
28
+ def get_active_entity_ids(self, limit: int = 5) -> list[str]:
29
+ """Return the most recently accessed entity IDs."""
30
+ return self.active_entities[:limit]
31
+
32
+ def add_query(self, query: str) -> None:
33
+ """Record a search query."""
34
+ self.recent_queries.insert(0, query)
35
+ if len(self.recent_queries) > 20:
36
+ self.recent_queries = self.recent_queries[:20]
37
+
38
+ def get_active_context(self, storage: StorageBackend, max_tokens: int = 300) -> str:
39
+ """Return compact summary of what's been discussed in this session."""
40
+ parts: list[str] = []
41
+ tokens_used = 0
42
+
43
+ for eid in self.active_entities[:5]:
44
+ entity = storage.get_entity(eid)
45
+ if entity:
46
+ entry = f"{entity.name}"
47
+ if entity.state.role:
48
+ entry += f" ({entity.state.role})"
49
+ entry_tokens = len(entry) // 4
50
+ if tokens_used + entry_tokens > max_tokens:
51
+ break
52
+ parts.append(entry)
53
+ tokens_used += entry_tokens
54
+
55
+ if not parts:
56
+ return ""
57
+ return "Active context: " + ", ".join(parts)
@@ -0,0 +1,6 @@
1
+ """Storage backends for Engram."""
2
+
3
+ from engram.storage.base import StorageBackend
4
+ from engram.storage.sqlite_store import SQLiteStorage
5
+
6
+ __all__ = ["StorageBackend", "SQLiteStorage"]
engram/storage/base.py ADDED
@@ -0,0 +1,63 @@
1
+ """Storage backend protocol — the contract all backends must implement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Protocol, runtime_checkable
6
+
7
+ from engram.models.entity import Entity, Tier
8
+ from engram.models.fact import Fact, FactStatus
9
+ from engram.models.relation import Relation
10
+ from engram.models.session import Session, SessionEvent
11
+
12
+
13
+ @runtime_checkable
14
+ class StorageBackend(Protocol):
15
+ """Abstract storage interface."""
16
+
17
+ def initialize(self) -> None: ...
18
+ def close(self) -> None: ...
19
+
20
+ # Entity CRUD
21
+ def create_entity(self, entity: Entity) -> Entity: ...
22
+ def get_entity(self, entity_id: str) -> Optional[Entity]: ...
23
+ def get_entity_by_name(self, name: str) -> Optional[Entity]: ...
24
+ def update_entity(self, entity: Entity) -> Entity: ...
25
+ def delete_entity(self, entity_id: str) -> bool: ...
26
+ def list_entities(
27
+ self,
28
+ tier: Optional[Tier] = None,
29
+ entity_type: Optional[str] = None,
30
+ limit: int = 100,
31
+ offset: int = 0,
32
+ ) -> list[Entity]: ...
33
+
34
+ # Fact CRUD
35
+ def add_fact(self, fact: Fact) -> Fact: ...
36
+ def get_facts(
37
+ self, entity_id: str, status: Optional[FactStatus] = None
38
+ ) -> list[Fact]: ...
39
+ def get_current_facts(self, entity_id: str) -> list[Fact]: ...
40
+ def update_fact(self, fact: Fact) -> Fact: ...
41
+
42
+ # Relations
43
+ def add_relation(self, relation: Relation) -> Relation: ...
44
+ def get_relations(
45
+ self, entity_id: str, direction: str = "both"
46
+ ) -> list[Relation]: ...
47
+
48
+ # Sessions
49
+ def save_session(self, session: Session) -> None: ...
50
+ def get_session(self, session_id: str) -> Optional[Session]: ...
51
+ def get_recent_sessions(
52
+ self, agent_id: str, limit: int = 5
53
+ ) -> list[Session]: ...
54
+ def add_session_event(self, event: SessionEvent) -> None: ...
55
+
56
+ # Conflicts
57
+ def add_conflict(self, conflict: dict) -> None: ...
58
+ def get_pending_conflicts(self) -> list[dict]: ...
59
+ def resolve_conflict(self, conflict_id: str, resolution: str, resolved_by: str) -> None: ...
60
+
61
+ # Stats
62
+ def entity_count(self) -> int: ...
63
+ def fact_count(self) -> int: ...
@@ -0,0 +1,144 @@
1
+ """Markdown exporter — one-way DB → Markdown for human readability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from engram.models.entity import Entity
9
+ from engram.models.fact import Fact
10
+ from engram.models.relation import Relation
11
+
12
+
13
+ def _slugify(name: str) -> str:
14
+ """Convert entity name to filesystem-safe slug."""
15
+ slug = name.lower().strip()
16
+ slug = re.sub(r'[^\w\s\u3040-\u9FFF\uAC00-\uD7AF-]', '', slug)
17
+ slug = re.sub(r'[\s_]+', '-', slug)
18
+ slug = slug.strip('-')
19
+ return slug[:80] if slug else "unnamed"
20
+
21
+
22
+ class MarkdownExporter:
23
+ """One-way export: DB → Markdown files. Never reads from Markdown.
24
+
25
+ Generates human-readable Markdown files from structured DB data.
26
+ These files are for viewing only — the DB is the source of truth.
27
+ """
28
+
29
+ def __init__(self, export_dir: Path):
30
+ self.export_dir = Path(export_dir)
31
+ self.export_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ def export_entity(
34
+ self,
35
+ entity: Entity,
36
+ facts: list[Fact],
37
+ relations: list[Relation],
38
+ ) -> Path:
39
+ """Generate a Markdown file for a single entity."""
40
+ md = self._render_entity(entity, facts, relations)
41
+ filename = f"{_slugify(entity.name)}.md"
42
+ filepath = self.export_dir / filename
43
+ filepath.write_text(md, encoding="utf-8")
44
+ return filepath
45
+
46
+ def export_all(
47
+ self,
48
+ entities: list[tuple[Entity, list[Fact], list[Relation]]],
49
+ ) -> int:
50
+ """Export all entities. Returns count of files written."""
51
+ count = 0
52
+ for entity, facts, relations in entities:
53
+ self.export_entity(entity, facts, relations)
54
+ count += 1
55
+ return count
56
+
57
+ def _render_entity(
58
+ self,
59
+ entity: Entity,
60
+ facts: list[Fact],
61
+ relations: list[Relation],
62
+ ) -> str:
63
+ """Render entity data as Markdown."""
64
+ lines: list[str] = []
65
+
66
+ # Title
67
+ lines.append(f"# {entity.name}")
68
+ lines.append("")
69
+
70
+ # Summary
71
+ if entity.summary:
72
+ lines.append(f"> {entity.summary}")
73
+ lines.append("")
74
+
75
+ # State
76
+ state = entity.state.to_dict()
77
+ if state:
78
+ lines.append("## State")
79
+ for key, value in state.items():
80
+ if key == "custom" and isinstance(value, dict):
81
+ for ck, cv in value.items():
82
+ lines.append(f"- **{ck}**: {cv}")
83
+ else:
84
+ lines.append(f"- **{key.title()}**: {value}")
85
+ lines.append("")
86
+
87
+ # Metadata
88
+ lines.append("## Metadata")
89
+ lines.append(f"- **Type**: {entity.entity_type}")
90
+ lines.append(f"- **Tier**: {entity.tier.value}")
91
+ lines.append(f"- **Updated**: {entity.updated_at.strftime('%Y-%m-%d %H:%M') if entity.updated_at else 'N/A'}")
92
+ if entity.aliases:
93
+ lines.append(f"- **Aliases**: {', '.join(entity.aliases)}")
94
+ lines.append("")
95
+
96
+ # Relations
97
+ if relations:
98
+ lines.append("## Relations")
99
+ for rel in relations:
100
+ if rel.from_entity_id == entity.id:
101
+ lines.append(f"- {rel.relation_type} → `{rel.to_entity_id}`")
102
+ else:
103
+ lines.append(f"- ← {rel.relation_type} from `{rel.from_entity_id}`")
104
+ lines.append("")
105
+
106
+ # Facts (current)
107
+ current_facts = [f for f in facts if f.is_current]
108
+ if current_facts:
109
+ lines.append("## Key Facts")
110
+ for fact in current_facts:
111
+ conf_label = self._confidence_label(fact.confidence)
112
+ source_info = f"{fact.source.type.value}"
113
+ if fact.source.author:
114
+ source_info += f", {fact.source.author}"
115
+ lines.append(
116
+ f"- {fact.raw_text} "
117
+ f"[{conf_label}] "
118
+ f"[Source: {source_info}]"
119
+ )
120
+ lines.append("")
121
+
122
+ # Timeline (all facts, chronological)
123
+ if facts:
124
+ lines.append("---")
125
+ lines.append("")
126
+ lines.append("## Timeline")
127
+ sorted_facts = sorted(facts, key=lambda f: f.created_at, reverse=True)
128
+ for fact in sorted_facts:
129
+ date_str = fact.created_at.strftime('%Y-%m-%d')
130
+ status_icon = "✓" if fact.is_current else "✗"
131
+ lines.append(f"- [{status_icon}] **{date_str}** | {fact.raw_text}")
132
+ lines.append("")
133
+
134
+ return "\n".join(lines)
135
+
136
+ def _confidence_label(self, confidence: float) -> str:
137
+ if confidence >= 0.8:
138
+ return "HIGH"
139
+ elif confidence >= 0.5:
140
+ return "MED"
141
+ elif confidence >= 0.3:
142
+ return "LOW"
143
+ else:
144
+ return "UNVERIFIED"
@@ -0,0 +1,30 @@
1
+ """Schema versioning and migration support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ CURRENT_VERSION = 1
8
+
9
+
10
+ def get_schema_version(conn: sqlite3.Connection) -> int:
11
+ try:
12
+ row = conn.execute("SELECT value FROM _meta WHERE key = 'schema_version'").fetchone()
13
+ return int(row[0]) if row else 0
14
+ except sqlite3.OperationalError:
15
+ return 0
16
+
17
+
18
+ def migrate(conn: sqlite3.Connection) -> None:
19
+ """Run any pending migrations."""
20
+ version = get_schema_version(conn)
21
+ if version >= CURRENT_VERSION:
22
+ return
23
+ # Future migrations go here:
24
+ # if version < 2:
25
+ # _migrate_v1_to_v2(conn)
26
+ conn.execute(
27
+ "INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)",
28
+ ("schema_version", str(CURRENT_VERSION)),
29
+ )
30
+ conn.commit()