code-context-control 2.53.0__py3-none-any.whl → 2.54.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.
cli/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.53.0"
95
+ __version__ = "2.54.0"
96
96
 
97
97
 
98
98
  def _command_deps() -> CommandDeps:
@@ -165,6 +165,7 @@ def _build_init_config(project_path: str) -> dict:
165
165
  "project_path": project_path,
166
166
  "version": __version__,
167
167
  "index_auto_update": True,
168
+ "index_max_files": 2000,
168
169
  "compression_mode": "smart",
169
170
  "mcp": {"mode": "direct"},
170
171
  "hybrid": deepcopy(HYBRID_DEFAULTS),
@@ -890,7 +891,7 @@ def _parse_cli_ide_arg(value: str) -> str:
890
891
  return normalized
891
892
 
892
893
 
893
- def _do_init(project_path: str, ide_name: str = None):
894
+ def _do_init(project_path: str, ide_name: str = None, no_embed: bool = False):
894
895
  """Run the core init steps (shared by new install and re-init after clear/reset)."""
895
896
  config = _build_init_config(project_path)
896
897
  save_config(config, project_path)
@@ -898,10 +899,27 @@ def _do_init(project_path: str, ide_name: str = None):
898
899
  for subdir in _C3_INIT_SUBDIRS:
899
900
  (Path(project_path) / CONFIG_DIR / subdir).mkdir(parents=True, exist_ok=True)
900
901
 
902
+ import time as _t
903
+
904
+ from cli.progress import ProgressLine
905
+
901
906
  print("Building code index...")
907
+ _prog = ProgressLine()
908
+ _t0 = _t.perf_counter()
902
909
  indexer = CodeIndex(project_path)
903
- result = indexer.build_index()
904
- print(f" Indexed {result['files_indexed']} files, {result['chunks_created']} chunks")
910
+ result = indexer.build_index(
911
+ on_progress=lambda entries, files, chunks: _prog.update(
912
+ f" scanning: {entries:,} entries | {files:,} files | {chunks:,} chunks"))
913
+ _prog.done()
914
+ entries = result.get("entries_scanned", 0)
915
+ print(f" Indexed {result['files_indexed']} files, {result['chunks_created']} chunks "
916
+ f"in {_t.perf_counter() - _t0:.1f}s ({entries:,} entries scanned)")
917
+ capped = result.get("files_capped", 0)
918
+ if capped:
919
+ total = result["files_indexed"] + capped
920
+ print(f" [!] Indexed {result['files_indexed']} of {total} candidate files "
921
+ f"(cap: index_max_files={result.get('max_files')}).")
922
+ print(" Raise index_max_files in .c3/config.json for full coverage.")
905
923
 
906
924
  # Build embedding index if Ollama is available (non-blocking on failure)
907
925
  try:
@@ -912,13 +930,24 @@ def _do_init(project_path: str, ide_name: str = None):
912
930
  ollama = OllamaClient(ollama_url)
913
931
  embed_model = config.get("embed_model", "nomic-embed-text")
914
932
  ei = EmbeddingIndex(project_path, ollama, embed_model=embed_model)
915
- if ei.ready:
933
+ if no_embed:
934
+ print(" Embedding index skipped (--no-embed)")
935
+ elif ei.probe()["ready"]:
936
+ # probe() initializes the lazy backends; checking .ready on a
937
+ # fresh instance is always False and silently skipped the build.
916
938
  print("Building embedding index...")
917
- ei_result = ei.build(indexer)
939
+ _t0 = _t.perf_counter()
940
+ _eprog = ProgressLine()
941
+ ei_result = ei.build(
942
+ indexer,
943
+ on_progress=lambda done, total, chunks: _eprog.update(
944
+ f" embedding: file {done}/{total} | {chunks:,} chunks"))
945
+ _eprog.done()
918
946
  print(f" Embedded {ei_result.get('chunks_embedded', 0)} chunks "
919
- f"({ei_result.get('files_processed', 0)} files)")
947
+ f"({ei_result.get('files_processed', 0)} files) "
948
+ f"in {_t.perf_counter() - _t0:.1f}s")
920
949
  else:
921
- print(" Embedding index skipped (Ollama not available or model not pulled)")
950
+ print(f" Embedding index skipped ({ei.unavailable_reason()})")
922
951
  except Exception:
923
952
  _log.debug("Embedding index build failed", exc_info=True)
924
953
 
@@ -926,15 +955,22 @@ def _do_init(project_path: str, ide_name: str = None):
926
955
  try:
927
956
  from services.doc_index import DocIndex
928
957
  print("Building doc index...")
958
+ _t0 = _t.perf_counter()
959
+ _dprog = ProgressLine()
929
960
  di = DocIndex(project_path)
930
- di_result = di.build()
931
- print(f" Indexed {di_result['docs_indexed']} docs, {di_result['chunks_created']} chunks")
961
+ di_result = di.build(
962
+ on_progress=lambda done, total: _dprog.update(
963
+ f" docs: {done}/{total}"))
964
+ _dprog.done()
965
+ print(f" Indexed {di_result['docs_indexed']} docs, "
966
+ f"{di_result['chunks_created']} chunks in {_t.perf_counter() - _t0:.1f}s")
932
967
  except Exception:
933
968
  _log.debug("Doc index build failed", exc_info=True)
934
969
 
935
970
  print("Building compression dictionary...")
936
971
  protocol = CompressionProtocol(project_path)
937
- new_terms = protocol.build_project_dictionary()
972
+ # Mine the in-memory index instead of re-reading the whole tree.
973
+ new_terms = protocol.build_project_dictionary(code_index=indexer)
938
974
  print(f" Added {len(new_terms)} project-specific terms")
939
975
 
940
976
  from core.ide import detect_ide, load_ide_config
@@ -966,11 +1002,12 @@ def cmd_init(args):
966
1002
  if requested_ide != "auto":
967
1003
  requested_ide = normalize_ide_name(requested_ide)
