ragtime-cli 0.2.11__tar.gz → 0.2.13__tar.gz

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 (30) hide show
  1. {ragtime_cli-0.2.11/ragtime_cli.egg-info → ragtime_cli-0.2.13}/PKG-INFO +1 -1
  2. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/pyproject.toml +1 -1
  3. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13/ragtime_cli.egg-info}/PKG-INFO +1 -1
  4. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/cli.py +66 -0
  5. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/mcp_server.py +1 -1
  6. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/memory.py +84 -15
  7. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/LICENSE +0 -0
  8. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/README.md +0 -0
  9. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/ragtime_cli.egg-info/SOURCES.txt +0 -0
  10. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/ragtime_cli.egg-info/dependency_links.txt +0 -0
  11. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/ragtime_cli.egg-info/entry_points.txt +0 -0
  12. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/ragtime_cli.egg-info/requires.txt +0 -0
  13. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/ragtime_cli.egg-info/top_level.txt +0 -0
  14. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/setup.cfg +0 -0
  15. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/__init__.py +0 -0
  16. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/audit.md +0 -0
  17. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/create-pr.md +0 -0
  18. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/generate-docs.md +0 -0
  19. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/handoff.md +0 -0
  20. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/import-docs.md +0 -0
  21. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/pr-graduate.md +0 -0
  22. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/recall.md +0 -0
  23. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/remember.md +0 -0
  24. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/save.md +0 -0
  25. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/commands/start.md +0 -0
  26. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/config.py +0 -0
  27. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/db.py +0 -0
  28. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/indexers/__init__.py +0 -0
  29. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/indexers/code.py +0 -0
  30. {ragtime_cli-0.2.11 → ragtime_cli-0.2.13}/src/indexers/docs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge
5
5
  Author-email: Bret Martineau <bretwardjames@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragtime-cli"
3
- version = "0.2.11"
3
+ version = "0.2.13"
4
4
  description = "Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge
5
5
  Author-email: Bret Martineau <bretwardjames@gmail.com>
6
6
  License-Expression: MIT
@@ -734,6 +734,72 @@ def reindex(path: Path):
734
734
  click.echo(f"✓ Reindexed {count} memory files")
735
735
 
736
736
 
737
+ @main.command()
738
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
739
+ @click.option("--dry-run", is_flag=True, help="Show what would be removed")
740
+ def dedupe(path: Path, dry_run: bool):
741
+ """Clean up index: remove duplicates and orphaned entries.
742
+
743
+ - Removes duplicate entries (keeps one per file path)
744
+ - Removes orphaned entries (files that no longer exist on disk)
745
+ """
746
+ path = Path(path).resolve()
747
+ db = get_db(path)
748
+ memory_dir = path / ".ragtime"
749
+
750
+ # Get all entries with their file paths
751
+ results = db.collection.get(include=["metadatas"])
752
+
753
+ # Group by file path and track orphans
754
+ by_file: dict[str, list[str]] = {}
755
+ orphans: list[str] = []
756
+
757
+ for i, mem_id in enumerate(results["ids"]):
758
+ file_path = results["metadatas"][i].get("file", "")
759
+ entry_type = results["metadatas"][i].get("type", "")
760
+
761
+ # Skip docs/code entries - only clean up memory entries
762
+ if entry_type in ("docs", "code"):
763
+ continue
764
+
765
+ if not file_path:
766
+ orphans.append(mem_id)
767
+ continue
768
+
769
+ # Check if file exists on disk
770
+ full_path = memory_dir / file_path
771
+ if not full_path.exists():
772
+ orphans.append(mem_id)
773
+ if dry_run:
774
+ click.echo(f" Orphan: {file_path} (file missing)")
775
+ continue
776
+
777
+ if file_path not in by_file:
778
+ by_file[file_path] = []
779
+ by_file[file_path].append(mem_id)
780
+
781
+ # Find duplicates (keep first, remove rest)
782
+ duplicates: list[str] = []
783
+ for file_path, ids in by_file.items():
784
+ if len(ids) > 1:
785
+ duplicates.extend(ids[1:])
786
+ if dry_run:
787
+ click.echo(f" Duplicate: {file_path} ({len(ids)} copies, removing {len(ids) - 1})")
788
+
789
+ to_remove = orphans + duplicates
790
+
791
+ if not to_remove:
792
+ click.echo("✓ Index is clean (no duplicates or orphans)")
793
+ return
794
+
795
+ if dry_run:
796
+ click.echo(f"\nWould remove {len(orphans)} orphans + {len(duplicates)} duplicates = {len(to_remove)} entries")
797
+ click.echo("Run without --dry-run to remove them")
798
+ else:
799
+ db.delete(to_remove)
800
+ click.echo(f"✓ Removed {len(orphans)} orphans + {len(duplicates)} duplicates = {len(to_remove)} entries")
801
+
802
+
737
803
  @main.command("new-branch")
