ragtime-cli 0.2.6__tar.gz → 0.2.7__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.6/ragtime_cli.egg-info → ragtime_cli-0.2.7}/PKG-INFO +1 -1
  2. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/pyproject.toml +1 -1
  3. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7/ragtime_cli.egg-info}/PKG-INFO +1 -1
  4. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/cli.py +146 -55
  5. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/config.py +12 -0
  6. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/db.py +51 -0
  7. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/indexers/code.py +30 -13
  8. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/indexers/docs.py +6 -1
  9. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/LICENSE +0 -0
  10. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/README.md +0 -0
  11. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/ragtime_cli.egg-info/SOURCES.txt +0 -0
  12. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/ragtime_cli.egg-info/dependency_links.txt +0 -0
  13. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/ragtime_cli.egg-info/entry_points.txt +0 -0
  14. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/ragtime_cli.egg-info/requires.txt +0 -0
  15. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/ragtime_cli.egg-info/top_level.txt +0 -0
  16. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/setup.cfg +0 -0
  17. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/__init__.py +0 -0
  18. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/audit.md +0 -0
  19. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/create-pr.md +0 -0
  20. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/generate-docs.md +0 -0
  21. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/handoff.md +0 -0
  22. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/import-docs.md +0 -0
  23. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/pr-graduate.md +0 -0
  24. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/recall.md +0 -0
  25. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/remember.md +0 -0
  26. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/save.md +0 -0
  27. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/commands/start.md +0 -0
  28. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/indexers/__init__.py +0 -0
  29. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/mcp_server.py +0 -0
  30. {ragtime_cli-0.2.6 → ragtime_cli-0.2.7}/src/memory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
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.6"
3
+ version = "0.2.7"
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.6
3
+ Version: 0.2.7
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
@@ -169,7 +169,7 @@ def get_remote_branches_with_ragtime(path: Path) -> list[str]:
169
169
 
170
170
 
171
171
  @click.group()
172
- @click.version_option(version="0.2.6")
172
+ @click.version_option(version="0.2.7")
173
173
  def main():
174
174
  """Ragtime - semantic search over code and documentation."""
175
175
  pass
@@ -258,12 +258,73 @@ Add your team's conventions above. Each rule should be:
258
258
  click.echo(f" Install for enhanced workflow: npm install -g @bretwardjames/ghp-cli")
259
259
 
260
260
 
261
+ # Batch size for ChromaDB upserts (embedding computation happens here)
262
+ INDEX_BATCH_SIZE = 100
263
+
264
+
265
+ def _upsert_entries(db, entries, entry_type: str = "docs", label: str = " Embedding"):
266
+ """Upsert entries to ChromaDB in batches with progress bar."""
267
+ if not entries:
268
+ return
269
+
270
+ # Process in batches with progress feedback
271
+ batches = [entries[i:i + INDEX_BATCH_SIZE] for i in range(0, len(entries), INDEX_BATCH_SIZE)]
272
+
273
+ with click.progressbar(
274
+ batches,
275
+ label=label,
276
+ show_percent=True,
277
+ show_pos=True,
278
+ item_show_func=lambda b: f"{len(b)} items" if b else "",
279
+ ) as batch_iter:
280
+ for batch in batch_iter:
281
+ if entry_type == "code":
282
+ ids = [f"{e.file_path}:{e.line_number}:{e.symbol_name}" for e in batch]
283
+ else:
284
+ ids = [e.file_path for e in batch]
285
+
286
+ documents = [e.content for e in batch]
287
+ metadatas = [e.to_metadata() for e in batch]
288
+ db.upsert(ids=ids, documents=documents, metadatas=metadatas)
289
+
290
+
291
+ def _get_files_to_process(
292
+ all_files: list[Path],
293
+ indexed_files: dict[str, float],
294
+ ) -> tuple[list[Path], list[str]]:
295
+ """
296
+ Compare files on disk with indexed files to determine what needs processing.
297
+
298
+ Returns:
299
+ (files_to_index, files_to_delete)
300
+ """
301
+ disk_files = {str(f): os.path.getmtime(f) for f in all_files}
302
+
303
+ to_index = []
304
+ for file_path in all_files:
305
+ path_str = str(file_path)
306
+ disk_mtime = disk_files[path_str]
307
+ indexed_mtime = indexed_files.get(path_str, 0.0)
308
+
309
+ # Index if new or modified (with 1-second tolerance for filesystem precision)
310
+ if disk_mtime > indexed_mtime + 1.0:
311
+ to_index.append(file_path)
312
+
313
+ # Find deleted files (in index but not on disk)
314
+ to_delete = [f for f in indexed_files.keys() if f not in disk_files]
315
+
316
+ return to_index, to_delete
317
+
318
+
261
319
  @main.command()