968
1004
  git_requested = getattr(args, "git", False)
1005
+ no_embed = bool(getattr(args, "no_embed", False))
969
1006
 
970
1007
  # ── Brand-new install ──────────────────────────────────────
971
1008
  if not c3_dir.exists() or not (c3_dir / "config.json").exists():
972
1009
  print_header(f"Initializing C3 for: {project_path}")
973
- _do_init(project_path, ide_name=requested_ide)
1010
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
974
1011
  try:
975
1012
  from services.project_manager import ProjectManager
976
1013
  ProjectManager().add_project(project_path)
@@ -1123,7 +1160,7 @@ def cmd_init(args):
1123
1160
  ti_manifest = c3_dir / "transcript_index" / "manifest.json"
1124
1161
  if ti_manifest.exists():
1125
1162
  ti_manifest.write_text("{}", encoding="utf-8")
1126
- _do_init(project_path, ide_name=requested_ide)
1163
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
1127
1164
  if git_requested:
1128
1165
  _init_local_git_repo(project_path)
1129
1166
  _run_install_mcp(project_path, requested_ide, mcp_mode=getattr(args, "mcp_mode", "direct"), banner="Updating MCP tools...")
@@ -1152,7 +1189,7 @@ def cmd_init(args):
1152
1189
  # ── Non-interactive (--force) ──────────────────────────────
1153
1190
  if getattr(args, "force", False):
1154
1191
  print("\n[--force] Applying update...")
1155
- _do_init(project_path, ide_name=requested_ide)
1192
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
1156
1193
  if git_requested:
1157
1194
  _init_local_git_repo(project_path)
1158
1195
  _run_install_mcp(project_path, requested_ide, mcp_mode=getattr(args, "mcp_mode", "direct"), banner="Updating MCP tools...")
@@ -1176,7 +1213,7 @@ def cmd_init(args):
1176
1213
 
1177
1214
  if selected.startswith("Update"):
1178
1215
  print()
1179
- _do_init(project_path, ide_name=requested_ide)
1216
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
1180
1217
  if git_requested:
1181
1218
  _init_local_git_repo(project_path)
1182
1219
  _prompt_install_mcp(project_path, requested_ide, default_mode=getattr(args, "mcp_mode", "direct"), banner="Updating MCP tools...")
@@ -1192,7 +1229,7 @@ def cmd_init(args):
1192
1229
  shutil.rmtree(target)
1193
1230
  print(f" Removed .c3/{subdir}/")
1194
1231
  print()
1195
- _do_init(project_path, ide_name=requested_ide)
1232
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
1196
1233
  if git_requested:
1197
1234
  _init_local_git_repo(project_path)
1198
1235
  _prompt_install_mcp(project_path, requested_ide, default_mode=getattr(args, "mcp_mode", "direct"), banner="Updating MCP tools...")
@@ -1209,7 +1246,7 @@ def cmd_init(args):
1209
1246
  shutil.rmtree(c3_dir)
1210
1247
  print(" Deleted .c3/")
1211
1248
  print()
1212
- _do_init(project_path, ide_name=requested_ide)
1249
+ _do_init(project_path, ide_name=requested_ide, no_embed=no_embed)
1213
1250
  if git_requested:
1214
1251
  _init_local_git_repo(project_path)
1215
1252
  _prompt_install_mcp(project_path, requested_ide, default_mode=getattr(args, "mcp_mode", "direct"), banner="Re-installing MCP tools...")
cli/commands/parser.py CHANGED
@@ -24,6 +24,7 @@ def build_parser(version: str, parse_cli_ide_arg):
24
24
  p_init.add_argument("--ide", default="auto", type=parse_cli_ide_arg, metavar="{auto,claude,vscode,cursor,codex,antigravity}", help="Target IDE for MCP config (default: auto-detect)")
25
25
  p_init.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="Default MCP mode if install is selected during init (default: direct)")
26
26
  p_init.add_argument("--git", action="store_true", help="Initialize a local Git repository during init/update")
27
+ p_init.add_argument("--no-embed", action="store_true", help="Skip building the semantic embedding index during init")
27
28
  p_init.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only, used with --force)")
28
29
  p_init.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
29
30
 
cli/hub_server.py CHANGED
@@ -1027,8 +1027,10 @@ def api_run_component():
1027
1027
  ollama = OllamaClient(ollama_url)
1028
1028
  embed_model = cfg.get("embed_model", "nomic-embed-text")
1029
1029
  ei = EmbeddingIndex(resolved, ollama, embed_model=embed_model)
1030
- if not ei.ready:
1031
- return jsonify({"success": True, "output": "Embedding index skipped (Ollama not available or model not pulled)."})
1030
+ # probe() initializes lazy backends; a fresh instance's .ready
1031
+ # is always False and would skip the build unconditionally.
1032
+ if not ei.probe()["ready"]:
1033
+ return jsonify({"success": True, "output": f"Embedding index skipped ({ei.unavailable_reason()})."})
1032
1034
  indexer = CodeIndex(resolved)
1033
1035
  if not indexer.chunks:
1034
1036
  indexer._load_index()
