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.
- ragtime_cli-0.1.0.dist-info/METADATA +220 -0
- ragtime_cli-0.1.0.dist-info/RECORD +21 -0
- ragtime_cli-0.1.0.dist-info/WHEEL +5 -0
- ragtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- ragtime_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ragtime_cli-0.1.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/cli.py +773 -0
- src/commands/audit.md +151 -0
- src/commands/handoff.md +176 -0
- src/commands/pr-graduate.md +187 -0
- src/commands/recall.md +175 -0
- src/commands/remember.md +168 -0
- src/commands/save.md +10 -0
- src/commands/start.md +206 -0
- src/config.py +101 -0
- src/db.py +167 -0
- src/indexers/__init__.py +0 -0
- src/indexers/docs.py +129 -0
- src/mcp_server.py +590 -0
- src/memory.py +379 -0
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
|