262
320
  @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
263
321
  @click.option("--type", "index_type", type=click.Choice(["all", "docs", "code"]), default="all")
264
322
  @click.option("--clear", is_flag=True, help="Clear existing index before indexing")
265
323
  def index(path: Path, index_type: str, clear: bool):
266
- """Index a project directory."""
324
+ """Index a project directory.
325
+
326
+ Without --clear, performs incremental indexing (only changed files).
327
+ """
267
328
  path = path.resolve()
268
329
  db = get_db(path)
269
330
  config = RagtimeConfig.load(path)
@@ -276,7 +337,10 @@ def index(path: Path, index_type: str, clear: bool):
276
337
  db.clear(type_filter=index_type)
277
338
 
278
339
  if index_type in ("all", "docs"):
279
- # Discover all doc files first
340
+ # Get currently indexed docs
341
+ indexed_docs = {} if clear else db.get_indexed_files("docs")
342
+
343
+ # Discover all doc files
280
344
  all_doc_files = []
281
345
  for docs_path in config.docs.paths:
282
346
  docs_root = path / docs_path
@@ -290,39 +354,55 @@ def index(path: Path, index_type: str, clear: bool):
290
354
  )
291
355
  all_doc_files.extend(files)
292
356
 
293
- if all_doc_files:
357
+ if all_doc_files or indexed_docs:
358
+ # Determine what needs processing
359
+ to_index, to_delete = _get_files_to_process(all_doc_files, indexed_docs)
360
+
294
361
  click.echo(f"Found {len(all_doc_files)} doc files")
295
- total_entries = []
296
- with click.progressbar(
297
- all_doc_files,
298
- label=" Processing",
299
- show_percent=True,
300
- show_pos=True,
301
- item_show_func=lambda f: f.name[:30] if f else "",
302
- ) as files:
303
- for file_path in files:
304
- entry = index_doc_file(file_path)
305
- if entry:
306
- total_entries.append(entry)
307
-
308
- if total_entries:
309
- ids = [e.file_path for e in total_entries]
310
- documents = [e.content for e in total_entries]
311
- metadatas = [e.to_metadata() for e in total_entries]
312
- db.upsert(ids=ids, documents=documents, metadatas=metadatas)
313
- click.echo(f" Indexed {len(total_entries)} documents")
314
- else:
315
- click.echo(" No valid documents found")
362
+ if not clear:
363
+ unchanged = len(all_doc_files) - len(to_index)
364
+ if unchanged > 0:
365
+ click.echo(f" {unchanged} unchanged, {len(to_index)} to index")
366
+ if to_delete:
367
+ click.echo(f" {len(to_delete)} to remove (deleted from disk)")
368
+
369
+ # Delete removed files
370
+ if to_delete:
371
+ db.delete_by_file(to_delete, "docs")
372
+
373
+ # Index new/changed files
374
+ if to_index:
375
+ entries = []
376
+ with click.progressbar(
377
+ to_index,
378
+ label=" Parsing",
379
+ show_percent=True,
380
+ show_pos=True,
381
+ item_show_func=lambda f: f.name[:30] if f else "",
382
+ ) as files:
383
+ for file_path in files:
384
+ entry = index_doc_file(file_path)
385
+ if entry:
386
+ entries.append(entry)
387
+
388
+ if entries:
389
+ _upsert_entries(db, entries, "docs")
390
+ click.echo(f" Indexed {len(entries)} documents")
391
+ elif not to_delete:
392
+ click.echo(" All docs up to date")
316
393
  else:
317
394
  click.echo(" No documents found")
318
395
 