cli/progress.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Single-line TTY progress for long CLI phases (index/embedding builds).
3
+
4
+ Quiet when stdout is piped - CI logs and command substitutions see only
5
+ the final summary lines, never carriage-return spam.
6
+ """
7
+ import sys
8
+ import time
9
+
10
+
11
+ class ProgressLine:
12
+ """Rewrites one status line in place; time-throttled; TTY-only."""
13
+
14
+ def __init__(self, stream=None, min_interval: float = 0.1):
15
+ self.stream = stream if stream is not None else sys.stdout
16
+ self.min_interval = min_interval
17
+ self._last = 0.0
18
+ self._width = 0
19
+ try:
20
+ self._tty = bool(self.stream.isatty())
21
+ except Exception:
22
+ self._tty = False
23
+
24
+ def update(self, text: str) -> None:
25
+ if not self._tty:
26
+ return
27
+ now = time.monotonic()
28
+ if now - self._last < self.min_interval:
29
+ return
30
+ self._last = now
31
+ pad = ' ' * max(0, self._width - len(text))
32
+ try:
33
+ self.stream.write('\r' + text + pad)
34
+ self.stream.flush()
35
+ except Exception:
36
+ self._tty = False
37
+ return
38
+ self._width = len(text)
39
+
40
+ def done(self) -> None:
41
+ """Clear the in-place line so the following prints start clean."""
42
+ if not self._tty or not self._width:
43
+ return
44
+ try:
45
+ self.stream.write('\r' + ' ' * self._width + '\r')
46
+ self.stream.flush()
47
+ except Exception:
48
+ pass
49
+ self._width = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.53.0
3
+ Version: 2.54.0
4
4
  Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Copilot, Cursor, Antigravity). Retrieve less, read less, edit safer — and version the configs that shape your agent (CLAUDE.md, skills, hooks, MCP).
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=tJ4ZmXD5Y-JjFWb0uoR7ycYklYFUMnnjF2G7BO-wyBI,12600
3
- cli/c3.py,sha256=ziZSc1n6ksQL_asFvZ4ErsQbT29DMA-WYh324AwTOII,307360
3
+ cli/c3.py,sha256=jTV4sTDaPkooaJtlwaQOAKYXY4Q6C5RfyioKX35vfTM,309220
4
4
  cli/docs.html,sha256=bNymSz7LatnWHjxSXL92QSUtGvB3jBzNHbOV-bRpAOo,142507
5
5
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
6
6
  cli/hook_artifact.py,sha256=Se1CNBfoBFyvJQlRmYdNtdRXIkQp9zaF0O6ndRMo7ts,2198
@@ -18,17 +18,18 @@ cli/hook_read.py,sha256=ZWZldgqGSNz78COhlgETQ7yxdOW5vWIIHUet9gZKhxk,8176
18
18
  cli/hook_session_stats.py,sha256=hZiX0r1SLOstu1PXvygWae0cIqHCcC3mb0SbFL6nCic,2083
19
19
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
20
20
  cli/hub.html,sha256=Hl-XPZGT1mMiKrbX9c5OsEw6mXEumwIB3vp1WlWaplM,183966
21
- cli/hub_server.py,sha256=fZUw32ZNUrQgB9Jo82JSfPmDq02kFOcOwxgbtXFgLq0,105299
21
+ cli/hub_server.py,sha256=5y6jIs2qaz5HoBdLXBl2yPtqIZ2VM17rJ4bWUOfUcSs,105443
22
22
  cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
23
23
  cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
24
24
  cli/mcp_server.py,sha256=YgzI2U1jW-ax9HCCVUwXeikMHDMlS4Qq-RdhnQuTvt4,40898
25
+ cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
25
26
  cli/server.py,sha256=WOEPgptkJSVtS25IwrPdOMdbKOh79Sm6-vdQlTOk-sA,136012
26
27
  cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
27
28
  cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
28
29
  cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
29
30
  cli/commands/__init__.py,sha256=0Z8MABNzwSFJGT4Xv9R5AJVR8XxraTsuVTz5b0bShmo,38
30
31
  cli/commands/common.py,sha256=fHZWzkd4OLl3vPWTTgaI5vaqMi3Ma3XqAu1Ds_-51_4,11299
31
- cli/commands/parser.py,sha256=dutcYYE7qImgZ1ZouobTRe2TyUMHii_r56vyQA7UVOw,25489
32
+ cli/commands/parser.py,sha256=0ym7T3xjsQPhj2jm0_aUHCQGx6Whcp1m50gH5JDSFHw,25611
32
33
  cli/guide/bitbucket.html,sha256=9vN1VPLmvTLrkqDSrqKmuy7z3BHEmQusY1jkAG0FjnI,33477
33
34
  cli/guide/getting-started.html,sha256=5iS-_iAP1PboU9pFbMsfh8C9Du9usqOfIyawOc-X4Hg,19070
34
35
  cli/guide/index.html,sha256=mUDUBX590TTappvrKT7_eXi0P8G-3YvHjGk9pCcwWzQ,21350
@@ -94,7 +95,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
94
95
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
95
96
  cli/ui/components/sidebar.js,sha256=cAY_jwYB-o1X_wWn__VXlG4IegVObuE3NmVsuFWqxtg,7417
96
97
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
97
- code_context_control-2.53.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
98
+ code_context_control-2.54.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
98
99
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
99
100
  core/config.py,sha256=NUU1DuespWRotlJsv59CNnLiWTR4Bc7ysmD1UFA76RE,16429
100
101
  core/ide.py,sha256=V6VVMVsFdmmcsMyxikjQp7z9xa42CWiHKS-ya-MAcG4,6172
@@ -152,20 +153,20 @@ services/bitbucket_client.py,sha256=JByovvtVZ6F4NcU611KVuTgKlT1rIsX2A2VlBDfvRV8,
152
153
  services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
153
154
  services/circuit_breaker.py,sha256=ES6PpjL40gfDhtK88GeS6GFzsz5EOEEkZsB-DzX_tbw,3179
154
155
  services/claude_md.py,sha256=vI2at-7DEsnRXYcSaE88uNTxCSxzhy8M77AM20VlYRc,42643
155
- services/compressor.py,sha256=urGF7CYv9BUWUhcf3tY64k-LCwE-LOq44BMl_NIMzU4,33337
156
+ services/compressor.py,sha256=AK31TqHFpkOD_Lqbp9aEOkOx7zENUwpsQHQ9B2imEHo,33081
156
157
  services/context_snapshot.py,sha256=2f_bxY3xX4ZC653ncYno8YSeSTFSavwBjrorytHA0Ns,16879
157
158
  services/conversation_store.py,sha256=-E2H6f9pMSOHiBjsq7tFUjrFAwWYExf7LJkibi5MRMA,35650
158
- services/doc_index.py,sha256=LTMVFsxQY5pPynGuu787433xBKMhE8KYbOrbLHooZqs,19038
159
+ services/doc_index.py,sha256=ZEQD1kyGCAMm9AY4Kpi1zDWJTul-cAcAdi_4Md43Hpw,19205
159
160
  services/e2e_benchmark.py,sha256=4pDXkOFjq5b2ZwDvMK0CWdUXQli5OE4cOh_OJs7W36c,132970
160
161
  services/e2e_evaluator.py,sha256=FsB4vMzcFUcmicfIwRtA8JZ_XeMpuk2_-86F2iVNEDc,17783
161
162
  services/e2e_tasks.py,sha256=Ln5VbGDIcS3NY3aml5ERbgjfjLxEslmRZ8AyaYxpWEo,34524
162
163
  services/edit_ledger.py,sha256=LbFDAnqAWKXsc5pUdJ8zSk5-1MNUwJduK3MO8_HUm5E,29018
163
- services/embedding_index.py,sha256=-ThnMbyMSnmMN0-iKCsMYn9hXTDlGbk7Cm75h5NMadc,14129
164
+ services/embedding_index.py,sha256=WVAmQkpvwbu2GaQfg4iPWyWiW8zQNN0ldQAo9ibsyBc,15908
164
165
  services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4139
165
166
  services/file_memory.py,sha256=ero1EY_42z0ORzZaOyvRK7Pc_OYxvgijf4BaKhEBPuY,34241
166
167
  services/git_context.py,sha256=FFdA8Y46op4DYsUnZsXLTqxIjgxb0q_d-hkFTbosC8Q,9518
167
168
  services/hub_service.py,sha256=Ta2ExJP1sePxb7zcHooroYXJKsylm5Ea8vQvts6-cAw,21876
168
- services/indexer.py,sha256=EIX7hu1x-hQIHiiUwG8R7bklZgWZu6BH5SuQsgvyyNs,27874
169
+ services/indexer.py,sha256=00y47xufHFEotljPDH4RBbWcqAHmCpTouafiggGcP7I,29511
169
170
  services/memory.py,sha256=UxlZ3BwYfTlq6Rlj5RPhNOw42bds-dSe5KAdA4TXBLE,13150
170
171
  services/memory_consolidator.py,sha256=hHMps-01cbbHSI-A1LEKCo3k2PoLOa8a7-vyYEf8DUs,19679
171
172
  services/memory_distiller.py,sha256=-07rtR3XhiZqKCUCFOcuAktfTJIbtcNqMj7rM35WXKg,26081
@@ -182,16 +183,17 @@ services/output_filter.py,sha256=lknQHs38JlRtXU4Ogoru6sveNokSKBQPfZXhf-fE0xM,217
182
183
  services/parser.py,sha256=CPoIN1FmX7kK-55FnC3jvRy3NLjlnf1armTaD6o7GBg,55203
183
184
  services/project_manager.py,sha256=LaNWqBeVMx6wcB6iiOQyRWSWhS-JXzfDApJmHFmTCy8,36477
184
185
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
185
- services/protocol.py,sha256=E0xxs43j-YSOBWalKHbX_wwqphQgDjqUjyyvTW0s6II,12247
186
+ services/protocol.py,sha256=XyUGjTHD9Zouw94IEqwH667pQ1TlroleLzlM3GRlEGk,12843
186
187
  services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
187
188
  services/retention.py,sha256=I2_RV233kWBBXox5rc_w-1h1aPua93o9huuPf-pJVuE,18629
188
189
  services/retrieval_broker.py,sha256=9X67VZ_6AkbAzopHuuMFKmP4CGZLnW576kjSKMenBnw,5261
189
190
  services/router.py,sha256=Cz10nx2fKTbaGn14mSBePWIDrw5rdcs_1JFYXeik084,15626
190
191
  services/runtime.py,sha256=b79gN0dFlzBO4rPaUMWier8FZFamMZlwIM3aywLmbB4,12840
192
+ services/scanner.py,sha256=5rMwxAO8Qchz3-GtpZTNwCQDiJIyhAMSXdwNPmTaq-U,4701
191
193
  services/session_benchmark.py,sha256=GX3H8OwKC0X9Kk5U-vfY6Y7qq9Qaz2HwadNA7mjO8RY,105047
192
194
  services/session_manager.py,sha256=qUo7ool6dDWXfVgFpokIRcWqkWh10OzMcp7aZTdmTHY,51632
193
195
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
194
- services/subprojects.py,sha256=DxD-3f4wCrwAg7F8znbvWFJf_lizgmulVDqozKGSztI,24965
196
+ services/subprojects.py,sha256=KNA-qGVPsd7Tg92Pt_kLWMLyER2YHmdQWO44sOelTIg,25123
195
197
  services/task_store.py,sha256=IHNeLipdyWtbWYu2-3ItIDeSDI9WoMKj6YwPPc1TgW0,46487
196
198
  services/telemetry.py,sha256=KbWCe7F8lGY9DufVPWGyGFo-IBcPgWh0kd3ySJnIgFE,11377
197
199
  services/text_index.py,sha256=r3o4CobTG9jAO9PWazgbWYLY9oi_FgEJ3xwEXrF4KM0,2783
@@ -224,8 +226,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
224
226
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
225
227
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
226
228
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
227
- code_context_control-2.53.0.dist-info/METADATA,sha256=MzVfgzYwfiVSTJz_uGxsENFxI-DJh1ochyB59ewxcuY,24759
228
- code_context_control-2.53.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
229
- code_context_control-2.53.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
230
- code_context_control-2.53.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
231
- code_context_control-2.53.0.dist-info/RECORD,,
229
+ code_context_control-2.54.0.dist-info/METADATA,sha256=jtQEMTmI5Z0UW8uxs6ImKZ8KKA9Fic3IrIgALMBz8zM,24759
230
+ code_context_control-2.54.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
231
+ code_context_control-2.54.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
232
+ code_context_control-2.54.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
233
+ code_context_control-2.54.0.dist-info/RECORD,,
services/compressor.py CHANGED
@@ -709,27 +709,19 @@ class CodeCompressor:
709
709
  '.css', '.html', '.json', '.yaml', '.yml', '.md'}
710
710
  allowed = set(extensions) if extensions else default_exts
711
711
 
712
- # Skip common non-essential dirs
713
- skip_dirs = {'node_modules', '.git', '__pycache__', '.c3', 'venv',
714
- 'env', '.venv', 'dist', 'build', '.next', '.cache'}
715
-
716
712
  results = []
717
713
  total_original = 0
718
714
  total_compressed = 0
719
715
  skipped_protected = []
720
716
 
721
- files = sorted(dirpath.rglob('*'))[:max_files * 3] # Pre-limit
722
- count = 0
717
+ # Pruned walk (shared SKIP_DIRS) - never descends into
718
+ # node_modules/.git/etc., stops as soon as the cap is reached.
719
+ from services.scanner import iter_files
723
720
 
724
- for fpath in files:
721
+ count = 0
722
+ for fpath in iter_files(dirpath, exts={e.lower() for e in allowed}):
725
723
  if count >= max_files:
726
724
  break
727
- if not fpath.is_file():
728
- continue
729
- if fpath.suffix.lower() not in allowed:
730
- continue
731
- if any(skip in fpath.parts for skip in skip_dirs):
732
- continue
733
725
  if self.is_protected_file(fpath):
734
726
  skipped_protected.append(self._relative_to_project(fpath))
735
727
  continue
services/doc_index.py CHANGED
@@ -92,15 +92,26 @@ class DocIndex:
92
92
 
93
93
  # --- Build ---
94
94
 
95
- def build(self, force: bool = False) -> dict:
96
- """Build or incrementally update the doc index."""
95
+ def build(self, force: bool = False, on_progress=None) -> dict:
96
+ """Build or incrementally update the doc index.
97
+
98
+ on_progress: callable(files_done, files_total), invoked per file.
99
+ """
97
100
  stats = {"docs_indexed": 0, "chunks_created": 0, "skipped": 0}
98
101
 
99
102
  files_to_index = self._discover_files()
100
103
  old_hashes = dict(self._file_hashes)
101
104
  new_hashes = {}
105
+ files_total = len(files_to_index)
106
+ files_done = 0
102
107
 
103
108
  for rel_path, fpath in files_to_index:
109
+ files_done += 1
110
+ if on_progress is not None:
111
+ try:
112
+ on_progress(files_done, files_total)
113
+ except Exception:
114
+ pass
104
115
  try:
105
116
  content = fpath.read_text(errors="replace")
106
117
  except Exception:
@@ -154,12 +165,6 @@ class DocIndex:
154
165
 
155
166
  def _discover_files(self) -> list[tuple[str, Path]]:
156
167
  """Find all doc, config, and code files to index."""
157
- skip_dirs = {
158
- "node_modules", ".git", "__pycache__", ".c3", "venv",
159
- "env", ".venv", "dist", "build", ".next", ".cache",
160
- "coverage", ".pytest_cache",
161
- }
162
-
163
168
  # Designated sub-projects keep their own doc index.
164
169
  try:
165
170
  from services.subprojects import make_excluder
@@ -170,15 +175,14 @@ class DocIndex:
170
175
 
171
176
  files = []
172
177
 
173
- # Markdown docs
174
- for ext in ("*.md", "*.mdx", "*.rst", "*.adoc"):
175
- for fpath in self.project_path.rglob(ext):
176
- if any(skip in fpath.parts for skip in skip_dirs):
177
- continue
178
- if _sub_excluded(fpath):
179
- continue
180
- rel = str(fpath.relative_to(self.project_path))
181
- files.append((rel, fpath))
178
+ # Markdown docs - one pruned walk instead of four full rglob passes
179
+ from services.scanner import iter_files
180
+ for fpath in iter_files(self.project_path,
181
+ exts={".md", ".mdx", ".rst", ".adoc"}):
182
+ if _sub_excluded(fpath):
183
+ continue
184
+ rel = str(fpath.relative_to(self.project_path))
185
+ files.append((rel, fpath))
182
186
 
183
187
  # Config files at project root
184
188
  for pattern in _CONFIG_PATTERNS:
@@ -39,6 +39,8 @@ class EmbeddingIndex:
39
39
  self._collection = None
40
40
  self._available = False
41
41
  self._ollama_ok = False
42
+ self._ollama_up = False
43
+ self._model_ok = False
42
44
  self._file_hashes: dict[str, str] = {} # doc_id -> content hash
43
45
  self._lock = threading.Lock()
44
46
  self._chunk_map: dict[str, dict] = {} # chunk_id -> metadata
@@ -101,18 +103,47 @@ class EmbeddingIndex:
101
103
  self._available = False
102
104
 
103
105
  try:
104
- self._ollama_ok = (
105
- self.ollama.is_available(timeout=2)
106
- and self.ollama.has_model(self.embed_model)
106
+ self._ollama_up = self.ollama.is_available(timeout=2)
107
+ self._model_ok = (
108
+ self._ollama_up and self.ollama.has_model(self.embed_model)
107
109
  )
108
110
  except Exception:
109
- self._ollama_ok = False
111
+ self._ollama_up = False
112
+ self._model_ok = False
113
+ self._ollama_ok = self._ollama_up and self._model_ok
110
114
 
111
115
  @property
112
116
  def ready(self) -> bool:
113
117
  """True when both chromadb and Ollama embeddings are available."""
114
118
  return self._available and self._ollama_ok
115
119
 
120
+ def probe(self) -> dict:
121
+ """Explicitly initialize backends and report readiness.
122
+
123
+ ``ready`` alone never triggers backend init (status reporters must
124
+ stay cheap for build_runtime/MCP handshake), so gating a build on a
125
+ fresh instance's ``ready`` always skipped. Init/CLI flows call this
126
+ instead: it pays the one-time backend cost, then reports truthfully.
127
+ """
128
+ self._ensure_ready()
129
+ return {
130
+ "ready": self.ready,
131
+ "chromadb": self._available,
132
+ "ollama": self._ollama_up,
133
+ "model": self._model_ok,
134
+ }
135
+
136
+ def unavailable_reason(self) -> str:
137
+ """Human-readable reason ``ready`` is False (after probe/init)."""
138
+ if not self._available:
139
+ return "chromadb not installed"
140
+ if not self._ollama_up:
141
+ return "Ollama not reachable"
142
+ if not self._model_ok:
143
+ return (f"embed model '{self.embed_model}' not pulled "
144
+ f"(run: ollama pull {self.embed_model})")
145
+ return ""
146
+
116
147
  # ── Hash tracking ─────────────────────────────────────
117
148
 
118
149
  def _load_hashes(self):
@@ -138,12 +169,14 @@ class EmbeddingIndex:
138
169
 
139
170
  # ── Build / Update ────────────────────────────────────
140
171
 
141
- def build(self, code_index, force: bool = False) -> dict:
172
+ def build(self, code_index, force: bool = False, on_progress=None) -> dict:
142
173
  """Build or incrementally update the embedding index from CodeIndex chunks.
