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.
- up/__init__.py +1 -1
- up/ai_cli.py +229 -0
- up/cli.py +54 -9
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1392 -32
- up/commands/memory.py +545 -0
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +752 -42
- up/commands/status.py +173 -18
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +64 -10
- up/core/__init__.py +69 -0
- up/core/checkpoint.py +479 -0
- up/core/provenance.py +364 -0
- up/core/state.py +678 -0
- up/events.py +512 -0
- up/git/__init__.py +37 -0
- up/git/utils.py +270 -0
- up/git/worktree.py +331 -0
- up/learn/__init__.py +155 -0
- up/learn/analyzer.py +227 -0
- up/learn/plan.py +374 -0
- up/learn/research.py +511 -0
- up/learn/utils.py +117 -0
- up/memory.py +1096 -0
- up/parallel.py +551 -0
- up/templates/config/__init__.py +1 -1
- up/templates/docs/SKILL.md +28 -0
- up/templates/docs/__init__.py +341 -0
- up/templates/docs/standards/HEADERS.md +24 -0
- up/templates/docs/standards/STRUCTURE.md +18 -0
- up/templates/docs/standards/TEMPLATES.md +19 -0
- up/templates/loop/__init__.py +92 -32
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.2.0.dist-info/RECORD +0 -23
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {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()
|