319
396
  if index_type in ("all", "code"):
397
+ # Get currently indexed code files
398
+ indexed_code = {} if clear else db.get_indexed_files("code")
399
+
320
400
  # Build exclusion list for code
321
401
  code_exclude = list(config.code.exclude)
322
402
  for docs_path in config.docs.paths:
323
403
  code_exclude.append(f"**/{docs_path}/**")
324
404
 
325
- # Discover all code files first
405
+ # Discover all code files
326
406
  all_code_files = []
327
407
  for code_path_str in config.code.paths:
328
408
  code_root = path / code_path_str
@@ -336,36 +416,47 @@ def index(path: Path, index_type: str, clear: bool):
336
416
  )
337
417
  all_code_files.extend(files)
338
418
 
339
- if all_code_files:
419
+ if all_code_files or indexed_code:
420
+ # Determine what needs processing
421
+ to_index, to_delete = _get_files_to_process(all_code_files, indexed_code)
422
+
340
423
  click.echo(f"Found {len(all_code_files)} code files")
341
- total_entries = []
342
- with click.progressbar(
343
- all_code_files,
344
- label=" Processing",
345
- show_percent=True,
346
- show_pos=True,
347
- item_show_func=lambda f: f.name[:30] if f else "",
348
- ) as files:
349
- for file_path in files:
350
- file_entries = index_code_file(file_path)
351
- total_entries.extend(file_entries)
352
-
353
- if total_entries:
354
- # Create unique IDs: file:line:symbol
355
- ids = [f"{e.file_path}:{e.line_number}:{e.symbol_name}" for e in total_entries]
356
- documents = [e.content for e in total_entries]
357
- metadatas = [e.to_metadata() for e in total_entries]
358
- db.upsert(ids=ids, documents=documents, metadatas=metadatas)
359
- click.echo(f" Indexed {len(total_entries)} code symbols")
360
-
361
- # Show breakdown by type
424
+ if not clear:
425
+ unchanged = len(all_code_files) - len(to_index)
426
+ if unchanged > 0:
427
+ click.echo(f" {unchanged} unchanged, {len(to_index)} to index")
428
+ if to_delete:
429
+ click.echo(f" {len(to_delete)} to remove (deleted from disk)")
430
+
431
+ # Delete removed files
432
+ if to_delete:
433
+ db.delete_by_file(to_delete, "code")
434
+
435
+ # Index new/changed files
436
+ if to_index:
437
+ entries = []
362
438
  by_type = {}
363
- for e in total_entries:
364
- by_type[e.symbol_type] = by_type.get(e.symbol_type, 0) + 1
365
- breakdown = ", ".join(f"{count} {typ}s" for typ, count in sorted(by_type.items()))
366
- click.echo(f" ({breakdown})")
367
- else:
368
- click.echo(" No code symbols found")
439
+ with click.progressbar(
440
+ to_index,
441
+ label=" Parsing",
442
+ show_percent=True,
443
+ show_pos=True,
444
+ item_show_func=lambda f: f.name[:30] if f else "",
445
+ ) as files:
446
+ for file_path in files:
447
+ file_entries = index_code_file(file_path)
448
+ for entry in file_entries:
449
+ entries.append(entry)
450
+ by_type[entry.symbol_type] = by_type.get(entry.symbol_type, 0) + 1
451
+
452
+ if entries:
453
+ click.echo(f" Found {len(entries)} symbols")
454
+ _upsert_entries(db, entries, "code")
455
+ click.echo(f" Indexed {len(entries)} code symbols")
456
+ breakdown = ", ".join(f"{count} {typ}s" for typ, count in sorted(by_type.items()))
457
+ click.echo(f" ({breakdown})")
458
+ elif not to_delete:
459
+ click.echo(" All code up to date")
369
460
  else:
370
461
  click.echo(" No code files found")
371
462
 
@@ -2026,7 +2117,7 @@ def update(check: bool):
2026
2117
  from urllib.request import urlopen
2027
2118
  from urllib.error import URLError
2028
2119
 
2029
- current = "0.2.6"
2120
+ current = "0.2.7"
2030
2121
 
2031
2122
  click.echo(f"Current version: {current}")