143
174
 
144
175
  Args:
145
176
  code_index: A CodeIndex instance with populated chunks/documents.
146
177
  force: If True, re-embed all files regardless of hash.
178
+ on_progress: callable(files_done, files_total, chunks_embedded),
179
+ invoked per file (skipped files count as done).
147
180
 
148
181
  Returns:
149
182
  Stats dict with files_processed, chunks_embedded, chunks_skipped, etc.
@@ -170,6 +203,15 @@ class EmbeddingIndex:
170
203
  files_skipped = 0
171
204
  errors = 0
172
205
  stale_ids = []
206
+ files_total = len(chunks_by_file)
207
+
208
+ def _report():
209
+ if on_progress is not None:
210
+ try:
211
+ on_progress(files_processed + files_skipped, files_total,
212
+ chunks_embedded)
213
+ except Exception:
214
+ pass
173
215
 
174
216
  with self._lock:
175
217
  # Detect deleted files — remove their embeddings
@@ -187,6 +229,7 @@ class EmbeddingIndex:
187
229
  if not force and self._file_hashes.get(doc_id) == new_hash:
188
230
  files_skipped += 1
189
231
  chunks_skipped += len(file_chunks)
232
+ _report()
190
233
  continue
191
234
 
192
235
  # Remove old chunks for this file before re-embedding
