ragtime-cli 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.

Potentially problematic release.


This version of ragtime-cli might be problematic. Click here for more details.

src/memory.py ADDED
@@ -0,0 +1,379 @@
1
+ """
2
+ Memory storage for ragtime.
3
+
4
+ Handles structured memory storage in .claude/memory/ directory.
5
+ Each memory is a markdown file with YAML frontmatter.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from dataclasses import dataclass, field
10
+ from datetime import date
11
+ from typing import Optional
12
+ import uuid
13
+ import re
14
+ import yaml
15
+
16
+
17
+ @dataclass
18
+ class Memory:
19
+ """A single memory entry."""
20
+ content: str
21
+ namespace: str
22
+ type: str
23
+ id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
24
+ component: Optional[str] = None
25
+ confidence: str = "medium"
26
+ confidence_reason: Optional[str] = None
27
+ source: str = "manual"
28
+ status: str = "active"
29
+ added: str = field(default_factory=lambda: date.today().isoformat())
30
+ author: Optional[str] = None
31
+ issue: Optional[str] = None
32
+ epic: Optional[str] = None
33
+ branch: Optional[str] = None
34
+ supersedes: Optional[str] = None
35
+
36
+ def to_frontmatter(self) -> dict:
37
+ """Convert to YAML frontmatter dict."""
38
+ data = {
39
+ "id": self.id,
40
+ "namespace": self.namespace,
41
+ "type": self.type,
42
+ "confidence": self.confidence,
43
+ "source": self.source,
44
+ "status": self.status,
45
+ "added": self.added,
46
+ }
47
+
48
+ # Add optional fields if present
49
+ if self.component:
50
+ data["component"] = self.component
51
+ if self.confidence_reason:
52
+ data["confidence_reason"] = self.confidence_reason
53
+ if self.author:
54
+ data["author"] = self.author
55
+ if self.issue:
56
+ data["issue"] = self.issue
57
+ if self.epic:
58
+ data["epic"] = self.epic
59
+ if self.branch:
60
+ data["branch"] = self.branch
61
+ if self.supersedes:
62
+ data["supersedes"] = self.supersedes
63
+
64
+ return data
65
+
66
+ def to_markdown(self) -> str:
67
+ """Convert to markdown with YAML frontmatter."""
68
+ frontmatter = yaml.dump(self.to_frontmatter(), default_flow_style=False, sort_keys=False)
69
+ return f"---\n{frontmatter}---\n\n{self.content}\n"
70
+
71
+ def to_metadata(self) -> dict:
72
+ """Convert to metadata dict for ChromaDB."""
73
+ meta = self.to_frontmatter()
74
+ meta["file"] = self.get_relative_path()
75
+ return meta
76
+
77
+ def get_relative_path(self) -> str:
78
+ """Get the relative path for this memory's file."""
79
+ slug = self._slugify(self.content[:50])
80
+
81
+ if self.namespace == "app":
82
+ if self.component:
83
+ return f"app/{self.component}/{self.id}-{slug}.md"
84
+ return f"app/{self.id}-{slug}.md"
85
+ elif self.namespace == "team":
86
+ return f"team/{self.id}-{slug}.md"
87
+ elif self.namespace.startswith("user-"):
88
+ username = self.namespace.replace("user-", "")
89
+ return f"users/{username}/{self.id}-{slug}.md"
90
+ elif self.namespace.startswith("branch-"):
91
+ branch_slug = self._slugify(self.namespace.replace("branch-", ""))
92
+ return f"branches/{branch_slug}/{self.id}-{slug}.md"
93
+ else:
94
+ return f"other/{self.namespace}/{self.id}-{slug}.md"
95
+
96
+ @staticmethod
97
+ def _slugify(text: str) -> str:
98
+ """Convert text to a filename-safe slug."""
99
+ # Lowercase and replace spaces/special chars with hyphens
100
+ slug = re.sub(r'[^\w\s-]', '', text.lower())
101
+ slug = re.sub(r'[-\s]+', '-', slug).strip('-')
102
+ return slug[:40] # Limit length
103
+
104
+ @classmethod
105
+ def from_file(cls, path: Path) -> "Memory":
106
+ """Parse a memory from a markdown file with YAML frontmatter."""
107
+ text = path.read_text()
108
+
109
+ if not text.startswith("---"):
110
+ raise ValueError(f"No YAML frontmatter found in {path}")
111
+
112
+ # Split frontmatter and content
113
+ parts = text.split("---", 2)
114
+ if len(parts) < 3:
115
+ raise ValueError(f"Invalid frontmatter format in {path}")
116
+
117
+ frontmatter = yaml.safe_load(parts[1])
118
+ content = parts[2].strip()
119
+
120
+ return cls(
121
+ id=frontmatter.get("id", str(uuid.uuid4())[:8]),
122
+ content=content,
123
+ namespace=frontmatter.get("namespace", "app"),
124
+ type=frontmatter.get("type", "unknown"),
125
+ component=frontmatter.get("component"),
126
+ confidence=frontmatter.get("confidence", "medium"),
127
+ confidence_reason=frontmatter.get("confidence_reason"),
128
+ source=frontmatter.get("source", "file"),
129
+ status=frontmatter.get("status", "active"),
130
+ added=frontmatter.get("added", date.today().isoformat()),
131
+ author=frontmatter.get("author"),
132
+ issue=frontmatter.get("issue"),
133
+ epic=frontmatter.get("epic"),
134
+ branch=frontmatter.get("branch"),
135
+ supersedes=frontmatter.get("supersedes"),
136
+ )
137
+
138
+
139
+ class MemoryStore:
140
+ """
141
+ File-based memory storage.
142
+
143
+ Stores memories as markdown files with YAML frontmatter.
144
+ Also maintains a ChromaDB index for semantic search.
145
+ """
146
+
147
+ def __init__(self, project_path: Path, db):
148
+ """
149
+ Initialize the memory store.
150
+
151
+ Args:
152
+ project_path: Root of the project
153
+ db: RagtimeDB instance for vector search
154
+ """
155
+ self.project_path = project_path
156
+ self.memory_dir = project_path / ".claude" / "memory"
157
+ self.db = db
158
+
159
+ def save(self, memory: Memory) -> Path:
160
+ """
161
+ Save a memory to disk and index it.
162
+
163
+ Returns:
164
+ Path to the saved file
165
+ """
166
+ # Determine file path
167
+ relative_path = memory.get_relative_path()
168
+ file_path = self.memory_dir / relative_path
169
+
170
+ # Create directory if needed
171
+ file_path.parent.mkdir(parents=True, exist_ok=True)
172
+
173
+ # Write file
174
+ file_path.write_text(memory.to_markdown())
175
+
176
+ # Index in ChromaDB
177
+ self.db.upsert(
178
+ ids=[memory.id],
179
+ documents=[memory.content],
180
+ metadatas=[memory.to_metadata()],
181
+ )
182
+
183
+ return file_path
184
+
185
+ def get(self, memory_id: str) -> Optional[Memory]:
186
+ """Get a memory by ID."""
187
+ # Search in ChromaDB to find the file
188
+ results = self.db.collection.get(ids=[memory_id])
189
+
190
+ if not results["ids"]:
191
+ return None
192
+
193
+ metadata = results["metadatas"][0]
194
+ file_path = self.memory_dir / metadata.get("file", "")
195
+
196
+ if file_path.exists():
197
+ return Memory.from_file(file_path)
198
+
199
+ return None
200
+
201
+ def delete(self, memory_id: str) -> bool:
202
+ """Delete a memory by ID."""
203
+ memory = self.get(memory_id)
204
+ if not memory:
205
+ return False
206
+
207
+ # Delete file
208
+ file_path = self.memory_dir / memory.get_relative_path()
209
+ if file_path.exists():
210
+ file_path.unlink()
211
+
212
+ # Clean up empty directories
213
+ self._cleanup_empty_dirs(file_path.parent)
214
+
215
+ # Remove from index
216
+ self.db.delete([memory_id])
217
+
218
+ return True
219
+
220
+ def update_status(self, memory_id: str, status: str) -> bool:
221
+ """Update a memory's status (e.g., graduated, abandoned)."""
222
+ memory = self.get(memory_id)
223
+ if not memory:
224
+ return False
225
+
226
+ memory.status = status
227
+ self.save(memory)
228
+ return True
229
+
230
+ def graduate(self, memory_id: str, new_confidence: str = "high") -> Optional[Memory]:
231
+ """
232
+ Graduate a branch memory to app namespace.
233
+
234
+ Creates a copy in app namespace and marks original as graduated.
235
+ """
236
+ memory = self.get(memory_id)
237
+ if not memory:
238
+ return None
239
+
240
+ if not memory.namespace.startswith("branch-"):
241
+ raise ValueError("Can only graduate branch memories")
242
+
243
+ # Create graduated copy
244
+ graduated = Memory(
245
+ content=memory.content,
246
+ namespace="app",
247
+ type=memory.type,
248
+ component=memory.component,
249
+ confidence=new_confidence,
250
+ confidence_reason="pr-graduate",
251
+ source="pr-graduate",
252
+ status="active",
253
+ author=memory.author,
254
+ issue=memory.issue,
255
+ epic=memory.epic,
256
+ )
257
+
258
+ # Save the graduated memory
259
+ self.save(graduated)
260
+
261
+ # Mark original as graduated
262
+ memory.status = "graduated"
263
+ self.save(memory)
264
+
265
+ return graduated
266
+
267
+ def list_memories(
268
+ self,
269
+ namespace: Optional[str] = None,
270
+ type_filter: Optional[str] = None,
271
+ status: Optional[str] = None,
272
+ component: Optional[str] = None,
273
+ limit: int = 100,
274
+ ) -> list[Memory]:
275
+ """List memories with optional filters."""
276
+ where = {}
277
+
278
+ if namespace:
279
+ if namespace.endswith("*"):
280
+ # Prefix match - ChromaDB doesn't support this directly
281
+ # We'll filter in Python
282
+ pass
283
+ else:
284
+ where["namespace"] = namespace
285
+
286
+ if type_filter:
287
+ where["type"] = type_filter
288
+
289
+ if status:
290
+ where["status"] = status
291
+
292
+ if component:
293
+ where["component"] = component
294
+
295
+ # Get from ChromaDB
296
+ results = self.db.collection.get(
297
+ where=where if where else None,
298
+ limit=limit,
299
+ )
300
+
301
+ memories = []
302
+ for i, mem_id in enumerate(results["ids"]):
303
+ metadata = results["metadatas"][i]
304
+ content = results["documents"][i] if results["documents"] else ""
305
+
306
+ # Handle namespace prefix filtering
307
+ if namespace and namespace.endswith("*"):
308
+ prefix = namespace[:-1]
309
+ if not metadata.get("namespace", "").startswith(prefix):
310
+ continue
311
+
312
+ memories.append(Memory(
313
+ id=mem_id,
314
+ content=content,
315
+ namespace=metadata.get("namespace", "unknown"),
316
+ type=metadata.get("type", "unknown"),
317
+ component=metadata.get("component"),
318
+ confidence=metadata.get("confidence", "medium"),
319
+ source=metadata.get("source", "unknown"),
320
+ status=metadata.get("status", "active"),
321
+ added=metadata.get("added", ""),
322
+ author=metadata.get("author"),
323
+ ))
324
+
325
+ return memories
326
+
327
+ def store_document(self, file_path: Path, namespace: str, doc_type: str = "handoff") -> Memory:
328
+ """
329
+ Store a document verbatim (like handoff.md).
330
+
331
+ The file content becomes the memory content without processing.
332
+ """
333
+ content = file_path.read_text()
334
+
335
+ memory = Memory(
336
+ content=content,
337
+ namespace=namespace,
338
+ type=doc_type,
339
+ source="store-doc",
340
+ confidence="medium",
341
+ confidence_reason="document",
342
+ )
343
+
344
+ self.save(memory)
345
+ return memory
346
+
347
+ def reindex(self) -> int:
348
+ """
349
+ Reindex all memory files.
350
+
351
+ Scans .claude/memory/ and indexes any files not in ChromaDB.
352
+ Returns count of files indexed.
353
+ """
354
+ if not self.memory_dir.exists():
355
+ return 0
356
+
357
+ count = 0
358
+ for md_file in self.memory_dir.rglob("*.md"):
359
+ try:
360
+ memory = Memory.from_file(md_file)
361
+ self.db.upsert(
362
+ ids=[memory.id],
363
+ documents=[memory.content],
364
+ metadatas=[memory.to_metadata()],
365
+ )
366
+ count += 1
367
+ except Exception as e:
368
+ print(f"Warning: Could not index {md_file}: {e}")
369
+
370
+ return count
371
+
372
+ def _cleanup_empty_dirs(self, dir_path: Path) -> None:
373
+ """Remove empty directories up to memory_dir."""
374
+ while dir_path != self.memory_dir and dir_path.exists():
375
+ if not any(dir_path.iterdir()):
376
+ dir_path.rmdir()
377
+ dir_path = dir_path.parent
378
+ else:
379
+ break