2032
2123
  click.echo("Checking PyPI for updates...")
@@ -33,6 +33,18 @@ class CodeConfig:
33
33
  "**/build/**",
34
34
  "**/dist/**",
35
35
  "**/.dart_tool/**",
36
+ # Generated code (Prisma, GraphQL, OpenAPI, etc.)
37
+ "**/generated/**",
38
+ "**/*.generated.*",
39
+ "**/*.g.dart",
40
+ # TypeScript declaration files (often auto-generated)
41
+ "**/*.d.ts",
42
+ # Test files (usually not needed in search)
43
+ "**/__tests__/**",
44
+ "**/*.test.*",
45
+ "**/*.spec.*",
46
+ # Python init files (rarely have searchable content)
47
+ "**/__init__.py",
36
48
  ])
37
49
 
38
50
 
@@ -165,3 +165,54 @@ class RagtimeDB:
165
165
  "docs": docs_count,
166
166
  "code": code_count,
167
167
  }
168
+
169
+ def get_indexed_files(self, type_filter: str | None = None) -> dict[str, float]:
170
+ """
171
+ Get all indexed files and their modification times.
172
+
173
+ Args:
174
+ type_filter: "code" or "docs" (None = both)
175
+
176
+ Returns:
177
+ Dict mapping file paths to their indexed mtime
178
+ """
179
+ where = {"type": type_filter} if type_filter else None
180
+ results = self.collection.get(where=where, include=["metadatas"])
181
+
182
+ files: dict[str, float] = {}
183
+ for meta in results["metadatas"]:
184
+ file_path = meta.get("file", "")
185
+ mtime = meta.get("mtime", 0.0)
186
+ # For code files, multiple entries per file - keep max mtime
187
+ if file_path not in files or mtime > files[file_path]:
188
+ files[file_path] = mtime
189
+
190
+ return files
191
+
192
+ def delete_by_file(self, file_paths: list[str], type_filter: str | None = None) -> int:
193
+ """
194
+ Delete all entries for the given file paths.
195
+
196
+ Args:
197
+ file_paths: List of file paths to remove
198
+ type_filter: "code" or "docs" (None = both)
199
+
200
+ Returns:
201
+ Number of entries deleted
202
+ """
203
+ if not file_paths:
204
+ return 0
205
+
206
+ # Build where clause
207
+ where = {"file": {"$in": file_paths}}
208
+ if type_filter:
209
+ where = {"$and": [{"file": {"$in": file_paths}}, {"type": type_filter}]}
210
+
211
+ # Get IDs to delete
212
+ results = self.collection.get(where=where)
213
+ ids = results["ids"]
214
+
215
+ if ids:
216
+ self.collection.delete(ids=ids)
217
+
218
+ return len(ids)
@@ -6,6 +6,7 @@ This allows searching for specific code constructs like "useAsyncState" or "JWTM
6
6
  """
7
7
 
8
8
  import ast
9
+ import os
9
10
  import re
10
11
  from fnmatch import fnmatch
11
12
  from pathlib import Path
@@ -32,6 +33,7 @@ class CodeEntry:
32
33
  symbol_type: str # function, class, interface, component, etc.
33
34
  line_number: int # Line where symbol starts
34
35
  docstring: str | None = None # Extracted docstring/JSDoc
36
+ mtime: float | None = None # File modification time for incremental indexing
35
37
 
36
38
  def to_metadata(self) -> dict:
37
39
  """Convert to ChromaDB metadata dict."""
@@ -42,6 +44,7 @@ class CodeEntry:
42
44
  "symbol_name": self.symbol_name,
43
45
  "symbol_type": self.symbol_type,
44
46
  "line": self.line_number,
47
+ "mtime": self.mtime or 0.0,
45
48
  }
46
49
 
47
50
 
@@ -92,14 +95,21 @@ def discover_code_files(
92
95
  rel_path = str(path)
93
96
 
94
97
  for ex in exclude:
95
- # Handle ** patterns by checking if pattern appears in path
98
+ # Handle ** patterns
96
99
  if "**" in ex:
97
- # Convert glob to a simpler check: **/node_modules/** means
98
- # any path containing /node_modules/ segment
99
- core_pattern = ex.replace("**", "").strip("/")
100
- if core_pattern and f"/{core_pattern}/" in f"/{rel_path}/":
101
- skip = True
102
- break
100
+ if ex.endswith("/**"):
101
+ # Directory pattern: **/node_modules/** or **/generated/**
102
+ # Extract the directory name to match as path segment
103
+ dir_pattern = ex.replace("**/", "").replace("/**", "")
104
+ if f"/{dir_pattern}/" in f"/{rel_path}/":
105
+ skip = True
106
+ break
107
+ else:
108
+ # File pattern: **/*.d.ts, **/*.test.*, **/*.generated.*
109
+ file_pattern = ex.replace("**/", "")
110
+ if fnmatch(path.name, file_pattern):
111
+ skip = True
112
+ break
103
113
  elif fnmatch(rel_path, ex) or fnmatch(path.name, ex):
104
114
  skip = True
105
115
  break
@@ -432,7 +442,8 @@ def index_file(file_path: Path) -> list[CodeEntry]:
432
442
  """