@@ -237,6 +280,7 @@ class EmbeddingIndex:
237
280
 
238
281
  self._file_hashes[doc_id] = new_hash
239
282
  files_processed += 1
283
+ _report()
240
284
 
241
285
  self._save_hashes()
242
286
 
services/indexer.py CHANGED
@@ -13,6 +13,7 @@ from collections import Counter, OrderedDict
13
13
  from pathlib import Path
14
14
 
15
15
  from core import count_tokens
16
+ from services.scanner import SKIP_DIRS, iter_files
16
17
 
17
18
 
18
19
  class CodeIndex:
@@ -37,10 +38,8 @@ class CodeIndex:
37
38
  self._cooccurrence = {} # term -> {term: count} for auto-synonyms
38
39
  self._file_mtimes = {} # doc_id -> mtime for recency bias
39
40
 
40
- # Config
41
- self.skip_dirs = {'node_modules', '.git', '__pycache__', '.c3', 'venv',
42
- 'env', '.venv', 'dist', 'build', '.next', '.cache',
43
- 'coverage', '.pytest_cache'}
41
+ # Config - shared pruned-walk skip set (services/scanner.py)
42
+ self.skip_dirs = set(SKIP_DIRS)
44
43
  self.code_exts = {
45
44
  # Python
46
45
  '.py', '.pyi', '.pyx',
@@ -87,8 +86,31 @@ class CodeIndex:
87
86
  self.exclude_prefixes = []
88
87
  self._is_excluded = None
89
88
 
90
- def build_index(self, max_files: int = 500) -> dict:
91
- """Build the full code index."""
89
+ _DEFAULT_MAX_FILES = 2000
90
+
91
+ def _configured_max_files(self) -> int:
92
+ """``index_max_files`` from .c3/config.json; default 2000."""
93
+ try:
94
+ cfg = json.loads((self.project_path / '.c3' / 'config.json')
95
+ .read_text(encoding='utf-8'))
96
+ val = int(cfg.get('index_max_files', self._DEFAULT_MAX_FILES))
97
+ return val if val > 0 else self._DEFAULT_MAX_FILES
98
+ except Exception:
99
+ return self._DEFAULT_MAX_FILES
100
+
101
+ def build_index(self, max_files: int = None, on_progress=None) -> dict:
102
+ """Build the full code index.
103
+
104
+ max_files: cap on files indexed. None reads ``index_max_files``
105
+ from .c3/config.json (default 2000). Traversal continues past
106
+ the cap only to count what was left out, so callers can report
107
+ "indexed N of M" instead of silently truncating.
108
+ on_progress: callable(entries_seen, files_indexed, chunks_created),
109
+ invoked during the scan (directory granularity plus per file).
110
+ """
111
+ if max_files is None:
112
+ max_files = self._configured_max_files()
113
+
92
114
  self.documents = {}
93
115
  self.chunks = {}
94
116
  self.symbols = {}
@@ -96,24 +118,35 @@ class CodeIndex:
96
118
 
97
119
  files_indexed = 0
98
120
  chunks_created = 0
121
+ files_capped = 0
122
+ scan_stats = {"entries": 0}
99
123
 
100
- for fpath in sorted(self.project_path.rglob('*')):
101
- if files_indexed >= max_files:
102
- break
103
- if not fpath.is_file():
104
- continue
105
- if fpath.suffix.lower() not in self.code_exts:
106
- continue
107
- if any(skip in fpath.parts for skip in self.skip_dirs):
108
- continue
109
- if self.exclude_prefixes:
124
+ def _report(entries_seen=None, _yielded=None):
125
+ if entries_seen is not None:
126
+ scan_stats["entries"] = entries_seen
127
+ if on_progress is not None:
110
128
  try:
111
- if self._is_excluded(fpath.relative_to(self.project_path).parts,
112
- self.exclude_prefixes):
113
- continue
114
- except ValueError:
129
+ on_progress(scan_stats["entries"], files_indexed,
130
+ chunks_created)
131
+ except Exception:
115
132
  pass
116
133
 
134
+ exclude_parts = None
135
+ if self.exclude_prefixes and self._is_excluded is not None:
136
+ def exclude_parts(parts, _c=self._is_excluded,
137
+ _p=self.exclude_prefixes):
138
+ return _c(parts, _p)
139
+
140
+ for fpath in iter_files(self.project_path, exts=self.code_exts,
141
+ skip_dirs=self.skip_dirs,
142
+ exclude_parts=exclude_parts,
143
+ on_progress=_report):
144
+ if files_indexed >= max_files:
145
+ # Walk on (cheap after pruning) to count coverage;
146
+ # reading and chunking stop at the cap.
147
+ files_capped += 1
148
+ continue
149
+
117
150
  try:
118
151
  content = fpath.read_text(errors='replace')
119
152
  except Exception:
@@ -150,6 +183,7 @@ class CodeIndex:
150
183
  pass
151
184
 
152
185
  files_indexed += 1
186
+ _report()
153
187
 
154
188
  # Build TF-IDF and co-occurrence synonyms
155
189
  self._build_tfidf()
@@ -162,7 +196,10 @@ class CodeIndex:
162
196
  "files_indexed": files_indexed,
163
197
  "chunks_created": chunks_created,
164
198
  "unique_symbols": len(self.symbols),
165
- "index_path": str(self.index_dir)
199
+ "index_path": str(self.index_dir),
200
+ "entries_scanned": scan_stats["entries"],
201
+ "files_capped": files_capped,
202
+ "max_files": max_files,
166
203
  }
