gitmap-core 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.
gitmap_core/context.py ADDED
@@ -0,0 +1,709 @@
1
+ """Context graph storage for GitMap.
2
+
3
+ Provides SQLite-backed event store for tracking operations, rationales,
4
+ and relationships between events. Enables episodic memory and context
5
+ awareness for IDE agents.
6
+
7
+ Execution Context:
8
+ Library module - imported by other gitmap_core modules
9
+
10
+ Dependencies:
11
+ - sqlite3: Database operations (stdlib)
12
+ - uuid: ID generation (stdlib)
13
+
14
+ Metadata:
15
+ Version: 0.1.0
16
+ Author: GitMap Team
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sqlite3
22
+ from dataclasses import asdict
23
+ from dataclasses import dataclass
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from uuid import uuid4
28
+
29
+
30
+ # ---- Data Model Classes -------------------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class Event:
35
+ """Context graph event.
36
+
37
+ Attributes:
38
+ id: Unique event identifier.
39
+ timestamp: ISO 8601 timestamp.
40
+ event_type: Type of event (commit, push, pull, merge, branch, diff).
41
+ actor: Who performed the event.
42
+ repo: Repository path.
43
+ ref: Related reference (commit ID, branch name).
44
+ payload: Event-specific data.
45
+ """
46
+
47
+ id: str
48
+ timestamp: str
49
+ event_type: str
50
+ actor: str | None
51
+ repo: str
52
+ ref: str | None
53
+ payload: dict[str, Any]
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Convert event to dictionary.
57
+
58
+ Returns:
59
+ Dictionary representation.
60
+ """
61
+ return asdict(self)
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> Event:
65
+ """Create event from dictionary.
66
+
67
+ Args:
68
+ data: Dictionary with event fields.
69
+
70
+ Returns:
71
+ Event instance.
72
+ """
73
+ return cls(**data)
74
+
75
+ @classmethod
76
+ def from_row(cls, row: sqlite3.Row) -> Event:
77
+ """Create event from database row.
78
+
79
+ Args:
80
+ row: SQLite row with event data.
81
+
82
+ Returns:
83
+ Event instance.
84
+ """
85
+ return cls(
86
+ id=row["id"],
87
+ timestamp=row["timestamp"],
88
+ event_type=row["event_type"],
89
+ actor=row["actor"],
90
+ repo=row["repo"],
91
+ ref=row["ref"],
92
+ payload=json.loads(row["payload"]),
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class Annotation:
98
+ """Annotation attached to an event.
99
+
100
+ Attributes:
101
+ id: Unique annotation identifier.
102
+ event_id: ID of associated event (can be None for standalone lessons).
103
+ annotation_type: Type (rationale, lesson, outcome, issue).
104
+ content: Annotation text content.
105
+ source: Who created the annotation (user, agent, auto).
106
+ timestamp: ISO 8601 timestamp.
107
+ """
108
+
109
+ id: str
110
+ event_id: str | None
111
+ annotation_type: str
112
+ content: str
113
+ source: str
114
+ timestamp: str
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ """Convert annotation to dictionary.
118
+
119
+ Returns:
120
+ Dictionary representation.
121
+ """
122
+ return asdict(self)
123
+
124
+ @classmethod
125
+ def from_dict(cls, data: dict[str, Any]) -> Annotation:
126
+ """Create annotation from dictionary.
127
+
128
+ Args:
129
+ data: Dictionary with annotation fields.
130
+
131
+ Returns:
132
+ Annotation instance.
133
+ """
134
+ return cls(**data)
135
+
136
+ @classmethod
137
+ def from_row(cls, row: sqlite3.Row) -> Annotation:
138
+ """Create annotation from database row.
139
+
140
+ Args:
141
+ row: SQLite row with annotation data.
142
+
143
+ Returns:
144
+ Annotation instance.
145
+ """
146
+ return cls(
147
+ id=row["id"],
148
+ event_id=row["event_id"],
149
+ annotation_type=row["annotation_type"],
150
+ content=row["content"],
151
+ source=row["source"],
152
+ timestamp=row["timestamp"],
153
+ )
154
+
155
+
156
+ @dataclass
157
+ class Edge:
158
+ """Relationship between events.
159
+
160
+ Attributes:
161
+ source_id: Source event ID.
162
+ target_id: Target event ID.
163
+ relationship: Type of relationship (caused_by, reverts, related_to, learned_from).
164
+ metadata: Optional additional data.
165
+ """
166
+
167
+ source_id: str
168
+ target_id: str
169
+ relationship: str
170
+ metadata: dict[str, Any] | None = None
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ """Convert edge to dictionary.
174
+
175
+ Returns:
176
+ Dictionary representation.
177
+ """
178
+ return asdict(self)
179
+
180
+ @classmethod
181
+ def from_dict(cls, data: dict[str, Any]) -> Edge:
182
+ """Create edge from dictionary.
183
+
184
+ Args:
185
+ data: Dictionary with edge fields.
186
+
187
+ Returns:
188
+ Edge instance.
189
+ """
190
+ return cls(**data)
191
+
192
+ @classmethod
193
+ def from_row(cls, row: sqlite3.Row) -> Edge:
194
+ """Create edge from database row.
195
+
196
+ Args:
197
+ row: SQLite row with edge data.
198
+
199
+ Returns:
200
+ Edge instance.
201
+ """
202
+ metadata = row["metadata"]
203
+ return cls(
204
+ source_id=row["source_id"],
205
+ target_id=row["target_id"],
206
+ relationship=row["relationship"],
207
+ metadata=json.loads(metadata) if metadata else None,
208
+ )
209
+
210
+
211
+ # ---- Context Store Class ------------------------------------------------------------------------------------
212
+
213
+
214
+ class ContextStore:
215
+ """SQLite-backed context graph storage.
216
+
217
+ Manages events, annotations, and edges for context-aware
218
+ version control operations.
219
+
220
+ Attributes:
221
+ db_path: Path to SQLite database file.
222
+ """
223
+
224
+ def __init__(self, db_path: Path) -> None:
225
+ """Initialize context store with SQLite database.
226
+
227
+ Args:
228
+ db_path: Path to context.db file.
229
+ """
230
+ self.db_path = db_path
231
+ self._conn: sqlite3.Connection | None = None
232
+ self._ensure_schema()
233
+
234
+ @property
235
+ def _connection(self) -> sqlite3.Connection:
236
+ """Get or create database connection.
237
+
238
+ Returns:
239
+ SQLite connection with row factory.
240
+ """
241
+ if self._conn is None:
242
+ self._conn = sqlite3.connect(str(self.db_path))
243
+ self._conn.row_factory = sqlite3.Row
244
+ # Enable WAL mode for better concurrent access
245
+ self._conn.execute("PRAGMA journal_mode=WAL")
246
+ return self._conn
247
+
248
+ def _ensure_schema(self) -> None:
249
+ """Create tables and indexes if they don't exist."""
250
+ conn = self._connection
251
+ conn.executescript("""
252
+ CREATE TABLE IF NOT EXISTS events (
253
+ id TEXT PRIMARY KEY,
254
+ timestamp TEXT NOT NULL,
255
+ event_type TEXT NOT NULL,
256
+ actor TEXT,
257
+ repo TEXT NOT NULL,
258
+ ref TEXT,
259
+ payload JSON NOT NULL
260
+ );
261
+
262
+ CREATE TABLE IF NOT EXISTS annotations (
263
+ id TEXT PRIMARY KEY,
264
+ event_id TEXT REFERENCES events(id),
265
+ annotation_type TEXT NOT NULL,
266
+ content TEXT NOT NULL,
267
+ source TEXT,
268
+ timestamp TEXT NOT NULL
269
+ );
270
+
271
+ CREATE TABLE IF NOT EXISTS edges (
272
+ source_id TEXT REFERENCES events(id),
273
+ target_id TEXT REFERENCES events(id),
274
+ relationship TEXT NOT NULL,
275
+ metadata JSON,
276
+ PRIMARY KEY (source_id, target_id, relationship)
277
+ );
278
+
279
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
280
+ CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
281
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
282
+ CREATE INDEX IF NOT EXISTS idx_annotations_event ON annotations(event_id);
283
+ CREATE INDEX IF NOT EXISTS idx_annotations_type ON annotations(annotation_type);
284
+ """)
285
+ conn.commit()
286
+
287
+ @staticmethod
288
+ def generate_id() -> str:
289
+ """Generate unique identifier.
290
+
291
+ Returns:
292
+ UUID string.
293
+ """
294
+ return str(uuid4())
295
+
296
+ # ---- Event Operations -----------------------------------------------------------------------------------
297
+
298
+ def record_event(
299
+ self,
300
+ event_type: str,
301
+ repo: str,
302
+ payload: dict[str, Any],
303
+ actor: str | None = None,
304
+ ref: str | None = None,
305
+ rationale: str | None = None,
306
+ ) -> Event:
307
+ """Record a new event, optionally with rationale annotation.
308
+
309
+ Args:
310
+ event_type: Type of event (commit, push, pull, merge, branch, diff).
311
+ repo: Repository path.
312
+ payload: Event-specific data.
313
+ actor: Who performed the event.
314
+ ref: Related reference (commit ID, branch name).
315
+ rationale: Optional rationale to annotate immediately.
316
+
317
+ Returns:
318
+ Created Event instance.
319
+ """
320
+ event_id = self.generate_id()
321
+ timestamp = datetime.now().isoformat()
322
+
323
+ conn = self._connection
324
+ conn.execute(
325
+ """
326
+ INSERT INTO events (id, timestamp, event_type, actor, repo, ref, payload)
327
+ VALUES (?, ?, ?, ?, ?, ?, ?)
328
+ """,
329
+ [event_id, timestamp, event_type, actor, repo, ref, json.dumps(payload)],
330
+ )
331
+ conn.commit()
332
+
333
+ event = Event(
334
+ id=event_id,
335
+ timestamp=timestamp,
336
+ event_type=event_type,
337
+ actor=actor,
338
+ repo=repo,
339
+ ref=ref,
340
+ payload=payload,
341
+ )
342
+
343
+ # If rationale provided, annotate immediately
344
+ if rationale:
345
+ self.add_annotation(
346
+ event_id=event_id,
347
+ annotation_type="rationale",
348
+ content=rationale,
349
+ source="user",
350
+ )
351
+
352
+ return event
353
+
354
+ def get_event(self, event_id: str) -> Event | None:
355
+ """Retrieve event by ID.
356
+
357
+ Args:
358
+ event_id: Event identifier.
359
+
360
+ Returns:
361
+ Event instance or None if not found.
362
+ """
363
+ conn = self._connection
364
+ cursor = conn.execute(
365
+ "SELECT * FROM events WHERE id = ?",
366
+ [event_id],
367
+ )
368
+ row = cursor.fetchone()
369
+ return Event.from_row(row) if row else None
370
+
371
+ def get_events_by_type(
372
+ self,
373
+ event_type: str,
374
+ limit: int = 50,
375
+ ) -> list[Event]:
376
+ """Get events filtered by type.
377
+
378
+ Args:
379
+ event_type: Event type to filter by.
380
+ limit: Maximum results to return.
381
+
382
+ Returns:
383
+ List of matching events.
384
+ """
385
+ conn = self._connection
386
+ cursor = conn.execute(
387
+ """
388
+ SELECT * FROM events
389
+ WHERE event_type = ?
390
+ ORDER BY timestamp DESC
391
+ LIMIT ?
392
+ """,
393
+ [event_type, limit],
394
+ )
395
+ return [Event.from_row(row) for row in cursor.fetchall()]
396
+
397
+ def get_events_by_ref(
398
+ self,
399
+ ref: str,
400
+ limit: int = 50,
401
+ ) -> list[Event]:
402
+ """Get events related to a specific ref.
403
+
404
+ Args:
405
+ ref: Commit ID or branch name.
406
+ limit: Maximum results to return.
407
+
408
+ Returns:
409
+ List of matching events.
410
+ """
411
+ conn = self._connection
412
+ cursor = conn.execute(
413
+ """
414
+ SELECT * FROM events
415
+ WHERE ref = ?
416
+ ORDER BY timestamp DESC
417
+ LIMIT ?
418
+ """,
419
+ [ref, limit],
420
+ )
421
+ return [Event.from_row(row) for row in cursor.fetchall()]
422
+
423
+ def search_events(
424
+ self,
425
+ query: str,
426
+ event_types: list[str] | None = None,
427
+ start_date: str | None = None,
428
+ end_date: str | None = None,
429
+ limit: int = 50,
430
+ ) -> list[Event]:
431
+ """Search across events and annotations by keyword.
432
+
433
+ Args:
434
+ query: Search query string.
435
+ event_types: Filter by event types.
436
+ start_date: Filter events after this date (ISO format).
437
+ end_date: Filter events before this date (ISO format).
438
+ limit: Maximum results to return.
439
+
440
+ Returns:
441
+ List of matching events.
442
+ """
443
+ conn = self._connection
444
+
445
+ # Build query with search in payload and annotations
446
+ sql = """
447
+ SELECT DISTINCT e.* FROM events e
448
+ LEFT JOIN annotations a ON e.id = a.event_id
449
+ WHERE (
450
+ e.payload LIKE ?
451
+ OR a.content LIKE ?
452
+ )
453
+ """
454
+ params: list[Any] = [f"%{query}%", f"%{query}%"]
455
+
456
+ if event_types:
457
+ placeholders = ",".join("?" for _ in event_types)
458
+ sql += f" AND e.event_type IN ({placeholders})"
459
+ params.extend(event_types)
460
+
461
+ if start_date:
462
+ sql += " AND e.timestamp >= ?"
463
+ params.append(start_date)
464
+
465
+ if end_date:
466
+ sql += " AND e.timestamp <= ?"
467
+ params.append(end_date)
468
+
469
+ sql += " ORDER BY e.timestamp DESC LIMIT ?"
470
+ params.append(limit)
471
+
472
+ cursor = conn.execute(sql, params)
473
+ return [Event.from_row(row) for row in cursor.fetchall()]
474
+
475
+ # ---- Annotation Operations ------------------------------------------------------------------------------
476
+
477
+ def add_annotation(
478
+ self,
479
+ event_id: str | None,
480
+ annotation_type: str,
481
+ content: str,
482
+ source: str = "user",
483
+ ) -> Annotation:
484
+ """Add annotation to an event.
485
+
486
+ Args:
487
+ event_id: ID of event to annotate (can be None for standalone).
488
+ annotation_type: Type (rationale, lesson, outcome, issue).
489
+ content: Annotation text content.
490
+ source: Who created the annotation.
491
+
492
+ Returns:
493
+ Created Annotation instance.
494
+ """
495
+ annotation_id = self.generate_id()
496
+ timestamp = datetime.now().isoformat()
497
+
498
+ conn = self._connection
499
+ conn.execute(
500
+ """
501
+ INSERT INTO annotations (id, event_id, annotation_type, content, source, timestamp)
502
+ VALUES (?, ?, ?, ?, ?, ?)
503
+ """,
504
+ [annotation_id, event_id, annotation_type, content, source, timestamp],
505
+ )
506
+ conn.commit()
507
+
508
+ return Annotation(
509
+ id=annotation_id,
510
+ event_id=event_id,
511
+ annotation_type=annotation_type,
512
+ content=content,
513
+ source=source,
514
+ timestamp=timestamp,
515
+ )
516
+
517
+ def get_annotations(
518
+ self,
519
+ event_id: str,
520
+ ) -> list[Annotation]:
521
+ """Get all annotations for an event.
522
+
523
+ Args:
524
+ event_id: Event identifier.
525
+
526
+ Returns:
527
+ List of annotations.
528
+ """
529
+ conn = self._connection
530
+ cursor = conn.execute(
531
+ """
532
+ SELECT * FROM annotations
533
+ WHERE event_id = ?
534
+ ORDER BY timestamp ASC
535
+ """,
536
+ [event_id],
537
+ )
538
+ return [Annotation.from_row(row) for row in cursor.fetchall()]
539
+
540
+ def record_lesson(
541
+ self,
542
+ content: str,
543
+ related_event_id: str | None = None,
544
+ source: str = "user",
545
+ ) -> Annotation:
546
+ """Record a learned lesson.
547
+
548
+ Args:
549
+ content: The lesson learned.
550
+ related_event_id: Optional event this lesson relates to.
551
+ source: Source of lesson (user, agent, auto).
552
+
553
+ Returns:
554
+ Created Annotation instance.
555
+ """
556
+ return self.add_annotation(
557
+ event_id=related_event_id,
558
+ annotation_type="lesson",
559
+ content=content,
560
+ source=source,
561
+ )
562
+
563
+ # ---- Edge Operations ------------------------------------------------------------------------------------
564
+
565
+ def add_edge(
566
+ self,
567
+ source_id: str,
568
+ target_id: str,
569
+ relationship: str,
570
+ metadata: dict[str, Any] | None = None,
571
+ ) -> Edge:
572
+ """Create relationship between events.
573
+
574
+ Args:
575
+ source_id: Source event ID.
576
+ target_id: Target event ID.
577
+ relationship: Type of relationship (caused_by, reverts, related_to, learned_from).
578
+ metadata: Optional additional data.
579
+
580
+ Returns:
581
+ Created Edge instance.
582
+ """
583
+ conn = self._connection
584
+ conn.execute(
585
+ """
586
+ INSERT OR REPLACE INTO edges (source_id, target_id, relationship, metadata)
587
+ VALUES (?, ?, ?, ?)
588
+ """,
589
+ [source_id, target_id, relationship, json.dumps(metadata) if metadata else None],
590
+ )
591
+ conn.commit()
592
+
593
+ return Edge(
594
+ source_id=source_id,
595
+ target_id=target_id,
596
+ relationship=relationship,
597
+ metadata=metadata,
598
+ )
599
+
600
+ def get_related_events(
601
+ self,
602
+ event_id: str,
603
+ relationship: str | None = None,
604
+ ) -> list[tuple[Event, str]]:
605
+ """Get events related to given event.
606
+
607
+ Args:
608
+ event_id: Event identifier.
609
+ relationship: Filter by relationship type.
610
+
611
+ Returns:
612
+ List of (event, relationship) tuples.
613
+ """
614
+ conn = self._connection
615
+
616
+ sql = """
617
+ SELECT e.*, ed.relationship FROM events e
618
+ JOIN edges ed ON (e.id = ed.target_id OR e.id = ed.source_id)
619
+ WHERE (ed.source_id = ? OR ed.target_id = ?)
620
+ AND e.id != ?
621
+ """
622
+ params: list[Any] = [event_id, event_id, event_id]
623
+
624
+ if relationship:
625
+ sql += " AND ed.relationship = ?"
626
+ params.append(relationship)
627
+
628
+ cursor = conn.execute(sql, params)
629
+ return [(Event.from_row(row), row["relationship"]) for row in cursor.fetchall()]
630
+
631
+ # ---- Timeline Operations --------------------------------------------------------------------------------
632
+
633
+ def get_timeline(
634
+ self,
635
+ ref: str | None = None,
636
+ start_date: str | None = None,
637
+ end_date: str | None = None,
638
+ include_annotations: bool = True,
639
+ limit: int = 100,
640
+ ) -> list[dict[str, Any]]:
641
+ """Get chronological timeline of events with annotations.
642
+
643
+ Args:
644
+ ref: Filter by specific ref.
645
+ start_date: Filter events after this date.
646
+ end_date: Filter events before this date.
647
+ include_annotations: Include annotations with events.
648
+ limit: Maximum events to return.
649
+
650
+ Returns:
651
+ List of timeline entries with events and annotations.
652
+ """
653
+ conn = self._connection
654
+
655
+ sql = "SELECT * FROM events WHERE 1=1"
656
+ params: list[Any] = []
657
+
658
+ if ref:
659
+ sql += " AND ref = ?"
660
+ params.append(ref)
661
+
662
+ if start_date:
663
+ sql += " AND timestamp >= ?"
664
+ params.append(start_date)
665
+
666
+ if end_date:
667
+ sql += " AND timestamp <= ?"
668
+ params.append(end_date)
669
+
670
+ sql += " ORDER BY timestamp DESC LIMIT ?"
671
+ params.append(limit)
672
+
673
+ cursor = conn.execute(sql, params)
674
+ events = [Event.from_row(row) for row in cursor.fetchall()]
675
+
676
+ timeline = []
677
+ for event in events:
678
+ entry: dict[str, Any] = {
679
+ "event_id": event.id,
680
+ "timestamp": event.timestamp,
681
+ "event_type": event.event_type,
682
+ "actor": event.actor,
683
+ "ref": event.ref,
684
+ "payload": event.payload,
685
+ }
686
+
687
+ if include_annotations:
688
+ annotations = self.get_annotations(event.id)
689
+ entry["annotations"] = [ann.to_dict() for ann in annotations]
690
+
691
+ timeline.append(entry)
692
+
693
+ return timeline
694
+
695
+ # ---- Utility Methods ------------------------------------------------------------------------------------
696
+
697
+ def close(self) -> None:
698
+ """Close database connection."""
699
+ if self._conn:
700
+ self._conn.close()
701
+ self._conn = None
702
+
703
+ def __enter__(self) -> ContextStore:
704
+ """Context manager entry."""
705
+ return self
706
+
707
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
708
+ """Context manager exit."""
709
+ self.close()