Skip to content

Commit 0d2cd1c

Browse files
author
Memtext User
committed
v0.4.0: Add collaboration, bundles, and shared context
- Add collaboration.py with event system and session tracking - Add ProjectBundle for export/import .mtbundle files - Add shared entries across projects (is_shared flag) - Add CLI commands: export, import, share - Add SessionTracker for activity monitoring - Add EventStore for tracking context changes
1 parent 053bd4f commit 0d2cd1c

File tree

3 files changed

+386
-1
lines changed

3 files changed

+386
-1
lines changed

src/memtext/cli.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,32 @@ def main(argv=None):
190190
"--save", action="store_true", help="Save extracted memories to DB"
191191
)
192192

193+
export_parser = subparsers.add_parser(
194+
"export",
195+
help="Export project context to bundle",
196+
description="Export context to a .mtbundle file for sharing",
197+
)
198+
export_parser.add_argument(
199+
"--output", help="Output filename (default: context.mtbundle)"
200+
)
201+
202+
import_parser = subparsers.add_parser(
203+
"import",
204+
help="Import project context from bundle",
205+
description="Import context from a .mtbundle file",
206+
)
207+
import_parser.add_argument("file", help="Bundle file to import")
208+
import_parser.add_argument(
209+
"--overwrite", action="store_true", help="Overwrite existing entries"
210+
)
211+
212+
share_parser = subparsers.add_parser(
213+
"share",
214+
help="Share an entry across projects",
215+
description="Mark an entry as shared for cross-project access",
216+
)
217+
share_parser.add_argument("entry_id", type=int, help="Entry ID to share")
218+
193219
serve_parser = subparsers.add_parser(
194220
"serve",
195221
help="Start API server",
@@ -359,6 +385,46 @@ def main(argv=None):
359385
print(f"{i}. [{entry['entry_type']}] {entry['title']}")
360386
print(f" importance={imp}, accesses={accesses}")
361387

388+
elif args.command == "export":
389+
try:
390+
from memtext.collaboration import ProjectBundle
391+
392+
bundle = ProjectBundle(Path.cwd() / "context")
393+
output = bundle.export()
394+
print(f"Exported to: {output.name}")
395+
logger.info(f"Exported bundle to {output}")
396+
except Exception as e:
397+
raise DatabaseError(f"Export failed: {e}")
398+
399+
elif args.command == "import":
400+
try:
401+
from memtext.collaboration import ProjectBundle
402+
403+
bundle_file = Path(args.file)
404+
if not bundle_file.exists():
405+
raise ValidationError(f"File not found: {args.file}")
406+
bundle = ProjectBundle(bundle_file)
407+
count = bundle.import_(overwrite=args.overwrite)
408+
print(f"Imported {count} entries")
409+
logger.info(f"Imported {count} entries from {bundle_file.name}")
410+
except Exception as e:
411+
raise DatabaseError(f"Import failed: {e}")
412+
413+
elif args.command == "share":
414+
require_context_dir()
415+
try:
416+
from memtext.db import make_shared
417+
418+
entry_id = args.entry_id
419+
success = make_shared(entry_id)
420+
if success:
421+
print(f"Entry {entry_id} marked as shared")
422+
logger.info(f"Marked entry {entry_id} as shared")
423+
else:
424+
print(f"Entry {entry_id} not found")
425+
except Exception as e:
426+
raise DatabaseError(f"Share failed: {e}")
427+
362428
elif args.command == "serve":
363429
try:
364430
from memtext.api import run as api_run

src/memtext/collaboration.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""Collaboration features: events, bundles, and activity tracking."""
2+
3+
import json
4+
import zipfile
5+
from pathlib import Path
6+
from datetime import datetime
7+
from typing import Optional, List, Dict, Any
8+
from dataclasses import dataclass, field, asdict
9+
from enum import Enum
10+
11+
12+
class EventType(Enum):
13+
CREATE = "CREATE"
14+
UPDATE = "UPDATE"
15+
DELETE = "DELETE"
16+
ACCESS = "ACCESS"
17+
SHARE = "SHARE"
18+
SESSION_START = "SESSION_START"
19+
SESSION_END = "SESSION_END"
20+
21+
22+
@dataclass
23+
class ContextEvent:
24+
"""An event in the context system."""
25+
26+
event_type: str
27+
entry_id: Optional[int] = None
28+
entry_title: Optional[str] = None
29+
project_path: Optional[str] = None
30+
session_id: Optional[str] = None
31+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
32+
metadata: Dict[str, Any] = field(default_factory=dict)
33+
34+
def to_dict(self) -> dict:
35+
return asdict(self)
36+
37+
38+
class EventStore:
39+
"""Store and retrieve context events."""
40+
41+
def __init__(self):
42+
self.events: List[ContextEvent] = []
43+
44+
def add(self, event: ContextEvent):
45+
self.events.append(event)
46+
47+
def get_recent(self, limit: int = 50) -> List[ContextEvent]:
48+
return self.events[-limit:]
49+
50+
def get_for_entry(self, entry_id: int) -> List[ContextEvent]:
51+
return [e for e in self.events if e.entry_id == entry_id]
52+
53+
def get_for_session(self, session_id: str) -> List[ContextEvent]:
54+
return [e for e in self.events if e.session_id == session_id]
55+
56+
def clear(self):
57+
self.events.clear()
58+
59+
60+
_global_events = EventStore()
61+
62+
63+
def emit_event(
64+
event_type: str,
65+
entry_id: Optional[int] = None,
66+
entry_title: Optional[str] = None,
67+
project_path: Optional[str] = None,
68+
session_id: Optional[str] = None,
69+
metadata: Optional[Dict] = None,
70+
):
71+
"""Emit a context event."""
72+
event = ContextEvent(
73+
event_type=event_type,
74+
entry_id=entry_id,
75+
entry_title=entry_title,
76+
project_path=project_path,
77+
session_id=session_id,
78+
metadata=metadata or {},
79+
)
80+
_global_events.add(event)
81+
return event
82+
83+
84+
def get_events(limit: int = 50) -> List[Dict]:
85+
"""Get recent events."""
86+
return [e.to_dict() for e in _global_events.get_recent(limit)]
87+
88+
89+
class ProjectBundle:
90+
"""Export/import project context as a bundle."""
91+
92+
MANIFEST_FILE = "manifest.json"
93+
ENTRIES_DIR = "entries"
94+
95+
def __init__(self, output_path: Path):
96+
self.output_path = output_path
97+
98+
def export(self, include_shared: bool = True) -> Path:
99+
"""Export project context to a .mtbundle file."""
100+
from memtext.db import query_entries, get_db_path, init_db
101+
102+
db_path = get_db_path()
103+
if not db_path.exists():
104+
init_db()
105+
106+
entries = query_entries(limit=1000)
107+
108+
bundle_path = self.output_path.with_suffix(".mtbundle")
109+
110+
with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as zf:
111+
manifest = {
112+
"version": "0.4.0",
113+
"exported_at": datetime.now().isoformat(),
114+
"entry_count": len(entries),
115+
}
116+
zf.writestr(self.MANIFEST_FILE, json.dumps(manifest, indent=2))
117+
118+
for entry in entries:
119+
entry_file = f"{self.ENTRIES_DIR}/{entry['id']}.json"
120+
zf.writestr(entry_file, json.dumps(entry, indent=2))
121+
122+
return bundle_path
123+
124+
def import_(self, overwrite: bool = False) -> int:
125+
"""Import entries from a .mtbundle file."""
126+
from memtext.db import add_entry, entry_exists, init_db
127+
128+
if not self.output_path.exists():
129+
raise FileNotFoundError(f"Bundle not found: {self.output_path}")
130+
131+
init_db()
132+
count = 0
133+
134+
with zipfile.ZipFile(self.output_path, "r") as zf:
135+
if self.MANIFEST_FILE not in zf.namelist():
136+
raise ValueError("Invalid bundle: manifest.json not found")
137+
138+
for name in zf.namelist():
139+
if name.startswith(self.ENTRIES_DIR) and name.endswith(".json"):
140+
with zf.open(name) as f:
141+
entry_data = json.load(f)
142+
143+
if overwrite or not entry_exists(
144+
entry_data["title"], entry_data.get("entry_type")
145+
):
146+
add_entry(
147+
title=entry_data["title"],
148+
content=entry_data["content"],
149+
entry_type=entry_data.get("entry_type", "note"),
150+
tags=entry_data.get("tags", "").split(",")
151+
if entry_data.get("tags")
152+
else None,
153+
importance=entry_data.get("importance", 1),
154+
)
155+
count += 1
156+
157+
return count
158+
159+
def list_contents(self) -> dict:
160+
"""List bundle contents."""
161+
if not self.output_path.exists():
162+
raise FileNotFoundError(f"Bundle not found: {self.output_path}")
163+
164+
with zipfile.ZipFile(self.output_path, "r") as zf:
165+
if self.MANIFEST_FILE not in zf.namelist():
166+
return {"error": "Invalid bundle"}
167+
168+
with zf.open(self.MANIFEST_FILE) as f:
169+
return json.load(f)
170+
171+
172+
class SessionTracker:
173+
"""Track agent session activity."""
174+
175+
def __init__(self):
176+
self.sessions: Dict[str, Dict] = {}
177+
178+
def start_session(self, session_id: str, project_path: str = None) -> Dict:
179+
"""Start a new session."""
180+
self.sessions[session_id] = {
181+
"session_id": session_id,
182+
"project_path": project_path or str(Path.cwd()),
183+
"started_at": datetime.now().isoformat(),
184+
"last_active": datetime.now().isoformat(),
185+
"event_count": 0,
186+
}
187+
emit_event(
188+
EventType.SESSION_START.value,
189+
session_id=session_id,
190+
project_path=project_path,
191+
)
192+
return self.sessions[session_id]
193+
194+
def end_session(self, session_id: str) -> Optional[Dict]:
195+
"""End a session."""
196+
if session_id not in self.sessions:
197+
return None
198+
199+
session = self.sessions[session_id]
200+
session["ended_at"] = datetime.now().isoformat()
201+
202+
emit_event(
203+
EventType.SESSION_END.value,
204+
session_id=session_id,
205+
project_path=session.get("project_path"),
206+
)
207+
208+
return self.sessions.pop(session_id)
209+
210+
def get_session(self, session_id: str) -> Optional[Dict]:
211+
"""Get session info."""
212+
return self.sessions.get(session_id)
213+
214+
def update_activity(self, session_id: str):
215+
"""Update session last active timestamp."""
216+
if session_id in self.sessions:
217+
self.sessions[session_id]["last_active"] = datetime.now().isoformat()
218+
self.sessions[session_id]["event_count"] += 1
219+
220+
def list_active(self) -> List[Dict]:
221+
"""List active sessions."""
222+
return list(self.sessions.values())
223+
224+
225+
_global_sessions = SessionTracker()
226+
227+
228+
def start_session(project_path: str = None) -> Dict:
229+
"""Start a new tracking session."""
230+
import uuid
231+
232+
session_id = str(uuid.uuid4())[:8]
233+
return _global_sessions.start_session(session_id, project_path)
234+
235+
236+
def end_session(session_id: str) -> Optional[Dict]:
237+
"""End a tracking session."""
238+
return _global_sessions.end_session(session_id)
239+
240+
241+
def get_active_sessions() -> List[Dict]:
242+
"""List active tracking sessions."""
243+
return _global_sessions.list_active()

0 commit comments

Comments
 (0)