167
204
 
168
205
  def _chunk_file(self, content: str, ext: str, doc_id: str) -> list:
services/protocol.py CHANGED
@@ -219,42 +219,53 @@ class CompressionProtocol:
219
219
  self.custom_dict[term.lower()] = code
220
220
  self.save_custom_dict()
221
221
 
222
- def build_project_dictionary(self) -> dict:
223
- """Auto-build a project-specific dictionary from codebase analysis."""
222
+ def build_project_dictionary(self, code_index=None) -> dict:
223
+ """Auto-build a project-specific dictionary from codebase analysis.
224
+
225
+ code_index: an already-built CodeIndex whose in-memory chunks are
226
+ mined instead of re-reading the whole tree from disk (init
227
+ passes the indexer it just built). Without one, falls back to
228
+ a pruned, capped scan.
229
+ """
224
230
  if not self.project_path.exists():
225
231
  return {}
226
232
 
227
233
  # Find commonly used terms in the project
228
234
  term_freq = {}
229
- skip_dirs = {'node_modules', '.git', '__pycache__', '.c3', 'venv'}
230
- code_exts = {'.py', '.js', '.ts', '.tsx', '.jsx', '.r', '.R'}
231
- try:
232
- from services.subprojects import make_excluder
233
- _sub_excluded = make_excluder(self.project_path)
234
- except Exception:
235
- def _sub_excluded(_p):
236
- return False
237
-
238
- for fpath in self.project_path.rglob('*'):
239
- if not fpath.is_file() or fpath.suffix not in code_exts:
240
- continue
241
- if any(skip in fpath.parts for skip in skip_dirs):
242
- continue
243
- if _sub_excluded(fpath):
244
- continue
245
-
246
- try:
247
- content = fpath.read_text(errors='replace')
248
- except Exception:
249
- continue
235
+ code_exts = {'.py', '.js', '.ts', '.tsx', '.jsx', '.r'}
250
236
 