433
443
  try:
434
444
  content = file_path.read_text(encoding='utf-8')
435
- except (IOError, UnicodeDecodeError):
445
+ mtime = os.path.getmtime(file_path)
446
+ except (IOError, UnicodeDecodeError, OSError):
436
447
  return []
437
448
 
438
449
  # Skip empty files
@@ -442,15 +453,21 @@ def index_file(file_path: Path) -> list[CodeEntry]:
442
453
  suffix = file_path.suffix.lower()
443
454
 
444
455
  if suffix == ".py":
445
- return index_python_file(file_path, content)
456
+ entries = index_python_file(file_path, content)
446
457
  elif suffix in [".ts", ".tsx", ".js", ".jsx"]:
447
- return index_typescript_file(file_path, content)
458
+ entries = index_typescript_file(file_path, content)
448
459
  elif suffix == ".vue":
449
- return index_vue_file(file_path, content)
460
+ entries = index_vue_file(file_path, content)
450
461
  elif suffix == ".dart":
451
- return index_dart_file(file_path, content)
462
+ entries = index_dart_file(file_path, content)
463
+ else:
464
+ return []
452
465
 
453
- return []
466
+ # Set mtime on all entries from this file
467
+ for entry in entries:
468
+ entry.mtime = mtime
469
+
470
+ return entries
454
471
 
455
472
 
456
473
  def index_directory(
@@ -4,6 +4,7 @@ Docs indexer - parses markdown files with YAML frontmatter.
4
4
  Designed for .claude/memory/ style files but works with any markdown.
5
5
  """
6
6
 
7
+ import os
7
8
  import re
8
9
  from pathlib import Path
9
10
  from dataclasses import dataclass
@@ -19,6 +20,7 @@ class DocEntry:
19
20
  category: str | None = None
20
21
  component: str | None = None
21
22
  title: str | None = None
23
+ mtime: float | None = None # File modification time for incremental indexing
22
24
 
23
25
  def to_metadata(self) -> dict:
24
26
  """Convert to ChromaDB metadata dict."""
@@ -29,6 +31,7 @@ class DocEntry:
29
31
  "category": self.category or "",
30
32
  "component": self.component or "",
31
33
  "title": self.title or Path(self.file_path).stem,
34
+ "mtime": self.mtime or 0.0,
32
35
  }
33
36
 
34
37
 
@@ -61,7 +64,8 @@ def index_file(file_path: Path) -> DocEntry | None:
61
64
  """
62
65
  try:
63
66
  content = file_path.read_text(encoding='utf-8')
64
- except (IOError, UnicodeDecodeError):
67
+ mtime = os.path.getmtime(file_path)
68
+ except (IOError, UnicodeDecodeError, OSError):
65
69
  return None
66
70
 
67
71
  metadata, body = parse_frontmatter(content)
@@ -77,6 +81,7 @@ def index_file(file_path: Path) -> DocEntry | None:
77
81
  category=metadata.get("category"),
78
82
  component=metadata.get("component"),
79
83
  title=metadata.get("title"),
84
+ mtime=mtime,
80
85
  )
81
86
 
82
87
 
File without changes
File without changes
File without changes
File without changes
File without changes