738
804
  @click.argument("issue", type=int)
739
805
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
@@ -493,7 +493,7 @@ class RagtimeMCPServer:
493
493
  "protocolVersion": "2024-11-05",
494
494
  "serverInfo": {
495
495
  "name": "ragtime",
496
- "version": "0.2.11",
496
+ "version": "0.2.13",
497
497
  },
498
498
  "capabilities": {
499
499
  "tools": {},
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
10
10
  from datetime import date
11
11
  from typing import Optional
12
12
  import uuid
13
+ import hashlib
13
14
  import re
14
15
  import yaml
15
16
 
@@ -109,42 +110,102 @@ class Memory:
109
110
  slug = re.sub(r'[-\s]+', '-', slug).strip('-')
110
111
  return slug[:40] # Limit length
111
112
 
113
+ @classmethod
114
+ def _infer_metadata_from_path(cls, relative_path: str) -> dict:
115
+ """
116
+ Infer namespace, component, and type from folder structure.
117
+
118
+ Supports:
119
+ app/{component}/*.md → namespace=app, component={component}
120
+ app/*.md → namespace=app
121
+ team/*.md → namespace=team
122
+ users/{username}/*.md → namespace=user-{username}
123
+ branches/{branch}/*.md → namespace=branch-{branch}
124
+ """
125
+ parts = relative_path.replace("\\", "/").split("/")
126
+ metadata = {}
127
+
128
+ if len(parts) >= 1:
129
+ first = parts[0]
130
+ if first == "app":
131
+ metadata["namespace"] = "app"
132
+ if len(parts) >= 3: # app/{component}/file.md
133
+ metadata["component"] = parts[1]
134
+ elif first == "team":
135
+ metadata["namespace"] = "team"
136
+ elif first == "users" and len(parts) >= 2:
137
+ metadata["namespace"] = f"user-{parts[1]}"
138
+ elif first == "branches" and len(parts) >= 2:
139
+ metadata["namespace"] = f"branch-{parts[1]}"
140
+
141
+ return metadata
142
+
112
143
  @classmethod
113
144
  def from_file(cls, path: Path, relative_to: Optional[Path] = None) -> "Memory":
114
145
  """
115
146
  Parse a memory from a markdown file with YAML frontmatter.
116
147
 
148
+ If no frontmatter exists, infers metadata from folder structure.
149
+
117
150
  Args:
118
151
  path: Full path to the markdown file
119
152
  relative_to: Base directory to compute relative path from (for indexing)
120
153
  """
121
154
  text = path.read_text()
122
155
 
156
+ # Compute relative path for inference and indexing
157
+ file_path = None
158
+ if relative_to:
159
+ try:
160
+ file_path = str(path.relative_to(relative_to))
161
+ except ValueError:
162
+ pass
163
+
164
+ # Handle files without frontmatter - infer from path
123
165
  if not text.startswith("---"):
124
- raise ValueError(f"No YAML frontmatter found in {path}")
166
+ inferred = cls._infer_metadata_from_path(file_path or str(path))
167
+ # Generate stable ID from path
168
+ memory_id = hashlib.sha256((file_path or str(path)).encode()).hexdigest()[:8]
169
+
170
+ return cls(
171
+ id=memory_id,
172
+ content=text.strip(),
173
+ namespace=inferred.get("namespace", "app"),
174
+ type=inferred.get("type", "note"),
175
+ component=inferred.get("component"),
176
+ source="file",
177
+ _file_path=file_path,
178
+ )
125
179
 
126
180
  # Split frontmatter and content
127
181
  parts = text.split("---", 2)
128
182
  if len(parts) < 3:
129
183
  raise ValueError(f"Invalid frontmatter format in {path}")
130
184
 
131
- frontmatter = yaml.safe_load(parts[1])
185
+ frontmatter = yaml.safe_load(parts[1]) or {}
132
186
  content = parts[2].strip()
133
187
 
134
- # Compute relative file path for indexing
135
- file_path = None
136
- if relative_to:
137
- try:
138
- file_path = str(path.relative_to(relative_to))
139
- except ValueError:
140
- pass # path not relative to base, will regenerate
188
+ # Infer missing metadata from folder structure
189
+ inferred = cls._infer_metadata_from_path(file_path or str(path))
190
+
191
+ # Use frontmatter ID if present, otherwise derive stable ID from file path
192
+ # This ensures reindex is idempotent - same file always gets same ID
193
+ if "id" in frontmatter:
194
+ memory_id = frontmatter["id"]
195
+ elif file_path:
196
+ # Stable hash of relative path
197
+ memory_id = hashlib.sha256(file_path.encode()).hexdigest()[:8]
198
+ else:
199
+ # Fallback: hash of absolute path
200
+ memory_id = hashlib.sha256(str(path).encode()).hexdigest()[:8]
141
201
 
142
202
  return cls(
143
- id=frontmatter.get("id", str(uuid.uuid4())[:8]),
203
+ id=memory_id,
144
204
  content=content,
145
- namespace=frontmatter.get("namespace", "app"),
146
- type=frontmatter.get("type", "unknown"),
147
- component=frontmatter.get("component"),
205
+ # Use frontmatter if present, fall back to inferred, then defaults
206
+ namespace=frontmatter.get("namespace") or inferred.get("namespace", "app"),
207
+ type=frontmatter.get("type") or inferred.get("type", "note"),
208
+ component=frontmatter.get("component") or inferred.get("component"),
148
209
  confidence=frontmatter.get("confidence", "medium"),
149
210
  confidence_reason=frontmatter.get("confidence_reason"),
150
211
  source=frontmatter.get("source", "file"),
@@ -411,7 +472,9 @@ class MemoryStore:
411
472
  """
412
473
  Reindex all memory files.
413
474
 
414
- Scans .ragtime/ and indexes any files not in ChromaDB.
475
+ Scans .ragtime/ and indexes files. Removes old entries for each file
476
+ before upserting to prevent duplicates from ID changes.
477
+
415
478
  Returns count of files indexed.
416
479
  """
417
480
  if not self.memory_dir.exists():
@@ -420,7 +483,13 @@ class MemoryStore:
420
483
  count = 0
421
484
  for md_file in self.memory_dir.rglob("*.md"):
422
485
  try:
423
- # Pass memory_dir so the actual file path is stored, not regenerated
486
+ # Compute relative path for this file
487
+ rel_path = str(md_file.relative_to(self.memory_dir))
488
+
489
+ # Delete any existing entries for this file path (handles ID changes)
490
+ self.db.delete_by_file([rel_path])
491
+
492
+ # Parse and index with stable ID
424
493
  memory = Memory.from_file(md_file, relative_to=self.memory_dir)
425
494
  self.db.upsert(
426
495
  ids=[memory.id],
File without changes
File without changes
File without changes
File without changes
File without changes