251
- # Extract identifiers
252
- identifiers = re.findall(r'\b[a-zA-Z_]\w{5,}\b', content)
253
- for ident in identifiers:
237
+ def _accumulate(text: str):
238
+ for ident in re.findall(r'\b[a-zA-Z_]\w{5,}\b', text):
254
239
  lower = ident.lower()
255
240
  if lower not in ACTION_CODES and lower not in TERM_CODES:
256
241
  term_freq[lower] = term_freq.get(lower, 0) + 1
257
242
 
243
+ chunks = getattr(code_index, "chunks", None) if code_index else None
244
+ if chunks:
245
+ # Sub-project exclusion already applied when the index was built.
246
+ from pathlib import Path as _P
247
+ for chunk in chunks.values():
248
+ if _P(chunk.get("doc_id", "")).suffix.lower() in code_exts:
249
+ _accumulate(chunk.get("content", ""))
250
+ else:
251
+ from services.scanner import iter_files
252
+ try:
253
+ from services.subprojects import make_excluder
254
+ _sub_excluded = make_excluder(self.project_path)
255
+ except Exception:
256
+ def _sub_excluded(_p):
257
+ return False
258
+
259
+ for fpath in iter_files(self.project_path, exts=code_exts,
260
+ max_files=1000):
261
+ if _sub_excluded(fpath):
262
+ continue
263
+ try:
264
+ content = fpath.read_text(errors='replace')
265
+ except Exception:
266
+ continue
267
+ _accumulate(content)
268
+
258
269
  # Generate codes for frequent terms
259
270
  frequent = sorted(term_freq.items(), key=lambda x: x[1], reverse=True)[:30]
260
271
  new_entries = {}
