up-cli 0.2.0__py3-none-any.whl → 0.5.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 (46) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +54 -9
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/init.py +195 -6
  8. up/commands/learn.py +1392 -32
  9. up/commands/memory.py +545 -0
  10. up/commands/provenance.py +267 -0
  11. up/commands/review.py +239 -0
  12. up/commands/start.py +752 -42
  13. up/commands/status.py +173 -18
  14. up/commands/sync.py +317 -0
  15. up/commands/vibe.py +304 -0
  16. up/context.py +64 -10
  17. up/core/__init__.py +69 -0
  18. up/core/checkpoint.py +479 -0
  19. up/core/provenance.py +364 -0
  20. up/core/state.py +678 -0
  21. up/events.py +512 -0
  22. up/git/__init__.py +37 -0
  23. up/git/utils.py +270 -0
  24. up/git/worktree.py +331 -0
  25. up/learn/__init__.py +155 -0
  26. up/learn/analyzer.py +227 -0
  27. up/learn/plan.py +374 -0
  28. up/learn/research.py +511 -0
  29. up/learn/utils.py +117 -0
  30. up/memory.py +1096 -0
  31. up/parallel.py +551 -0
  32. up/templates/config/__init__.py +1 -1
  33. up/templates/docs/SKILL.md +28 -0
  34. up/templates/docs/__init__.py +341 -0
  35. up/templates/docs/standards/HEADERS.md +24 -0
  36. up/templates/docs/standards/STRUCTURE.md +18 -0
  37. up/templates/docs/standards/TEMPLATES.md +19 -0
  38. up/templates/loop/__init__.py +92 -32
  39. up/ui/__init__.py +14 -0
  40. up/ui/loop_display.py +650 -0
  41. up/ui/theme.py +137 -0
  42. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
  43. up_cli-0.5.0.dist-info/RECORD +55 -0
  44. up_cli-0.2.0.dist-info/RECORD +0 -23
  45. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  46. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/memory.py ADDED
