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.
- engram/__init__.py +8 -0
- engram/__main__.py +6 -0
- engram/cli/__init__.py +1 -0
- engram/cli/app.py +291 -0
- engram/cli/formatters.py +90 -0
- engram/cli/simple.py +267 -0
- engram/config.py +72 -0
- engram/engine.py +612 -0
- engram/exceptions.py +41 -0
- engram/extraction/__init__.py +6 -0
- engram/extraction/base.py +20 -0
- engram/extraction/llm_extractor.py +197 -0
- engram/extraction/ner/__init__.py +7 -0
- engram/extraction/ner/cjk.py +63 -0
- engram/extraction/ner/english.py +109 -0
- engram/extraction/ner/korean.py +106 -0
- engram/extraction/regex_extractor.py +188 -0
- engram/integrations/__init__.py +1 -0
- engram/integrations/mcp_server.py +213 -0
- engram/integrations/sdk.py +194 -0
- engram/models/__init__.py +19 -0
- engram/models/entity.py +72 -0
- engram/models/fact.py +58 -0
- engram/models/quality.py +61 -0
- engram/models/relation.py +26 -0
- engram/models/search.py +96 -0
- engram/models/session.py +53 -0
- engram/models/source.py +73 -0
- engram/quality/__init__.py +8 -0
- engram/quality/confidence.py +38 -0
- engram/quality/conflict.py +79 -0
- engram/quality/decay.py +28 -0
- engram/quality/gate.py +120 -0
- engram/quality/pii.py +80 -0
- engram/search/__init__.py +13 -0
- engram/search/base.py +20 -0
- engram/search/fts5_search.py +210 -0
- engram/search/hybrid.py +99 -0
- engram/search/semantic.py +186 -0
- engram/search/tokenizer.py +85 -0
- engram/session/__init__.py +6 -0
- engram/session/context.py +87 -0
- engram/session/manager.py +152 -0
- engram/session/working_memory.py +57 -0
- engram/storage/__init__.py +6 -0
- engram/storage/base.py +63 -0
- engram/storage/markdown_export.py +144 -0
- engram/storage/migrations.py +30 -0
- engram/storage/sqlite_store.py +615 -0
- memorytrace-0.1.0.dist-info/METADATA +138 -0
- memorytrace-0.1.0.dist-info/RECORD +54 -0
- memorytrace-0.1.0.dist-info/WHEEL +4 -0
- memorytrace-0.1.0.dist-info/entry_points.txt +3 -0
- memorytrace-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
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()
|