services/scanner.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Pruned Filesystem Scanner
3
+
4
+ Shared walker for every C3 index build (code index, doc index, compression
5
+ dictionary, directory compression). Replaces the sorted(Path.rglob('*'))
6
+ pattern, which enumerated every entry under node_modules/.git/venv before
7
+ filtering and materialized the whole tree up front - on large projects that
8
+ meant minutes of stat calls before the first file was even considered.
9
+
10
+ os.walk with in-place dirnames pruning never descends into skipped
11
+ directories, yields files in deterministic order, exits as soon as the
12
+ caller stops consuming, and reports progress so long scans are visible.
13
+ """
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Callable, Iterator, Optional, Set, Tuple
17
+
18
+ # Superset of the historical per-service skip lists.
19
+ # Matched against directory names exactly (never file names).
20
+ SKIP_DIRS = {
21
+ # original shared set
22
+ 'node_modules', '.git', '__pycache__', '.c3', 'venv', 'env', '.venv',
23
+ 'dist', 'build', '.next', '.cache', 'coverage', '.pytest_cache',
24
+ # heavyweights the old lists missed
25
+ 'target', '.tox', '.nox', '.eggs', '.mypy_cache', '.ruff_cache',
26
+ '.gradle', 'Pods', 'obj', '.idea', '.vs', '.svn', '.hg',
27
+ 'bower_components', '.terraform', '.parcel-cache', '.turbo',
28
+ '.nuxt', '.yarn', '.pnpm-store',
29
+ }
30
+
31
+
32
+ def gitignore_dir_names(root) -> set:
33
+ """Literal directory names from the root .gitignore (best-effort).
34
+
35
+ Only unambiguous entries are used - a bare name or ``/name/`` with no
36
+ wildcard, negation, or nested separator - so pruning can never be
37
+ broader than the ignore file itself. Data/log directories are exactly
38
+ what makes large-project scans hang, and they are almost always
39
+ plain-name entries.
40
+ """
41
+ names = set()
42
+ try:
43
+ text = (Path(root) / '.gitignore').read_text(errors='replace')
44
+ except OSError:
45
+ return names
46
+ for line in text.splitlines():
47
+ line = line.strip()
48
+ if not line or line.startswith('#') or line.startswith('!'):
49
+ continue
50
+ if any(ch in line for ch in '*?['):
51
+ continue
52
+ cleaned = line.strip('/')
53
+ if cleaned and '/' not in cleaned and '\\' not in cleaned:
54
+ names.add(cleaned)
55
+ return names
56
+
57
+
58
+ def iter_files(
59
+ root,
60
+ exts: Optional[Set[str]] = None,
61
+ skip_dirs: Optional[Set[str]] = None,
62
+ exclude_parts: Optional[Callable[[Tuple[str, ...]], bool]] = None,
63
+ max_files: Optional[int] = None,
64
+ on_progress: Optional[Callable[[int, int], None]] = None,
65
+ respect_gitignore: bool = True,
66
+ ) -> Iterator[Path]:
67
+ """Yield candidate files under ``root`` with directory-level pruning.
68
+
69
+ Args:
70
+ exts: lowercase suffix allowlist (None = every file).
71
+ skip_dirs: directory names to prune (default: SKIP_DIRS).
72
+ exclude_parts: predicate over project-relative path parts; True
73
+ skips a file, and prunes whole directories before descent
74
+ (used for sub-project exclusion).
75
+ max_files: stop traversal entirely after yielding this many.
76
+ on_progress: called as ``on_progress(entries_seen, files_yielded)``
77
+ at directory granularity - cheap enough to wire straight to a
78
+ TTY progress line (which should throttle by time).
79
+ respect_gitignore: also prune literal directory names listed in
80
+ the root .gitignore.
81
+ """
82
+ root = Path(root)
83
+ skip = set(SKIP_DIRS if skip_dirs is None else skip_dirs)
84
+ if respect_gitignore:
85
+ skip |= gitignore_dir_names(root)
86
+
87
+ yielded = 0
88
+ seen = 0
89
+ for dirpath, dirnames, filenames in os.walk(str(root), topdown=True,
90
+ followlinks=False):
91
+ try:
92
+ rel_parts = Path(dirpath).relative_to(root).parts
93
+ except ValueError:
94
+ rel_parts = ()
95
+
96
+ kept = []
97
+ for d in sorted(dirnames):
98
+ if d in skip:
99
+ continue
100
+ if exclude_parts is not None and exclude_parts(rel_parts + (d,)):
101
+ continue
102
+ kept.append(d)
103
+ dirnames[:] = kept
104
+ seen += len(filenames) + len(kept)
105
+
106
+ for fname in sorted(filenames):
107
+ if exts is not None:
108
+ if os.path.splitext(fname)[1].lower() not in exts:
109
+ continue
110
+ if exclude_parts is not None and exclude_parts(rel_parts + (fname,)):
111
+ continue
112
+ yield Path(dirpath) / fname
113
+ yielded += 1
114
+ if max_files is not None and yielded >= max_files:
115
+ if on_progress is not None:
116
+ on_progress(seen, yielded)
117
+ return
118
+
119
+ if on_progress is not None:
120
+ on_progress(seen, yielded)
services/subprojects.py CHANGED
@@ -587,7 +587,9 @@ class SubprojectManager:
587
587
  ollama = OllamaClient(cfg.get("ollama_base_url", "http://localhost:11434"))
588
588
  ei = EmbeddingIndex(self.parent_path, ollama,
589
589
  embed_model=cfg.get("embed_model", "nomic-embed-text"))
590
- if ei.ready:
590
+ # probe() initializes lazy backends; a fresh instance's
591
+ # .ready is always False and skipped this unconditionally.
592
+ if ei.probe()["ready"]:
591
593
  detail["embeddings"] = ei.build(indexer, force=True)
592
594
  except Exception:
593
595
  pass # embeddings are best-effort; index/doc exclusion is the contract