@@ -0,0 +1,1096 @@
1
+ """Long-term memory system for up-cli.
2
+
3
+ Provides persistent memory across sessions using ChromaDB with local embeddings.
4
+ No external API required - uses sentence-transformers for embeddings.
5
+
6
+ Features:
7
+ - Session summaries
8
+ - Code learnings
9
+ - Decision log
10
+ - Error memory
11
+ - Semantic search (vector-based)
12
+ - Auto-update on changes
13
+ - Branch/commit-aware knowledge tracking
14
+ - Version-specific memory retrieval
15
+
16
+ Storage:
17
+ - ChromaDB (default): Semantic/vector search with local embeddings
18
+ - JSON (fallback): Simple keyword search for fast operations
19
+
20
+ ChromaDB is installed automatically with up-cli.
21
+ First run may take 30-60s to download embedding model (~100MB).
22
+ """
23
+
24
+ import json
25
+ import hashlib
26
+ import subprocess
27
+ from dataclasses import dataclass, field, asdict
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Optional, List, Dict, Any, Tuple
31
+
32
+ # ChromaDB is now a required dependency
33
+ def _check_chromadb():
34
+ """Check if chromadb is available."""
35
+ try:
36
+ import chromadb
37
+ return True
38
+ except ImportError:
39
+ return False
40
+
41
+
42
+ def _ensure_chromadb():
43
+ """Ensure ChromaDB is installed, provide helpful message if not."""
44
+ if not _check_chromadb():
45
+ raise ImportError(
46
+ "ChromaDB is required for up-cli memory system.\n"
47
+ "Install with: pip install up-cli[all]\n"
48
+ "Or: pip install chromadb"
49
+ )
50
+
51
+
52
+ def _get_git_context(workspace: Path) -> Dict[str, str]:
53
+ """Get current git context (branch, commit, etc.).
54
+
55
+ Returns dict with:
56
+ - branch: Current branch name
57
+ - commit: Current commit hash (short)
58
+ - commit_full: Full commit hash
59
+ - tag: Current tag if HEAD is tagged
60
+ """
61
+ context = {
62
+ "branch": "unknown",
63
+ "commit": "unknown",
64
+ "commit_full": "unknown",
65
+ "tag": None,
66
+ }
67
+
68
+ try:
69
+ # Get current branch
70
+ result = subprocess.run(
71
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
72
+ cwd=workspace,
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=5
76
+ )
77
+ if result.returncode == 0:
78
+ context["branch"] = result.stdout.strip()
79
+
80
+ # Get current commit (short)
81
+ result = subprocess.run(
82
+ ["git", "rev-parse", "--short", "HEAD"],
83
+ cwd=workspace,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=5
87
+ )
88
+ if result.returncode == 0:
89
+ context["commit"] = result.stdout.strip()
90
+
91
+ # Get full commit hash
92
+ result = subprocess.run(
93
+ ["git", "rev-parse", "HEAD"],
94
+ cwd=workspace,
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=5
98
+ )
99
+ if result.returncode == 0:
100
+ context["commit_full"] = result.stdout.strip()
101
+
102
+ # Get tag if exists
103
+ result = subprocess.run(
104
+ ["git", "describe", "--tags", "--exact-match", "HEAD"],
105
+ cwd=workspace,
106
+ capture_output=True,
107
+ text=True,
108
+ timeout=5
109
+ )
110
+ if result.returncode == 0:
111
+ context["tag"] = result.stdout.strip()
112
+
113
+ except (subprocess.TimeoutExpired, FileNotFoundError):
114
+ pass
115
+
116
+ return context
117
+
118
+
119
+ # =============================================================================
120
+ # Data Models
121
+ # =============================================================================
122
+
123
+ @dataclass
124
+ class MemoryEntry:
125
+ """A single memory entry with git context."""
126
+ id: str
127
+ type: str # 'session', 'learning', 'decision', 'error', 'code', 'commit'
128
+ content: str
129
+ metadata: Dict[str, Any] = field(default_factory=dict)
130
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
131
+ # Git context - automatically populated
132
+ branch: Optional[str] = None
133
+ commit: Optional[str] = None
134
+
135
+ def to_dict(self) -> dict:
136
+ return asdict(self)
137
+
138
+
139
+ @dataclass
140
+ class SessionSummary:
141
+ """Summary of an AI session."""
142
+ session_id: str
143
+ started_at: str
144
+ ended_at: str
145
+ summary: str
146
+ tasks_completed: List[str] = field(default_factory=list)
147
+ files_modified: List[str] = field(default_factory=list)
148
+ key_decisions: List[str] = field(default_factory=list)
149
+ learnings: List[str] = field(default_factory=list)
150
+ errors_encountered: List[str] = field(default_factory=list)
151
+
152
+
153
+ @dataclass
154
+ class CodeLearning:
155
+ """A learning extracted from code changes."""
156
+ pattern: str
157
+ description: str
158
+ example: str
159
+ file_path: str
160
+ commit_hash: Optional[str] = None
161
+
162
+
163
+ @dataclass
164
+ class ErrorMemory:
165
+ """Memory of an error and its solution."""
166
+ error_type: str
167
+ error_message: str
168
+ context: str
169
+ solution: str
170
+ prevention: str
171
+
172
+
173
+ # =============================================================================
174
+ # Memory Store (Abstract)
175
+ # =============================================================================
176
+
177
+ class MemoryStore:
178
+ """Abstract base for memory storage."""
179
+
180
+ def add(self, entry: MemoryEntry) -> None:
181
+ raise NotImplementedError
182
+
183
+ def search(self, query: str, limit: int = 5) -> List[MemoryEntry]:
184
+ raise NotImplementedError
185
+
186
+ def get_by_type(self, entry_type: str, limit: int = 10) -> List[MemoryEntry]:
187
+ raise NotImplementedError
188
+
189
+ def delete(self, entry_id: str) -> bool:
190
+ raise NotImplementedError
191
+
192
+ def clear(self) -> None:
193
+ raise NotImplementedError
194
+
195
+
196
+ # =============================================================================
197
+ # ChromaDB Implementation (Vector Search)
198
+ # =============================================================================
199
+
200
+ class ChromaMemoryStore(MemoryStore):
201
+ """Memory store using ChromaDB with local embeddings.
202
+
203
+ Uses ChromaDB's default embedding function (all-MiniLM-L6-v2).
204
+ First initialization downloads the model (~100MB), which takes 30-60s.
205
+ Subsequent loads are fast (2-5s).
206
+
207
+ Storage location: .up/memory/chroma/
208
+ """
209
+
210
+ def __init__(self, workspace: Path):
211
+ import chromadb
212
+
213
+ self.workspace = workspace
214
+ self.db_path = workspace / ".up" / "memory" / "chroma"
215
+ self.db_path.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Check if this is first-time initialization
218
+ is_first_time = not (self.db_path / "chroma.sqlite3").exists()
219
+
220
+ if is_first_time:
221
+ import sys
222
+ print(
223
+ "Initializing ChromaDB (first time setup)...\n"
224
+ "Downloading embedding model (~100MB). This may take 30-60 seconds.",
225
+ file=sys.stderr
226
+ )
227
+
228
+ # Initialize ChromaDB with persistent storage (new API)
229
+ self.client = chromadb.PersistentClient(
230
+ path=str(self.db_path),
231
+ settings=chromadb.Settings(
232
+ anonymized_telemetry=False,
233
+ allow_reset=True,
234
+ )
235
+ )
236
+
237
+ # Get or create collection (uses default embedding function)
238
+ self.collection = self.client.get_or_create_collection(
239
+ name="up_memory",
240
+ metadata={"description": "Long-term memory for up-cli"}
241
+ )
242
+
243
+ if is_first_time:
244
+ import sys
245
+ print("ChromaDB ready!", file=sys.stderr)
246
+
247
+ def add(self, entry: MemoryEntry) -> None:
248
+ """Add entry to memory with auto-embedding."""
249
+ # Build metadata including branch/commit context
250
+ metadata = {
251
+ "type": entry.type,
252
+ "timestamp": entry.timestamp,
253
+ "branch": entry.branch or "unknown",
254
+ "commit": entry.commit or "unknown",
255
+ **{k: v for k, v in entry.metadata.items() if v is not None}
256
+ }
257
+
258
+ # ChromaDB doesn't allow None values in metadata
259
+ metadata = {k: v for k, v in metadata.items() if v is not None}
260
+
261
+ self.collection.add(
262
+ ids=[entry.id],
263
+ documents=[entry.content],
264
+ metadatas=[metadata]
265
+ )
266
+
267
+ def search(self, query: str, limit: int = 5,
268
+ entry_type: Optional[str] = None) -> List[MemoryEntry]:
269
+ """Semantic search for relevant memories."""
270
+ where = {"type": entry_type} if entry_type else None
271
+
272
+ results = self.collection.query(
273
+ query_texts=[query],
274
+ n_results=limit,
275
+ where=where
276
+ )
277
+
278
+ entries = []
279
+ if results and results['ids']:
280
+ for i, id_ in enumerate(results['ids'][0]):
281
+ meta = results['metadatas'][0][i]
282
+ entries.append(MemoryEntry(
283
+ id=id_,
284
+ type=meta.get('type', 'unknown'),
285
+ content=results['documents'][0][i],
286
+ metadata=meta,
287
+ timestamp=meta.get('timestamp', ''),
288
+ branch=meta.get('branch'),
289
+ commit=meta.get('commit'),
290
+ ))
291
+
292
+ return entries
293
+
294
+ def get_by_type(self, entry_type: str, limit: int = 10) -> List[MemoryEntry]:
295
+ """Get entries by type."""
296
+ results = self.collection.get(
297
+ where={"type": entry_type},
298
+ limit=limit
299
+ )
300
+
301
+ entries = []
302
+ if results and results['ids']:
303
+ for i, id_ in enumerate(results['ids']):
304
+ meta = results['metadatas'][i]
305
+ entries.append(MemoryEntry(
306
+ id=id_,
307
+ type=entry_type,
308
+ content=results['documents'][i],
309
+ metadata=meta,
310
+ timestamp=meta.get('timestamp', ''),
311
+ branch=meta.get('branch'),
312
+ commit=meta.get('commit'),
313
+ ))
314
+
315
+ return entries
316
+
317
+ def delete(self, entry_id: str) -> bool:
318
+ """Delete an entry."""
319
+ try:
320
+ self.collection.delete(ids=[entry_id])
321
+ return True
322
+ except Exception:
323
+ return False
324
+
325
+ def clear(self) -> None:
326
+ """Clear all memories."""
327
+ self.client.delete_collection("up_memory")
328
+ self.collection = self.client.create_collection(
329
+ name="up_memory",
330
+ metadata={"description": "Long-term memory for up-cli"}
331
+ )
332
+
333
+ def persist(self) -> None:
334
+ """Persist to disk (automatic with PersistentClient)."""
335
+ # PersistentClient auto-persists, but we keep method for API compatibility
336
+ pass
337
+
338
+
339
+ # =============================================================================
340
+ # JSON Implementation (Fallback - No Dependencies)
341
+ # =============================================================================
342
+
343
+ class JSONMemoryStore(MemoryStore):
344
+ """Simple JSON-based memory store with keyword search."""
345
+
346
+ def __init__(self, workspace: Path):
347
+ self.workspace = workspace
348
+ self.db_path = workspace / ".up" / "memory"
349
+ self.db_path.mkdir(parents=True, exist_ok=True)
350
+ self.index_file = self.db_path / "index.json"
351
+ self.entries: Dict[str, MemoryEntry] = {}
352
+ self._load()
353
+
354
+ def _load(self) -> None:
355
+ """Load from disk."""
356
+ if self.index_file.exists():
357
+ try:
358
+ data = json.loads(self.index_file.read_text())
359
+ for id_, entry_data in data.items():
360
+ self.entries[id_] = MemoryEntry(**entry_data)
361
+ except (json.JSONDecodeError, TypeError):
362
+ pass
363
+
364
+ def _save(self) -> None:
365
+ """Save to disk."""
366
+ data = {id_: entry.to_dict() for id_, entry in self.entries.items()}
367
+ self.index_file.write_text(json.dumps(data, indent=2))
368
+
369
+ def add(self, entry: MemoryEntry) -> None:
370
+ """Add entry to memory."""
371
+ self.entries[entry.id] = entry
372
+ self._save()
373
+
374
+ def search(self, query: str, limit: int = 5,
375
+ entry_type: Optional[str] = None) -> List[MemoryEntry]:
376
+ """Keyword-based search."""
377
+ query_lower = query.lower()
378
+ query_words = set(query_lower.split())
379
+
380
+ scored = []
381
+ for entry in self.entries.values():
382
+ if entry_type and entry.type != entry_type:
383
+ continue
384
+
385
+ content_lower = entry.content.lower()
386
+ # Simple scoring: count matching words
387
+ score = sum(1 for word in query_words if word in content_lower)
388
+ if score > 0:
389
+ scored.append((score, entry))
390
+
391
+ # Sort by score descending
392
+ scored.sort(key=lambda x: x[0], reverse=True)
393
+ return [entry for _, entry in scored[:limit]]
394
+
395
+ def get_by_type(self, entry_type: str, limit: int = 10) -> List[MemoryEntry]:
396
+ """Get entries by type."""
397
+ entries = [e for e in self.entries.values() if e.type == entry_type]
398
+ entries.sort(key=lambda e: e.timestamp, reverse=True)
399
+ return entries[:limit]
400
+
401
+ def delete(self, entry_id: str) -> bool:
402
+ """Delete an entry."""
403
+ if entry_id in self.entries:
404
+ del self.entries[entry_id]
405
+ self._save()
406
+ return True
407
+ return False
408
+
409
+ def clear(self) -> None:
410
+ """Clear all memories."""
411
+ self.entries.clear()
412
+ self._save()
413
+
414
+
415
+ # =============================================================================
416
+ # Memory Manager (Main Interface)
417
+ # =============================================================================
418
+
419
+ class MemoryManager:
420
+ """Main interface for the memory system.
421
+
422
+ Uses ChromaDB for semantic search with local embeddings.
423
+ Falls back to JSON only if explicitly requested (use_vectors=False).
424
+
425
+ Knowledge Tracking:
426
+ - Every memory entry is tagged with the current branch and commit
427
+ - Search can be filtered by branch to get branch-specific knowledge
428
+ - Compare knowledge across branches (what was learned on feature-x?)
429
+ - Track when knowledge was created relative to commits
430
+
431
+ First-time initialization:
432
+ - ChromaDB will download embedding model (~100MB) on first use
433
+ - This takes 30-60 seconds, subsequent loads are fast (2-5s)
434
+ """
435
+
436
+ def __init__(self, workspace: Optional[Path] = None, use_vectors: bool = True):
437
+ self.workspace = workspace or Path.cwd()
438
+
439
+ # ChromaDB is the default (required dependency)
440
+ # JSON is only used when explicitly requested for fast operations
441
+ if use_vectors:
442
+ if _check_chromadb():
443
+ try:
444
+ self.store = ChromaMemoryStore(self.workspace)
445
+ self._backend = "chromadb"
446
+ except Exception as e:
447
+ # Fall back to JSON if ChromaDB fails (e.g., corrupted DB)
448
+ import sys
449
+ print(f"Warning: ChromaDB failed ({e}), using JSON fallback", file=sys.stderr)
450
+ self.store = JSONMemoryStore(self.workspace)
451
+ self._backend = "json"
452
+ else:
453
+ # ChromaDB not installed - show helpful message
454
+ import sys
455
+ print(
456
+ "Note: ChromaDB not found. Using JSON (keyword search).\n"
457
+ "For semantic search, install: pip install chromadb",
458
+ file=sys.stderr
459
+ )
460
+ self.store = JSONMemoryStore(self.workspace)
461
+ self._backend = "json"
462
+ else:
463
+ # Explicitly requested JSON (fast mode)
464
+ self.store = JSONMemoryStore(self.workspace)
465
+ self._backend = "json"
466
+
467
+ self.config_file = self.workspace / ".up" / "memory_config.json"
468
+ self.config = self._load_config()
469
+
470
+ # Cache git context (refreshed on demand)
471
+ self._git_context_cache: Optional[Dict[str, str]] = None
472
+ self._git_context_time: Optional[datetime] = None
473
+
474
+ def _load_config(self) -> dict:
475
+ """Load configuration."""
476
+ if self.config_file.exists():
477
+ try:
478
+ return json.loads(self.config_file.read_text())
479
+ except json.JSONDecodeError:
480
+ pass
481
+ return {
482
+ "auto_index_commits": True,
483
+ "auto_summarize_sessions": True,
484
+ "max_entries_per_type": 100,
485
+ "track_branches": True, # Track branch context
486
+ }
487
+
488
+ def _save_config(self) -> None:
489
+ """Save configuration."""
490
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
491
+ self.config_file.write_text(json.dumps(self.config, indent=2))
492
+
493
+ def _generate_id(self, prefix: str, content: str) -> str:
494
+ """Generate unique ID for entry."""
495
+ hash_input = f"{prefix}:{content}:{datetime.now().isoformat()}"
496
+ return f"{prefix}_{hashlib.md5(hash_input.encode()).hexdigest()[:12]}"
497
+
498
+ def _get_git_context(self, force_refresh: bool = False) -> Dict[str, str]:
499
+ """Get current git context with caching.
500
+
501
+ Caches for 60 seconds to avoid repeated git calls.
502
+ """
503
+ now = datetime.now()
504
+
505
+ # Use cache if fresh (within 60 seconds)
506
+ if (not force_refresh
507
+ and self._git_context_cache
508
+ and self._git_context_time
509
+ and (now - self._git_context_time).seconds < 60):
510
+ return self._git_context_cache
511
+
512
+ # Refresh cache
513
+ self._git_context_cache = _get_git_context(self.workspace)
514
+ self._git_context_time = now
515
+
516
+ return self._git_context_cache
517
+
518
+ def _create_entry_with_context(
519
+ self,
520
+ prefix: str,
521
+ entry_type: str,
522
+ content: str,
523
+ extra_metadata: Optional[Dict[str, Any]] = None
524
+ ) -> MemoryEntry:
525
+ """Create a memory entry with automatic git context."""
526
+ git_ctx = self._get_git_context()
527
+
528
+ metadata = extra_metadata or {}
529
+ metadata["session"] = self._get_current_session_id()
530
+
531
+ return MemoryEntry(
532
+ id=self._generate_id(prefix, content),
533
+ type=entry_type,
534
+ content=content,
535
+ metadata=metadata,
536
+ branch=git_ctx["branch"],
537
+ commit=git_ctx["commit"],
538
+ )
539
+
540
+ # -------------------------------------------------------------------------
541
+ # Session Management
542
+ # -------------------------------------------------------------------------
543
+
544
+ def start_session(self) -> str:
545
+ """Start a new session and return session ID."""
546
+ session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
547
+
548
+ session_file = self.workspace / ".up" / "current_session.json"
549
+ session_file.parent.mkdir(parents=True, exist_ok=True)
550
+ session_file.write_text(json.dumps({
551
+ "session_id": session_id,
552
+ "started_at": datetime.now().isoformat(),
553
+ "tasks": [],
554
+ "files_modified": [],
555
+ "decisions": [],
556
+ "learnings": [],
557
+ "errors": [],
558
+ }, indent=2))
559
+
560
+ return session_id
561
+
562
+ def end_session(self, summary: str = None) -> None:
563
+ """End current session and save summary to memory."""
564
+ session_file = self.workspace / ".up" / "current_session.json"
565
+
566
+ if not session_file.exists():
567
+ return
568
+
569
+ session_data = json.loads(session_file.read_text())
570
+ session_id = session_data.get("session_id", "unknown")
571
+
572
+ # Auto-generate summary if not provided
573
+ if not summary:
574
+ summary = self._auto_summarize_session(session_data)
575
+
576
+ # Create memory entry
577
+ entry = MemoryEntry(
578
+ id=session_id,
579
+ type="session",
580
+ content=summary,
581
+ metadata={
582
+ "started_at": session_data.get("started_at"),
583
+ "ended_at": datetime.now().isoformat(),
584
+ "tasks_count": len(session_data.get("tasks", [])),
585
+ "files_count": len(session_data.get("files_modified", [])),
586
+ }
587
+ )
588
+
589
+ self.store.add(entry)
590
+
591
+ # Clean up session file
592
+ session_file.unlink()
593
+
594
+ def _auto_summarize_session(self, session_data: dict) -> str:
595
+ """Generate automatic session summary."""
596
+ parts = []
597
+
598
+ tasks = session_data.get("tasks", [])
599
+ if tasks:
600
+ parts.append(f"Completed tasks: {', '.join(tasks)}")
601
+
602
+ files = session_data.get("files_modified", [])
603
+ if files:
604
+ parts.append(f"Modified files: {', '.join(files[:5])}")
605
+ if len(files) > 5:
606
+ parts.append(f" ...and {len(files) - 5} more")
607
+
608
+ decisions = session_data.get("decisions", [])
609
+ if decisions:
610
+ parts.append(f"Key decisions: {'; '.join(decisions)}")
611
+
612
+ learnings = session_data.get("learnings", [])
613
+ if learnings:
614
+ parts.append(f"Learnings: {'; '.join(learnings)}")
615
+
616
+ return "\n".join(parts) if parts else "Session with no recorded activity."
617
+
618
+ def record_task(self, task: str) -> None:
619
+ """Record a completed task in current session."""
620
+ self._update_session("tasks", task)
621
+
622
+ def record_file(self, file_path: str) -> None:
623
+ """Record a modified file in current session."""
624
+ self._update_session("files_modified", file_path)
625
+
626
+ def record_decision(self, decision: str) -> None:
627
+ """Record a decision in current session with git context."""
628
+ self._update_session("decisions", decision)
629
+
630
+ # Also add to long-term memory with branch/commit context
631
+ entry = self._create_entry_with_context(
632
+ prefix="decision",
633
+ entry_type="decision",
634
+ content=decision,
635
+ )
636
+ self.store.add(entry)
637
+
638
+ def record_learning(self, learning: str) -> None:
639
+ """Record a learning in current session with git context."""
640
+ self._update_session("learnings", learning)
641
+
642
+ # Also add to long-term memory with branch/commit context
643
+ entry = self._create_entry_with_context(
644
+ prefix="learning",
645
+ entry_type="learning",
646
+ content=learning,
647
+ )
648
+ self.store.add(entry)
649
+
650
+ def record_error(self, error: str, solution: str = None) -> None:
651
+ """Record an error and optional solution with git context."""
652
+ content = f"Error: {error}"
653
+ if solution:
654
+ content += f"\nSolution: {solution}"
655
+
656
+ self._update_session("errors", error)
657
+
658
+ entry = self._create_entry_with_context(
659
+ prefix="error",
660
+ entry_type="error",
661
+ content=content,
662
+ extra_metadata={
663
+ "error": error,
664
+ "solution": solution,
665
+ }
666
+ )
667
+ self.store.add(entry)
668
+
669
+ def _update_session(self, key: str, value: str) -> None:
670
+ """Update current session data."""
671
+ session_file = self.workspace / ".up" / "current_session.json"
672
+
673
+ if session_file.exists():
674
+ data = json.loads(session_file.read_text())
675
+ if key not in data:
676
+ data[key] = []
677
+ if value not in data[key]:
678
+ data[key].append(value)
679
+ session_file.write_text(json.dumps(data, indent=2))
680
+
681
+ def _get_current_session_id(self) -> str:
682
+ """Get current session ID."""
683
+ session_file = self.workspace / ".up" / "current_session.json"
684
+ if session_file.exists():
685
+ data = json.loads(session_file.read_text())
686
+ return data.get("session_id", "unknown")
687
+ return "no_session"
688
+
689
+ # -------------------------------------------------------------------------
690
+ # Auto-Update from Git
691
+ # -------------------------------------------------------------------------
692
+
693
+ def index_recent_commits(self, count: int = 10) -> int:
694
+ """Index recent git commits into memory with branch context.
695
+
696
+ Each commit is tagged with the branch it was indexed from.
697
+ This allows tracking which branches contain which knowledge.
698
+ """
699
+ if not self.config.get("auto_index_commits", True):
700
+ return 0
701
+
702
+ try:
703
+ # Get current branch for context
704
+ git_ctx = self._get_git_context(force_refresh=True)
705
+ current_branch = git_ctx["branch"]
706
+
707
+ # Get commit log with more details
708
+ result = subprocess.run(
709
+ ["git", "log", f"-{count}",
710
+ "--pretty=format:%H|%h|%s|%b|%D|||"],
711
+ cwd=self.workspace,
712
+ capture_output=True,
713
+ text=True,
714
+ timeout=10
715
+ )
716
+
717
+ if result.returncode != 0:
718
+ return 0
719
+
720
+ indexed = 0
721
+ commits = result.stdout.split("|||")
722
+
723
+ for commit in commits:
724
+ if not commit.strip():
725
+ continue
726
+
727
+ parts = commit.strip().split("|", 4)
728
+ if len(parts) < 3:
729
+ continue
730
+
731
+ commit_hash_full = parts[0]
732
+ commit_hash = parts[1]
733
+ subject = parts[2]
734
+ body = parts[3] if len(parts) > 3 else ""
735
+ refs = parts[4] if len(parts) > 4 else ""
736
+
737
+ entry_id = f"commit_{commit_hash}"
738
+
739
+ # Check if already indexed
740
+ existing = self.store.search(commit_hash, limit=1)
741
+ if existing and any(e.id == entry_id for e in existing):
742
+ continue
743
+
744
+ content = f"Commit {commit_hash}: {subject}"
745
+ if body.strip():
746
+ content += f"\n\n{body.strip()}"
747
+
748
+ # Parse refs to find associated branches/tags
749
+ branches_in_commit = []
750
+ tags_in_commit = []
751
+ if refs:
752
+ for ref in refs.split(", "):
753
+ ref = ref.strip()
754
+ if ref.startswith("tag:"):
755
+ tags_in_commit.append(ref[4:].strip())
756
+ elif ref and not ref.startswith("HEAD"):
757
+ branches_in_commit.append(ref)
758
+
759
+ # Convert lists to strings (ChromaDB doesn't accept lists)
760
+ entry = MemoryEntry(
761
+ id=entry_id,
762
+ type="commit",
763
+ content=content,
764
+ metadata={
765
+ "hash": commit_hash,
766
+ "hash_full": commit_hash_full,
767
+ "subject": subject,
768
+ "branches": ", ".join(branches_in_commit) if branches_in_commit else "",
769
+ "tags": ", ".join(tags_in_commit) if tags_in_commit else "",
770
+ },
771
+ branch=current_branch,
772
+ commit=commit_hash,
773
+ )
774
+ self.store.add(entry)
775
+ indexed += 1
776
+
777
+ return indexed
778
+
779
+ except (subprocess.TimeoutExpired, FileNotFoundError):
780
+ return 0
781
+
782
+ def index_file_changes(self) -> int:
783
+ """Index recent file changes."""
784
+ try:
785
+ result = subprocess.run(
786
+ ["git", "diff", "--name-only", "HEAD~5..HEAD"],
787
+ cwd=self.workspace,
788
+ capture_output=True,
789
+ text=True,
790
+ timeout=10
791
+ )
792
+
793
+ if result.returncode != 0:
794
+ return 0
795
+
796
+ files = result.stdout.strip().split("\n")
797
+ indexed = 0
798
+
799
+ for file_path in files:
800
+ if not file_path.strip():
801
+ continue
802
+
803
+ full_path = self.workspace / file_path
804
+ if not full_path.exists():
805
+ continue
806
+
807
+ # Only index code files
808
+ if full_path.suffix not in {'.py', '.js', '.ts', '.tsx', '.go', '.rs'}:
809
+ continue
810
+
811
+ try:
812
+ content = full_path.read_text()[:2000] # First 2000 chars
813
+ except Exception:
814
+ continue
815
+
816
+ entry_id = f"file_{hashlib.md5(file_path.encode()).hexdigest()[:12]}"
817
+
818
+ entry = MemoryEntry(
819
+ id=entry_id,
820
+ type="code",
821
+ content=f"File: {file_path}\n\n{content}",
822
+ metadata={"path": file_path}
823
+ )
824
+ self.store.add(entry)
825
+ indexed += 1
826
+
827
+ return indexed
828
+
829
+ except (subprocess.TimeoutExpired, FileNotFoundError):
830
+ return 0
831
+
832
+ # -------------------------------------------------------------------------
833
+ # Search & Retrieval
834
+ # -------------------------------------------------------------------------
835
+
836
+ def search(self, query: str, limit: int = 5,
837
+ entry_type: Optional[str] = None,
838
+ branch: Optional[str] = None) -> List[MemoryEntry]:
839
+ """Search memory for relevant entries.
840
+
841
+ Args:
842
+ query: Search query
843
+ limit: Max results
844
+ entry_type: Filter by type (learning, decision, error, etc.)
845
+ branch: Filter by branch (None = all branches)
846
+ """
847
+ results = self.store.search(query, limit * 2 if branch else limit, entry_type)
848
+
849
+ # Filter by branch if specified
850
+ if branch:
851
+ results = [e for e in results if e.branch == branch][:limit]
852
+
853
+ return results
854
+
855
+ def search_on_branch(self, query: str, branch: str, limit: int = 5) -> List[MemoryEntry]:
856
+ """Search for knowledge on a specific branch."""
857
+ return self.search(query, limit, branch=branch)
858
+
859
+ def search_current_branch(self, query: str, limit: int = 5) -> List[MemoryEntry]:
860
+ """Search for knowledge on the current branch only."""
861
+ git_ctx = self._get_git_context()
862
+ return self.search(query, limit, branch=git_ctx["branch"])
863
+
864
+ def recall(self, topic: str, branch: Optional[str] = None) -> str:
865
+ """Recall information about a topic (returns formatted text).
866
+
867
+ Args:
868
+ topic: Topic to recall
869
+ branch: Optional branch filter (None = all branches)
870
+ """
871
+ entries = self.search(topic, limit=5, branch=branch)
872
+
873
+ if not entries:
874
+ branch_info = f" on branch '{branch}'" if branch else ""
875
+ return f"No memories found about '{topic}'{branch_info}."
876
+
877
+ lines = [f"Memories about '{topic}':\n"]
878
+ for entry in entries:
879
+ branch_tag = f" @{entry.branch}" if entry.branch else ""
880
+ lines.append(f"[{entry.type}]{branch_tag} {entry.content[:200]}...")
881
+ lines.append("")
882
+
883
+ return "\n".join(lines)
884
+
885
+ def get_branch_knowledge(self, branch: str) -> Dict[str, List[MemoryEntry]]:
886
+ """Get all knowledge recorded on a specific branch.
887
+
888
+ Returns dict organized by type:
889
+ {
890
+ "learnings": [...],
891
+ "decisions": [...],
892
+ "errors": [...],
893
+ "commits": [...],
894
+ }
895
+ """
896
+ result = {
897
+ "learnings": [],
898
+ "decisions": [],
899
+ "errors": [],
900
+ "commits": [],
901
+ }
902
+
903
+ for entry_type in result.keys():
904
+ entries = self.store.get_by_type(entry_type[:-1] if entry_type.endswith("s") else entry_type, 100)
905
+ result[entry_type] = [e for e in entries if e.branch == branch]
906
+
907
+ return result
908
+
909
+ def compare_branches(self, branch1: str, branch2: str) -> Dict[str, Any]:
910
+ """Compare knowledge between two branches.
911
+
912
+ Useful for seeing what was learned on a feature branch
913
+ that isn't on main yet.
914
+ """
915
+ knowledge1 = self.get_branch_knowledge(branch1)
916
+ knowledge2 = self.get_branch_knowledge(branch2)
917
+
918
+ return {
919
+ "branch1": {
920
+ "name": branch1,
921
+ "total": sum(len(v) for v in knowledge1.values()),
922
+ "learnings": len(knowledge1["learnings"]),
923
+ "decisions": len(knowledge1["decisions"]),
924
+ },
925
+ "branch2": {
926
+ "name": branch2,
927
+ "total": sum(len(v) for v in knowledge2.values()),
928
+ "learnings": len(knowledge2["learnings"]),
929
+ "decisions": len(knowledge2["decisions"]),
930
+ },
931
+ "unique_to_branch1": {
932
+ "learnings": [e for e in knowledge1["learnings"]
933
+ if e.content not in [x.content for x in knowledge2["learnings"]]],
934
+ "decisions": [e for e in knowledge1["decisions"]
935
+ if e.content not in [x.content for x in knowledge2["decisions"]]],
936
+ },
937
+ "unique_to_branch2": {
938
+ "learnings": [e for e in knowledge2["learnings"]
939
+ if e.content not in [x.content for x in knowledge1["learnings"]]],
940
+ "decisions": [e for e in knowledge2["decisions"]
941
+ if e.content not in [x.content for x in knowledge1["decisions"]]],
942
+ },
943
+ }
944
+
945
+ def get_recent_sessions(self, limit: int = 5) -> List[MemoryEntry]:
946
+ """Get recent session summaries."""
947
+ return self.store.get_by_type("session", limit)
948
+
949
+ def get_learnings(self, limit: int = 10, branch: Optional[str] = None) -> List[MemoryEntry]:
950
+ """Get recorded learnings, optionally filtered by branch."""
951
+ entries = self.store.get_by_type("learning", limit * 2 if branch else limit)
952
+ if branch:
953
+ entries = [e for e in entries if e.branch == branch][:limit]
954
+ return entries
955
+
956
+ def get_decisions(self, limit: int = 10, branch: Optional[str] = None) -> List[MemoryEntry]:
957
+ """Get recorded decisions, optionally filtered by branch."""
958
+ entries = self.store.get_by_type("decision", limit * 2 if branch else limit)
959
+ if branch:
960
+ entries = [e for e in entries if e.branch == branch][:limit]
961
+ return entries
962
+
963
+ def get_errors(self, limit: int = 10, branch: Optional[str] = None) -> List[MemoryEntry]:
964
+ """Get recorded errors, optionally filtered by branch."""
965
+ entries = self.store.get_by_type("error", limit * 2 if branch else limit)
966
+ if branch:
967
+ entries = [e for e in entries if e.branch == branch][:limit]
968
+ return entries
969
+
970
+ def get_current_context(self) -> Dict[str, Any]:
971
+ """Get current git context and relevant memories."""
972
+ git_ctx = self._get_git_context(force_refresh=True)
973
+
974
+ return {
975
+ "branch": git_ctx["branch"],
976
+ "commit": git_ctx["commit"],
977
+ "tag": git_ctx.get("tag"),
978
+ "branch_learnings": len(self.get_learnings(100, branch=git_ctx["branch"])),
979
+ "branch_decisions": len(self.get_decisions(100, branch=git_ctx["branch"])),
980
+ "total_memories": self.get_stats()["total"],
981
+ }
982
+
983
+ # -------------------------------------------------------------------------
984
+ # Management
985
+ # -------------------------------------------------------------------------
986
+
987
+ def get_stats(self) -> dict:
988
+ """Get memory statistics including branch info."""
989
+ # Get entries by type
990
+ all_entries = []
991
+ for entry_type in ["session", "learning", "decision", "error", "commit", "code"]:
992
+ all_entries.extend(self.store.get_by_type(entry_type, 1000))
993
+
994
+ # Count branches
995
+ branches = {}
996
+ for entry in all_entries:
997
+ branch = entry.branch or "unknown"
998
+ if branch not in branches:
999
+ branches[branch] = 0
1000
+ branches[branch] += 1
1001
+
1002
+ # Current context
1003
+ git_ctx = self._get_git_context()
1004
+
1005
+ stats = {
1006
+ "backend": self._backend,
1007
+ "sessions": len(self.store.get_by_type("session", 1000)),
1008
+ "learnings": len(self.store.get_by_type("learning", 1000)),
1009
+ "decisions": len(self.store.get_by_type("decision", 1000)),
1010
+ "errors": len(self.store.get_by_type("error", 1000)),
1011
+ "commits": len(self.store.get_by_type("commit", 1000)),
1012
+ "code_files": len(self.store.get_by_type("code", 1000)),
1013
+ "branches": branches,
1014
+ "branch_count": len(branches),
1015
+ "current_branch": git_ctx["branch"],
1016
+ "current_commit": git_ctx["commit"],
1017
+ }
1018
+ stats["total"] = sum(v for k, v in stats.items() if isinstance(v, int) and k not in ["branch_count"])
1019
+ return stats
1020
+
1021
+ def clear(self) -> None:
1022
+ """Clear all memory."""
1023
+ self.store.clear()
1024
+
1025
+ def sync(self) -> dict:
1026
+ """Sync memory with current state (index commits, files, etc.)."""
1027
+ results = {
1028
+ "commits_indexed": self.index_recent_commits(),
1029
+ "files_indexed": self.index_file_changes(),
1030
+ }
1031
+ return results
1032
+
1033
+
1034
+ # =============================================================================
1035
+ # CLI
1036
+ # =============================================================================
1037
+
1038
+ def main():
1039
+ """CLI for memory system."""
1040
+ import sys
1041
+
1042
+ manager = MemoryManager()
1043
+
1044
+ if len(sys.argv) < 2:
1045
+ print("Usage: python memory.py <command> [args]")
1046
+ print("\nCommands:")
1047
+ print(" search <query> Search memory")
1048
+ print(" recall <topic> Recall information")
1049
+ print(" stats Show statistics")
1050
+ print(" sync Sync with git")
1051
+ print(" clear Clear all memory")
1052
+ print(" start-session Start new session")
1053
+ print(" end-session End current session")
1054
+ return
1055
+
1056
+ cmd = sys.argv[1]
1057
+
1058
+ if cmd == "search" and len(sys.argv) > 2:
1059
+ query = " ".join(sys.argv[2:])
1060
+ results = manager.search(query)
1061
+ for entry in results:
1062
+ print(f"[{entry.type}] {entry.content[:100]}...")
1063
+ print()
1064
+
1065
+ elif cmd == "recall" and len(sys.argv) > 2:
1066
+ topic = " ".join(sys.argv[2:])
1067
+ print(manager.recall(topic))
1068
+
1069
+ elif cmd == "stats":
1070
+ stats = manager.get_stats()
1071
+ print(json.dumps(stats, indent=2))
1072
+
1073
+ elif cmd == "sync":
1074
+ results = manager.sync()
1075
+ print(f"Indexed {results['commits_indexed']} commits")
1076
+ print(f"Indexed {results['files_indexed']} files")
1077
+
1078
+ elif cmd == "clear":
1079
+ manager.clear()
1080
+ print("Memory cleared.")
1081
+
1082
+ elif cmd == "start-session":
1083
+ session_id = manager.start_session()
1084
+ print(f"Started session: {session_id}")
1085
+
1086
+ elif cmd == "end-session":
1087
+ summary = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else None
1088
+ manager.end_session(summary)
1089
+ print("Session ended and saved to memory.")
1090
+
1091
+ else:
1092
+ print(f"Unknown command: {cmd}")
1093
+
1094
+
1095
+ if __name__ == "__main__":
1096
+ main()